Compare commits

..
67 Commits
Author SHA1 Message Date
Pouzor ad0b1b427d chore: bump version to 3.3.4
ha-relevant: no
2026-08-17 19:19:25 +02:00
Pouzor 4c460cec1c fix(scan): stop a re-scan from deleting a device's hand-added services
Since 3.3.0 the inventory row is the only copy of a device's services and
every canvas drawing it reads that list, but the scanner still assigned
`keep.services = fingerprint_ports(...)` — the pre-split behaviour, when a
node held its own copy. One re-scan therefore deleted every service the user
added by hand, on every canvas at once, and brought back the ones they had
deleted. It unions now, like every other writer of that field.

Two more things #347 turned up:

* A node whose view matches nothing the row still holds drew nothing at all —
  the row had been replaced under it, so every key was gone and every key was
  new, and `apply_view` hid the lot. Such a view says nothing about the list
  that replaced it, so it is treated as no view: the row is drawn. An empty
  view is untouched, being a real answer ("this canvas draws none of them").

* The 3.3.3 view seed recovers a node's arrangement from the pre-3.3.0 backup,
  which is 3.2.0-era and cannot know about a property added afterwards. On
  3.3.0-3.3.2 the row was the only place to add one and every canvas drew it,
  so seeding strictly from the backup took it off all of them at once. Those
  are appended visible, keeping the recovered order and hidden flags for
  everything the backup does know. Services keep the strict recovery: holding
  back what a scan fingerprinted is the whole point of the view.

ha-relevant: maybe
2026-08-17 19:05:13 +02:00
Pouzor 1aa88de491 chore: bump version to 3.3.3
ha-relevant: no
2026-08-17 14:09:01 +02:00
Pouzor d0679ad72b fix(inventory): stop the view seed from re-reading the backup every boot
The seed selected any node with a `device_id` and no view. Deleting a device
leaves that column dangling — SQLite runs with foreign keys off, so the
`ON DELETE SET NULL` never fires on an existing table — and such a node can be
seeded from nothing, so it stayed selected. The query matched it again on every
later start, re-opening the pre-upgrade backup and logging a recovery that
recovered nothing.

Narrowing the query to nodes whose row actually exists restores the invariant
the seed is documented to have: it runs on one boot, and never opens a file
again.

ha-relevant: no
2026-08-17 14:06:09 +02:00
Pouzor 5df70ce128 feat(canvas): give each node its own view of a device's services and properties
Since 3.3.0 the inventory row owns a device's services and properties, and
every node drawing that device rendered the row wholesale. One row shared by
several canvases meant one rendering: a service the scanner fingerprinted
appeared on every canvas at once (users reported Uptime Kuma and Synology DSM
on hosts running neither — both are port-only signatures), and a property added
on one schematic showed up on all the others.

Order and visibility are presentation, so they move to the node. `display_view`
records, per node, which of the row's services and properties it draws and in
what order, keyed by `port|protocol|name` and by lowercased property key so the
view survives an edit to the fact itself. The facts stay on the row: hiding is
per node, deleting is still device-wide.

An item the view does not list is reported hidden rather than dropped, so what
a scan finds next is one toggle away instead of pushed onto every canvas.

The wire shape is unchanged. A client already sends its lists in display order
with their `visible` flags, so the view is read back out of them rather than
asking for a second field, and `visible` is only stamped when something is
hidden.

Upgrades keep what each canvas showed:

* from 3.2.0, the backfill seeds each node's view from its own legacy columns
  before they are dropped;
* from 3.3.0-3.3.2 those columns are gone and the row holds the union of every
  canvas, so the layout is recovered from the backup `_backup_db` took before
  the 3.3.0 migration — the newest one whose `nodes` table still has the
  columns, read read-only and matched by node id;
* with no usable backup the row is the seed, so every canvas keeps showing
  exactly what it shows today and only later additions are held back.

Refs #347

ha-relevant: maybe
2026-08-17 14:06:09 +02:00
Pouzor a58725c2ee chore: bump version to 3.3.2
ha-relevant: no
2026-08-17 11:37:54 +02:00
Pouzor 466df59b0c fix(inventory): finish the 3.3.0 backfill on databases carrying timestamps
3.3.1 kept `nodes` writable when the backfill failed, but the backfill still
failed — so a canvas upgraded from 3.2.0 shows every device stripped of its ip,
services, hostname, notes and hardware. The facts are intact in the legacy
columns; nothing was ever reading them across.

The backfill reads those columns with raw SQL, so SQLite hands `last_seen` and
`last_scan` back as text. Writing text into a DateTime column raises
`TypeError: SQLite DateTime type only accepts Python datetime and date objects`,
which is neither IntegrityError nor OperationalError — so it went straight past
the per-node savepoint and aborted the whole run, exactly as before.

- Parse the legacy timestamps back into datetimes, the way `_decode_json`
  already handles the JSON columns. An unparseable stamp becomes None rather
  than an error: the status checker refreshes both within a minute.
- Catch every exception per node, not two chosen classes. Picking the exception
  types was the defect; a node that fails for any reason must cost only itself.

The same run would also have destroyed data. A canvas saved while an earlier
migration was stuck minted a blank inventory row from a UI that had no facts to
show and linked the node to it. The backfill only looked at unlinked nodes, so
it skipped that one, counted the canvas migrated, and dropped the columns
holding its only copy of the ip and services.

- Read linked nodes too, and fill only what their row is missing. A row that
  already holds a fact is never overwritten, so an edit made after the migration
  survives, and the pass stays a no-op once everything has moved across.

Verified end to end against a rebuilt 3.2.0 database stuck the way the reports
describe: one boot restores ip, services, hostname, mac, notes, check method,
hardware, properties, status and last_seen, then drops the legacy columns.

Refs #347, #348, #351

ha-relevant: no
2026-08-17 11:36:32 +02:00
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
Pouzor ecc3cb8364 chore: bump version to 3.2.0
ha-relevant: no
2026-08-10 00:40:24 +02:00
Pouzor 9954fccf75 feat(rack): link a mount to any Device Inventory entry
The rack's link picker read `/nodes`, so it only ever offered devices
someone had already approved onto a logical canvas — two rows on a homelab
holding 74 inventory entries. A device on no canvas is still the record of
a real box, and is exactly what a rack is built out of.

`DevicePickerModal` replaces `NodePickerModal` and lists the Device
Inventory itself. Picking an entry calls the new `relinkDevice`, which
repoints the mount's `deviceId`, adopts that entry's node, status and —
unless the user renamed the plate — its label. One entry, one mount: a row
another plate stands for is not offered, and the store refuses it anyway.
The placeholder a rack-created plate left behind is dropped through the new
`DELETE /api/v1/scan/pending/{id}`, which refuses a device a rack still
mounts (409): foreign keys are off at runtime, so the mount would be left
naming a row that no longer exists.

`LinkedDevicePanel` becomes "Linked device" and now prints what discovery
found even when nothing on a canvas answers for the device; only the
canvas-side rows go missing, under a "Not on a logical canvas." note.

Also renames `pending_devices` to `device_inventory` (and
`pending_device_links` to `device_inventory_links`), with the Python and
TypeScript names that followed it. "Pending devices" was the scanner's word
for a queue of finds awaiting approval; the rows outlive approval, are
edited by hand and are what a rack mounts. Routes, payload keys and MCP
tool names are a published contract and are unchanged — `/scan/pending/*`
and the `pending_devices` key in `/stats` stay as they are.

The rename migration runs before `create_all`, or an empty new table would
be created beside the populated old one and every scanned device would read
as gone; it repairs that state too, for anyone whose app already started
mid-upgrade. Foreign keys are switched on for the rename so SQLite rewrites
the `REFERENCES` clause in `rack_devices`.

ha-relevant: maybe
2026-08-10 00:23:10 +02:00
Pouzor c263f839a8 fix(rack): stamp the linked node's last-seen with an offset
SQLite has no timezone type: an aware `last_seen` reads back naive, and
Pydantic then put it on the wire as `2026-08-08T10:00:00`. To the browser
that is *local* time, so the Logical view panel printed a timestamp
shifted by the viewer's UTC offset.

ha-relevant: no
2026-08-10 00:23:10 +02:00
Pouzor e22152d2d3 feat(rack): show the logical view of a mounted device
The Edit Device modal left the column under the port list empty, while
the logical canvas already held every technical fact about the same box.

A mount that stands for a Device Inventory entry now prints them there:
canvas name, type, hostname, IP, MAC, OS, the status check the node runs,
the canvas it is drawn on, when it was last seen, and the services
discovery fingerprinted on it. Read-only — the logical view owns them.

`/racks/inventory` ships the node half as `node_*` alongside the
inventory row's own mac/hostname/os/services, and the panel prefers the
node value: the node is what the user curates, the inventory row is what
discovery last saw and goes stale after a rename or a DHCP move. Rows
neither side can fill are dropped, so a device on no canvas still shows
what was reported and an accessory shows no panel at all.

The new API fields are optional client-side, so an older backend reads
back as "not on a canvas" rather than an empty node.

ha-relevant: maybe
2026-08-10 00:23:10 +02:00
Pouzor 57df3b9198 fix(rack): stop a blanked height field from shrinking the rack
Three from the third review pass, all in code this branch added.

Number('') is 0, so `Number(draft) || MIN_RACK_U` turned a select-all +
Delete into `uHeight: 1` on blur. A rack with mounts refused it and
showed a misleading toast; an empty one — the just-added case, 24U by
default — took it silently, with no undo. An empty or unparseable field
is now "no edit".

CableLayer returned null on `visibility === 'hidden'` before the
per-cable filter that deliberately keeps the selected run drawn ever
ran, so switching the header select to Hidden left RackCablePanel open
over a cable nothing showed. Hidden now still draws the selection —
and nothing else, not even the hover reveal.

PropertyList tracked the open edit form by array position while the
array belongs to the caller. Removing or reordering an earlier row
slid the form onto its neighbour and Save overwrote the wrong
property. The form closes on any mutation from outside it. This one
predates the branch — it moved verbatim out of DetailPanel — but the
extraction doubled its reach to the cable panel, and the component had
no tests. It has ten now.

ha-relevant: maybe
2026-08-09 20:35:23 +02:00
Pouzor 5617f0800f fix(rack): commit height and colour edits on blur, not per keystroke
The rack height field called updateRack on every character. Since that
relocates the mounts a shrink pushes past the top rail, and the rack
canvas has no undo, typing rearranged the rack behind the user:
backspacing "24" leaves "2", every mount is relocated to the bottom,
and typing "4" back restores the height but not the layout. On a full
rack it fired the refusal toast twice per edit instead.

The height and the cable colour hex now keep a local draft and commit
on blur or Enter. A colour that does not parse falls back to the cable
type's default, rather than handing the SVG stroke a half-typed "#39d"
and leaving the run invisible.

Three more from the same review:

- Delete rack asks first, naming how many mounts it takes with it. It
  drops every mount and every cable touching them, with no undo.
- save() takes the design id the caller means to write. The
  design-switch flow saves the old design while the store may already
  hold the new one; it now refuses instead of persisting the wrong one
  and dropping the other's edits.
- fromRackDevice clamps col_span against col_start rather than against
  the grid alone. 11 + 12 is two legal fields spanning to column 23 of
  12, which the new backend validator rejects — one device 422'd the
  whole save.

ha-relevant: maybe
2026-08-09 20:35:23 +02:00
Pouzor cd6402a680 fix(rack): keep a rack height edit from orphaning its mounts
The number input's min/max are hints the browser does not enforce on a
typed value, and updateRack wrote the patch straight through. Two ways
out of a usable canvas:

- Shrinking below a mounted device left the plate drawn above the
  chassis, outside the React Flow node, with nothing in the UI able to
  drag it back. freeUnits only counts 1..uHeight, so the modal's "used
  of" label under-counted it in silence.
- A height over 100 made RackSave reject every save with a 422, so both
  the explicit Save and each autosave tick reported "Save failed" with
  no cause.

updateRack now clamps to [MIN_RACK_U, MAX_RACK_U] and relocates the
mounts a shrink pushes past the top rail, one at a time so two never
land on the same slot. It returns false — changing nothing — when one
has nowhere to go, and the modal says so.

Two server-side guards that would have contained it: RackSaveRequest
cross-checks every mount against the rack it names, and RackDeviceSave
checks col_start + col_span against the grid, which each field passing
its own bounds never caught.

Also normalizes the MAC on POST /scan/pending. Every other write path
canonicalizes it and dedup compares by equality, so a hand-typed
AA-BB-CC-11-22-33 never matched the scanned aa:bb:cc:11:22:33 and
approve built a duplicate node.

ha-relevant: maybe
2026-08-09 20:35:23 +02:00
Pouzor 4d2128c494 feat(rack): document a cable and print it on the canvas
Clicking a cable — in patch mode or out of it — selects it and opens a
right rail. It carries the physical facts (type, colour, label) and
`properties`: the same NodeProperty records the logical canvas already
uses for nodes, each with its own "show on canvas" eye. What is ticked
visible is drawn on a small plate at the midpoint of the run, so an
export carries its lengths and VLANs.

The property editor moves out of DetailPanel into
components/common/PropertyList so both canvases share one implementation
rather than growing a second.

Persistence: rack_cables gains label_visible and properties, added by an
idempotent migration in _try_migrate; the response schema coerces the
NULLs legacy rows read back as.

Also drops the 40 % plate fade that came with cables-on. It let the rail
strips and the U grid show through the mounted gear, which read as a
rendering bug — cables are drawn above the plates anyway.

ha-relevant: maybe
2026-08-09 20:35:23 +02:00
Pouzor 84360ffc50 chore(deps): bump js-yaml to 4.3.1 for CVE-2026-59870
js-yaml 4.0.0 through 4.3.0 resolve !!omap in quadratic time
(GHSA-5p4m-2wfm-xmqj, high). The advisory landed after this branch's last
green run and fails `npm audit --omit=dev --audit-level=high` on main too.

4.3.1 is the patched 4.x, so the existing caret range covers it and the YAML
import/export code is untouched. Staying on 4.x avoids the 5.x API change,
which belongs in its own bump.

ha-relevant: no
2026-08-09 20:35:23 +02:00
Pouzor 56df462a8f ci: stop the secret scan reporting pytest names as Lob keys
The push that carried the whole branch through TruffleHog failed on a
"verified" Lob credential: `test_updates_an_existing_device_in_place`, a test
function in backend/tests/test_racks.py. Lob's detector matches any
`test_`-prefixed identifier of that length and reports it verified, because
Lob test-mode keys authenticate unconditionally — so verification proves
nothing here.

Lob is a direct-mail API this project does not use, and any pytest function
named that long trips it again, so the detector is excluded rather than the
test renamed.

ha-relevant: no
2026-08-09 20:35:23 +02:00
Pouzor 06ccd354c5 fix(rack): make Import links idempotent instead of one-shot
networkImportDone lived in memory only. loadDesign starts from emptyState and
the flag was in neither the save payload nor standaloneStorage, so a reload
re-armed the import: the second run found the original ports taken, walked on
to the next free pair, and drew a duplicate cable for every logical link — once
per reload.

The device pair is the guard now, and it survives a reload because the cables
do: a pair already patched is skipped whatever ports carry it. The flag and the
disabled button go with it, which also makes the feature better — run it again
after racking more gear and it adds only what is missing.

While in there, the import prefers a port whose type matches the link: a fibre
edge landing on two RJ45 jacks drew an amber run across copper. Any free port
is still used when the plate has no matching one.

Sidebar's rack branch gains the tests it shipped without: the view/action swap,
the hidden discovery actions, + Device opening the editor, the capacity footer,
and the unsaved badge reading the rack store rather than the canvas one.

ha-relevant: no
2026-08-09 20:35:23 +02:00
Pouzor c63bfd3859 fix(rack): keep Import links usable after a run that matched nothing
importCablesFromNetwork latched networkImportDone whatever the outcome. Click
Import links before racking anything and the toast says "No matching link found
— rack the devices first", then the button is disabled for the rest of the
session: it asks for a retry and forbids it. Only a design switch or a reload
cleared the flag.

It now latches on success only.

ha-relevant: no
2026-08-09 20:35:23 +02:00
Pouzor 2af6f728d7 docs(rack): document the rack canvas for users
The rack canvas shipped with a developer reference under frontend/src/rack and
nothing a user would find: the README did not contain the word rack, and
FEATURES.md jumped from Multiple Canvases to Customize Style.

docs/rack-canvas.md covers it end to end — creating the canvas, rack settings,
the three ways to mount something, the placement rules, the faceplate catalog,
ports and patching, the Check device status, saving, and the known limits.
FEATURES.md gains it as section 5, beside the other canvas features rather than
appended after Authentication, so sections 5 to 18 shift by one; the README gets
a nav entry and a short section pointing at the full page.

Marked as working without a backend, since a rack canvas persists to
localStorage in standalone; the inventory picker, the status check and the link
import stay flagged as full mode.

ha-relevant: no
2026-08-09 20:35:23 +02:00
Pouzor b1186bd7dd fix(rack): drop the cable type filter, and dress the visibility select like the header
Two native <select> elements sat in a header made of ghost buttons, and they
looked it: their own border and background, an OS chevron, an unthemed popup.

The type filter goes entirely — state, action and the CableLayer branch that
read it, plus CABLE_TYPE_LABELS which had no other consumer. Filtering the
cabling down to copper or fibre answered a question nobody asks of a rack
they are looking at; the type still follows the port a patch starts from,
which is where it matters.

The visibility control becomes the shared ui/select, with a trigger sized and
coloured like the buttons beside it — transparent until hovered — and an icon
per option: pointer for on-hover, eye for always, crossed eye for hidden.

Tests cover the trigger reflecting the active visibility, a pick writing it to
the store, and CableLayer drawing ethernet and fibre alike. base-ui commits a
selection on a real pointer sequence rather than a bare click, so that one goes
through userEvent.

ha-relevant: no
2026-08-09 20:35:23 +02:00
Pouzor a70fc248bf feat(walkthrough): introduce the rack canvas, and keep Next inside the card
The Getting Started tour stopped at the logical canvas, so the third design
type was invisible to anyone who had not gone looking for New Canvas → Kind.
A step between grouping and styling now points at the canvas switcher and
says what a rack canvas is for. It is `mode: 'all'` — a rack needs no backend,
it persists to localStorage in standalone like any other canvas — and it
anchors on the switcher rather than a rack control, since the tour runs on a
network canvas where none of the rack sidebar items exist.

That twelfth step also broke the card footer. The progress strip grows with
the step count and neither footer child could shrink (`min-width: auto`), so
at twelve dots the strip pushed the counter and both buttons past the right
edge of the 320px card — Next ended up outside it. The strip is now the part
that gives: it shrinks and clips, the controls hold their size, and Next stays
anchored whatever the step count.

ha-relevant: maybe
The overlay fix applies to any build of the frontend; the rack step itself is
pointless until the rack canvas is ported.
2026-08-09 20:35:23 +02:00
Pouzor ff740f7466 feat(rack): patch by dragging port to port, and select a cable before deleting it
Cabling was a two-click flow with no way back: the only way to remove a patch
was to click it, which deleted it outright, and clicking a port was the only way
to start one.

- Drag from a port to another to patch, with a dashed rubber band following the
  pointer. Clicking one port then another still works; Escape drops a
  half-drawn patch.
- A click on a cable now selects it (accent halo, fat invisible hit area);
  Delete/Backspace or the new header Unplug button removes it.
- Leaving patch mode restores the cable visibility it was entered with, instead
  of stranding the canvas on "always" with every plate faded until a reload.
- Draw every port at one fixed size — the 1U switch/patch-panel socket — rather
  than scaling it with plate height. Drops the now-unused template portSize.

ha-relevant: no
2026-08-09 20:35:23 +02:00
Pouzor 3138cda378 feat(rack): follow a node's status check, and type rack-created gear
A mount's Status select gains "Check device": instead of a colour frozen at
mount time, it follows the status check already configured on the matching
logical-canvas node (ping, http, ssh…). The rack runs no checker of its own,
so `auto` resolves through the Device Inventory's `node_status`, falls back to
`unknown` when nothing is behind it, and is only offered when the mount — or
the entry being picked — resolves to a node. Losing that link drops the mount
back to `unknown` rather than leaving it on a status nothing can answer.
`useAutoStatusRefresh` polls the inventory every 60s while at least one mount
follows a node, and only then; it writes inventory only, so autosave stays
quiet.

A device created from the rack canvas also lands in the Device Inventory with
a type now. Nothing discovers those rows, so the plate they wear is the only
thing that says what they are — without it they had no icon, no role badge and
no place in the type filter. Passive gear uses the rack-only kinds
`patch_panel` and `pdu`; the latter is deliberately not `socket`, which is
excluded from the rack inventory and would make a PDU vanish on creation.

ha-relevant: maybe
2026-08-09 20:35:23 +02:00
Pouzor 0640704678 fix(rack): draw desktop NAS towers at a size a disk actually is
The drive stack stretched to fill the plate, so the 2-bay box wore two doors
twice as wide as the 5-bay one's — a 3.5" disk is the same disk in every
chassis. The stack now grows with the bay count instead: `nasBays` sizes it
from a fixed per-door width, and scales that back through `colSpan`, since unit
coordinates are fractions of the plate and a narrower plate needs bigger
fractions to draw the same door.

Height went to 5U for all three. A U draws at 24px against a 456px inner width,
so the canvas squashes the vertical axis about 1.8x: a tower that is 3U in
metal renders as a squat box, and its doors read as squares. The picker looked
right all along because it draws a plate at its own aspect, not the rack's.

The 2-bay is a sixth of the rack rather than a quarter — 76x120px at 19", close
to the proportions of the real thing. That is a new plate width, so the modal's
Width select and the picker's width labels learned it; a colSpan with no
matching option leaves the select blank. A test now walks the catalog and
asserts every plate width is offered.

ha-relevant: maybe
2026-08-09 20:35:23 +02:00
Pouzor b129200929 feat(rack): pick a mount from the Device Inventory, filtered to rackable gear
The rack device modal listed inventory entries in a flat <select>: one line per
device, no search, no filters, nothing to tell two look-alike hosts apart. It
now opens the Device Inventory modal itself in a picker mode — a card click
returns the device instead of approving it, and the bulk/clear controls (plus
the s/a/Enter shortcuts) are hidden, since a picker has no business deleting
anything.

That list also carried VMs, containers and mesh devices, which no rack can
hold. A new "Rackable" filter drops them, armed by default when the rack opens
the modal. It is an exclusion list, not an allow list, so new hardware types
show up by default and an unclassified device (a bare ARP hit) stays visible.
`utils/rackable.ts` mirrors `_UNRACKABLE_TYPES` in racks.py — the list the
inventory endpoint already filtered on — and test_rackable_sync.py fails the
backend suite on drift.

The rack side keeps the last word: a device missing from its own inventory, or
already mounted in this design, is refused by name rather than mounted as a
ghost. Nothing is preselected anymore either, so submitting without picking
says so instead of quietly mounting whichever entry happened to be first.

Standalone keeps the <select>: the inventory modal reads pending_devices over
REST and there is no backend there.

The modal itself was max-w-md and one column, which pushed the port list below
the fold. It now matches NodeModal's width with the fields on the left and the
ports on the right.

ha-relevant: maybe
2026-08-09 20:35:23 +02:00
Pouzor e1f8850d1f fix(rack): guard rack rows against cross-design saves, and half-applied plates
Four more defects from the branch review.

`POST /racks/save` fetched every row by primary key alone and then wrote
`design_id` from the payload, so a save on design B carrying an id that belongs
to design A moved A's rack — devices and cables with it — into B, with no error
and no way to notice. Ids come from the client, so a copied design or a stale
tab is enough. `_owned` now refuses a row owned by another design with a 409.

`commitEdit` dropped `applyFaceplate`'s return. Picking a plate the rack has no
room for, then shrinking the height by hand, let `updateDevice` succeed on its
own and `setPorts` write the new plate's ports onto the old faceplate — the
device ended up wearing a plate it did not have, silently. The failure is now
reported and nothing is committed.

Two smaller ones. The inventory select held a dead id after the list was
refetched, so submitting reported "No free slot in this rack" when the real
cause was an entry that had been racked elsewhere or deleted; it now falls back
to its placeholder and says so. And both create paths patched the form's own
geometry over the slot `findSlot` had just chosen, undoing a relocation the user
was never shown — the mount is handed the whole geometry up front, and only the
overrides are patched afterwards.

ha-relevant: maybe
2026-08-09 20:35:23 +02:00
Pouzor 9bf90cbbed fix(rack): draw the desktop NAS plates like the boxes they are
The first cut put the drive bays in a squat block across the top half and left
the name across the middle, which looked nothing like a UGREEN DXP or a
Synology DS: their front is two to five tall tray doors filling the height, over
a thin strip carrying the badge, the LED and the sockets.

The name band was the blocker — it was nailed to mid-height for every plate, so
anything tall wore its name across its own artwork. `labelBox.y` (default 0.5,
so rack gear is untouched) now places the band, and the status LED rides it.
The NAS plates put it at 0.86 with the trays above and the ports on the same
strip.

Bay corners also scaled badly: a flat 1.5px radius reads as a square hole once
the cells are as large as a 3U tray, so the radius now follows the cell size.

ha-relevant: yes
2026-08-09 20:35:23 +02:00
Pouzor 7067bd76d4 feat(rack): add desktop NAS faceplates in 2, 4 and 5 bays
A UGREEN DXP or a Synology DS is not rack gear: it sits on a shelf, taking
about a third of the width and standing some 3U tall, with its drive trays side
by side rather than in a grid. The catalog had no shape for that — a homelab
NAS had to borrow the 2U rack-mount plate.

Trays fill the upper half, one column per disk. The name band and the ports
share the lower half side by side, since the name is always drawn at mid-height
whatever the U count. Ports follow the class: one RJ45 on the 2-bay, two on the
4-bay, two plus an SFP+ on the 5-bay.

`suggestFaceplate` still proposes the rack-mount `nas-2u` for a discovered NAS;
which of the two a scan should assume is a separate question.

ha-relevant: yes
2026-08-09 20:35:23 +02:00
Pouzor 8cafeee743 fix(rack): stop the device modal losing cables, status and inventory rows
Four defects found reviewing the last two commits.

Reverting a faceplate choice took every cable on the device down with it.
Browsing to another plate and back left `plateChanged` set with freshly seeded
port ids, while `commitEdit` skipped `applyFaceplate` because the id matched —
so `setPorts` received ids the device never had and dropped every cable
attached to the old ones, with no warning, since from the user's side the plate
had not changed. Coming back to the device's own plate now restores its ports,
ids included.

The inventory select preselects the first unracked entry, but only
`pickInventory` seeded the status from it, so submitting without touching the
select wrote `unknown` over the status the mount had just copied. Status now
seeds from that same entry.

Creating a device POSTs to the Device Inventory before the mount is attempted,
so a rack with no free slot stranded the row — and stranded another on every
retry. The rack is checked for a slot before anything is created.

And `_service_name` accepted a service with no port, where the inventory
requires one (`None not in _COMMON_PORTS` is True): a portless service named a
device in the rack picker but not in the Device Inventory, the exact mismatch
the naming fix set out to remove.

ha-relevant: maybe
2026-08-09 20:35:23 +02:00
Pouzor cfa296ef87 fix(rack): name inventory entries the way the Device Inventory does
The rack picker listed "192.168.1.63 · 192.168.1.63" where the inventory
showed "jellyfin", so a device the user picked out by one name turned up here
under another.

Two causes. The inventory route labelled an entry
`friendly_name or hostname or ip or id`, skipping the app-name step: `_device_label`
now mirrors `deviceLabel` in PendingDevicesModal — friendly name, host, the app
its ports say it runs (ssh/http/https ignored, generic web deprioritized), IP,
IEEE, id. And the option line appended the IP unconditionally, doubling it
whenever the label already was the IP; it now adds the IP only when it says
something new, plus the type as the tiebreaker between look-alikes.

Also fixes the port row: the name field and the type select both carried
`w-full` from the shared input class, so the select took the row and the name
field collapsed into an unlabelled box.

ha-relevant: maybe
2026-08-09 20:35:23 +02:00
Pouzor c6c7624084 feat(rack): pick faceplates from a visual catalog
A plate is a drawing, not a name — "Switch 24 ports + 2 SFP" in a <select>
tells you far less than seeing the plate. The Faceplate field now previews the
current plate and opens FaceplatePicker: every template drawn with the canvas
renderer, grouped by category, searchable by name or category, and filtered by
kind so accessories are offered only to accessories. Switching the device
source between device and accessory swaps the plate to a default of the other
kind.

Each tile scales the plate to the tile width and keeps its fraction of the rack
(a half-width plate draws half as wide), so sizes compare honestly; the meta
line states U height, width and port count.

Two layout notes worth keeping: DialogContent carries `sm:max-w-sm`, which is
media-scoped and beats a plain `max-w-*`, so the override has to be prefixed;
and an SVG is a flex item that shrinks but never grows, so the plate is drawn
at a nominal size and scaled up by CSS instead of sized in pixels.

ha-relevant: no
2026-08-09 20:35:23 +02:00
Pouzor e2da642554 feat(rack): edit devices in a modal, file rack gear in the Device Inventory
The rack canvas loses its right rail: a mount is now edited in its own dialog,
like a network node, and the canvas keeps the full width.

Devices:
- `RackDeviceModal` adds and edits a mount — label, faceplate, U position,
  height, column, width, status, colour, ports, unmount. Adding offers three
  sources: an existing Device Inventory entry, a new device created here, or a
  rack-only accessory. Opened by `+ Device` in the left rail or a double-click
  on a plate.
- `RackSettingsModal` takes over rack chrome (name, location, capacity, 19"/10",
  numbering, colours, delete), on a double-click of the chassis.
- The left rail's `+ Rack` becomes `+ Device`; `+ Rack` stays in the header.

Inventory:
- Gear created from a rack canvas lands in the Device Inventory with
  `discovery_source: "rack"`, so it inherits the existing search, filters, hide
  and delete instead of a parallel list in the sidebar. New "Rack devices"
  source filter and badge.
- It is never placed on a logical canvas: approve returns 409, bulk-approve
  skips it with `match: "rack"`, and the UI drops the Approve button and reports
  the skip.
- `InventoryTray` becomes `AccessoryTray` — blanks, shelves and cable managers
  only, still a drag source.

Fixes:
- A taller faceplate (or a hand-typed height) no longer silently keeps the old
  geometry when it collides: the device relocates to the nearest slot that takes
  the new size, and only a rack with no such slot refuses the edit. This is why
  a 1U and a 2U plate rendered at the same height and the field looked locked.
- The empty-state buttons sat under `.react-flow__renderer` (z-index 4), so the
  pane swallowed their clicks as a canvas drag.

Tests: rack device/settings modals, empty-state stacking, relocate-vs-refuse in
the store, rack source bucketing, inventory approve gating (frontend + backend).

ha-relevant: maybe
The modal-based editors and the rack store are portable; the inventory tagging
rides on the standalone FastAPI scan routes. Discuss before porting.
2026-08-09 20:35:23 +02:00
Pouzor a0d16e1454 feat(rack): integrate the rack canvas as a design type
The rack prototype lived behind /racklab with no backend. It now becomes a
third design_type alongside network and electrical, sharing the app shell:
same header, same left rail, same right panel, same design switcher. App
branches the renderer on design_type.

Backend
- racks / rack_devices / rack_cables, all design_id-scoped.
- rack_devices.device_id -> pending_devices is the primary link. Those rows
  survive approval and node deletion, so unracking never removes the device
  from the Device Inventory. node_id -> nodes is a second, optional link used
  only for live status and for matching network-link imports. Both are
  ON DELETE SET NULL and label is denormalized, so a rack keeps rendering
  after an inventory purge.
- GET /racks, POST /racks/save (full-state upsert + prune, same contract as
  canvas/save), GET /racks/inventory.
- POST /scan/pending adds an inventory entry by hand, for hardware no scan
  can discover.
- design_type is now validated and actually branched on; the schema comment
  calling it vestigial is gone.
- Design delete unwinds rack rows explicitly (SQLite does not always honour
  the cascade); design copy duplicates racks, mounts and patches with fresh
  ids and re-points the cables at the copies.

Frontend
- /racklab and RackLab are removed; no dual maintenance.
- Rack types lifted into @/types; rackSerializer maps the snake_case wire
  shapes to them, narrowing every enum on the way in.
- The store gains load/save, dirty tracking and an editSeq, so racks get the
  same explicit Save and the same opt-in autosave as the logical canvas.
- Header, sidebar and inspector adapt in rack mode: canvas-only actions are
  hidden, Add Rack / Patch / cable visibility / type filter / Import links
  take their place, the sidebar body becomes the inventory tray, and the
  footer counts racks, mounts, cables and free U.
- rackTheme derives the rack palette from the active app theme rather than
  declaring one per theme, so a new theme works here for free. Per-rack
  chrome stays user-editable; only its default comes from the theme.
- New Canvas gains a Kind picker (hidden when copying, since a copy inherits
  the source kind).

Tests: 24 backend, 5 frontend files (store, persistence, networkLinks,
rackTheme, rackSerializer) plus DesignModal coverage for the Kind picker.

ha-relevant: maybe
Frontend rack renderer is portable; the FastAPI routes and SQLAlchemy models
are standalone-only by the persistence/transport split. Discuss before porting.
2026-08-09 20:35:23 +02:00
Pouzor 33df37b691 feat(rack): front-only rack canvas prototype at /racklab
Physical counterpart to the logical canvas: racks hold hardware at a U
position, with drawn front panels and port-to-port patching.

- 12-column grid so 2 or 3 machines can share one U; collision and
  snap-to-free-slot handled in layout.ts
- 19 faceplate templates drawn as SVG in unit coordinates, so a plate
  scales with U height and rack width
- RJ45 / SFP port artwork only; ports stay hidden on non-patch gear
  until hover, selection or patch mode. Outlets are artwork, never cabled
- cables are their own relation, not React Flow edges, rendered in a
  ViewportPortal so they cross racks and follow pan/zoom
- unmounting a device never removes its inventory entry
- rack settings: size, 19"/10", numbering direction, colours, enclosure

Isolated behind its own entry point (like /view) — no API, no
persistence, no design_type change until the UX is settled.

ha-relevant: yes
2026-08-09 20:35:23 +02:00
196 changed files with 23002 additions and 2017 deletions
+6
View File
@@ -22,6 +22,12 @@ jobs:
uses: trufflesecurity/trufflehog@main
with:
path: ./
# Lob (a direct-mail API this project does not use) matches any
# `test_`-prefixed identifier of ~35 chars and reports it verified,
# because Lob test keys authenticate unconditionally. Every pytest
# function named at that length trips it — the first was
# `test_updates_an_existing_device_in_place`.
extra_args: --exclude-detectors=Lob
dependency-audit:
runs-on: ubuntu-latest
+80
View File
@@ -5,6 +5,86 @@ 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.4] - 2026-08-17
### Fixes
- A network scan no longer deletes the services you added by hand. The scanner replaced a device's whole service list with what it fingerprinted, so one scan wiped manual entries on every canvas at once and brought back the ones you had deleted. It merges now. (#347)
- A node whose services were all replaced under it no longer renders empty: when nothing a node's view names is left on the device, the device's own list is drawn instead of hiding everything. (#347)
- Properties added while running 3.3.0–3.3.2 survive the 3.3.3 upgrade. The view recovered from the pre-3.3.0 backup could not know about them, so they were hidden on every canvas; they are kept visible, and the per-canvas arrangement the backup does know is still restored. (#347)
## [3.3.3] - 2026-08-17
### Features
- Each node now keeps its own view of a device's services and properties: order and visibility are per node, so a service the scanner fingerprinted no longer appears on every canvas drawing that device, and a property added on one schematic stays there. The facts still live on the inventory row — hiding is per node, deleting is device-wide. Upgrades keep what each canvas showed today. (#357)
### Fixes
- The one-off view seed no longer re-opens the pre-upgrade backup on every boot: a node pointing at a deleted device kept matching the seed query and logged a recovery that recovered nothing. (#357)
## [3.3.2] - 2026-08-17
### Fixes
- Devices upgraded from 3.2.0 keep their ip, services, hostname, notes and hardware. The 3.3.0 migration aborted on timestamps SQLite returns as text, so nothing was carried across to the Device Inventory and every device read back blank. (#347, #348)
- A canvas saved while an earlier migration was stuck no longer loses its device facts: the migration now fills the blank inventory row that save created, instead of skipping the node and dropping the columns holding its only copy. (#347)
## [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
- Rack canvas: a third design type, next to network and electrical, with racks, mounted devices and port-to-port patching. (#315)
- Declarative SVG faceplates picked from a visual catalog, including desktop NAS enclosures in 2, 4 and 5 bays. (#315)
- Rack gear is filed in the Device Inventory, and a mount can be picked from the rackable entries. (#315)
- Cables carry a type, colour, label and properties, printed on the canvas at the midpoint of the run. (#315)
- Mounted devices follow the status check of the logical node they match, and links can be imported from the logical canvas. (#315)
- Getting Started walkthrough covers the rack canvas. (#315)
- Show the logical view of a mounted device, and link a mount to any Device Inventory entry. (#323)
### Fixes
- Guard rack rows against cross-design saves and half-applied faceplates. (#315)
- A rack height edit no longer orphans its mounts; height and colour commit on blur, and a blanked field no longer shrinks the rack. (#315)
- Import links is idempotent, and stays usable after a run that matched nothing. (#315)
- Stop the device modal losing cables, status and inventory rows. (#315)
- Name rack inventory entries the way the Device Inventory does. (#315)
- Stamp the linked node's last-seen with an offset. (#323)
- Bump js-yaml to 4.3.1 for CVE-2026-59870. (#315)
- CI: stop the secret scan reporting pytest names as Lob keys. (#315)
### Docs
- Document the rack canvas for users. (#315)
## [3.1.2] - 2026-08-01
### Features
+48 -28
View File
@@ -16,20 +16,21 @@ Here's what Homelable can do. One line on what each feature is, then how to swit
2. [Groups & Nesting](#2-groups--nesting)
3. [Text Annotations](#3-text-annotations)
4. [Multiple Canvases](#4-multiple-canvases)
5. [Customize Style](#5-customize-style)
6. [Floor Plan](#6-floor-plan-)
7. [Network Scanner (IP import)](#7-network-scanner-ip-import-)
8. [Zigbee Import](#8-zigbee-import-)
9. [Z-Wave Import](#9-z-wave-import-)
10. [Proxmox VE Import](#10-proxmox-ve-import-)
11. [Device Inventory](#11-device-inventory-)
12. [Live Status Monitoring](#12-live-status-monitoring-)
13. [Export (PNG / SVG / YAML / Markdown)](#13-export)
14. [Live View (read-only public canvas)](#14-live-view-)
15. [Gethomepage Widget](#15-gethomepage-widget-)
16. [MCP Server (AI integration)](#16-mcp-server-)
17. [Settings & Shortcuts](#17-settings--shortcuts)
18. [Authentication (Local / OpenID Connect)](#18-authentication-local--openid-connect-)
5. [Rack Canvas](#5-rack-canvas)
6. [Customize Style](#6-customize-style)
7. [Floor Plan](#7-floor-plan-)
8. [Network Scanner (IP import)](#8-network-scanner-ip-import-)
9. [Zigbee Import](#9-zigbee-import-)
10. [Z-Wave Import](#10-z-wave-import-)
11. [Proxmox VE Import](#11-proxmox-ve-import-)
12. [Device Inventory](#12-device-inventory-)
13. [Live Status Monitoring](#13-live-status-monitoring-)
14. [Export (PNG / SVG / YAML / Markdown)](#14-export)
15. [Live View (read-only public canvas)](#15-live-view-)
16. [Gethomepage Widget](#16-gethomepage-widget-)
17. [MCP Server (AI integration)](#17-mcp-server-)
18. [Settings & Shortcuts](#18-settings--shortcuts)
19. [Authentication (Local / OpenID Connect)](#19-authentication-local--openid-connect-)
---
@@ -74,7 +75,26 @@ Here's what Homelable can do. One line on what each feature is, then how to swit
---
## 5. Customize Style
## 5. Rack Canvas
**What:** The physical side of the lab, as its own kind of canvas: racks, the gear mounted in them, and the patch cables between their ports. Where a diagram says what talks to what, a rack says what sits in which U, and which switch port the uplink lands on.
**Use:**
- **Canvas switcher** → **New Canvas** → Kind **Rack**. A canvas cannot change kind afterwards.
- **Add Rack** in the header drops a rack; double-click its frame for U height, 19"/10" width, numbering direction, colours and the enclosed/open look.
- **+ Device** in the sidebar mounts something: an entry from your **Device Inventory** 🔒, a **new device** (which joins the inventory tagged *Rack devices*), or a rack-only **accessory** (blank, shelf, cable manager). Double-click a plate to edit it, and **Unmount** to take it out — the device itself stays in the inventory.
- Gear occupies a U range and part of a 12-column width grid, so half- and third-width machines share a U. A drop snaps to the nearest free slot, an impossible one previews red, and growing a device relocates it to the nearest slot that fits.
- The **Faceplate** field opens a visual catalog — servers, switches, routers, patch panels, UPS and PDUs, desktop NAS towers, shelves and blanks — drawn as vector artwork that scales with the rack and follows your theme.
- **Patch**: drag from one port to another (or click both in turn) to cable them, across racks if you need to. Click a cable to select it, then **Delete** or **Unplug**. A header select controls whether cables show on hover, always, or not at all.
- **Status**: pin a mount's colour, or set **Check device** 🔒 so it follows the status check already configured on the matching diagram node.
- **Import links** 🔒 derives patches from the physical links already drawn on your diagrams. Run it again after racking more gear — pairs already cabled are left alone.
- Saving is explicit — **Save Rack** — like any other canvas.
> **Full documentation:** [docs/rack-canvas.md](./docs/rack-canvas.md)
---
## 6. Customize Style
**What:** Repaint the whole thing with a preset theme, or roll your own node and edge colors.
@@ -85,7 +105,7 @@ Here's what Homelable can do. One line on what each feature is, then how to swit
---
## 6. Floor Plan 🔒
## 7. Floor Plan 🔒
**What:** Put a background image (a house plan, an office layout, a rack diagram) behind a canvas and lay your devices out on top of it.
@@ -96,7 +116,7 @@ Here's what Homelable can do. One line on what each feature is, then how to swit
---
## 7. Network Scanner (IP import) 🔒
## 8. Network Scanner (IP import) 🔒
**What:** Point `nmap` at your network, fingerprint the services it finds, and turn hosts into nodes.
@@ -116,7 +136,7 @@ SCANNER_HTTP_VERIFY_TLS=false
---
## 8. Zigbee Import 🔒
## 9. Zigbee Import 🔒
**What:** Pull your **Zigbee2MQTT** topology in over MQTT and drop every device on the canvas as a typed node.
@@ -129,7 +149,7 @@ Nodes come in as `zigbee_coordinator` / `zigbee_router` / `zigbee_enddevice`. Th
---
## 9. Z-Wave Import 🔒
## 10. Z-Wave Import 🔒
**What:** Same idea for **Z-Wave JS UI**, over the same MQTT broker.
@@ -142,7 +162,7 @@ Nodes: `zwave_coordinator` / `zwave_router` / `zwave_enddevice`. The hierarchy c
---
## 10. Proxmox VE Import 🔒
## 11. Proxmox VE Import 🔒
**What:** Pull your **Proxmox VE** inventory (hosts, VMs, LXC) in over the Proxmox REST API — typed, named nodes with run state and hardware specs. Optional scheduled **auto-sync**; guest IPs already found by a scan are merged, not duplicated.
@@ -156,7 +176,7 @@ Nodes: `proxmox` (host) / `vm` / `lxc`, linked host→guest by a `virtual` edge.
---
## 11. Device Inventory 🔒
## 12. Device Inventory 🔒
**What:** The holding pen for everything found by a scan or import that isn't on the canvas yet, plus a separate **Hidden Devices** list.
@@ -167,7 +187,7 @@ Nodes: `proxmox` (host) / `vm` / `lxc`, linked host→guest by a `virtual` edge.
---
## 12. Live Status Monitoring 🔒
## 13. Live Status Monitoring 🔒
**What:** Keeps checking each node and shows its status (🟢 online / 🔴 offline / ⚫ unknown) right on the canvas.
@@ -188,7 +208,7 @@ Nodes: `proxmox` (host) / `vm` / `lxc`, linked host→guest by a `virtual` edge.
---
## 13. Export
## 14. Export
**What:** Get your canvas out as a picture or as structured data.
@@ -200,7 +220,7 @@ Nodes: `proxmox` (host) / `vm` / `lxc`, linked host→guest by a `virtual` edge.
---
## 14. Live View 🔒
## 15. Live View 🔒
**What:** A read-only, no-login snapshot of a canvas you can share on your LAN. Off by default.
@@ -212,7 +232,7 @@ Pan and zoom only, no editing. Click a node with an IP and it opens in a new tab
---
## 15. Gethomepage Widget 🔒
## 16. Gethomepage Widget 🔒
**What:** A tiny JSON stats endpoint for [gethomepage](https://gethomepage.dev)'s `customapi` widget. Off by default.
@@ -224,7 +244,7 @@ Widget snippet lives in the [README](./README.md#gethomepage-widget-read-only-st
---
## 16. MCP Server 🔒
## 17. MCP Server 🔒
**What:** A [Model Context Protocol](https://modelcontextprotocol.io) server so an MCP client (Claude Code, Claude Desktop, Open WebUI…) can read and change your topology. Optional, runs as its own service.
@@ -242,7 +262,7 @@ The AI can list nodes/edges/canvas/pending/scans, add/update/delete nodes and ed
---
## 17. Settings & Shortcuts
## 18. Settings & Shortcuts
**What:** App config and keyboard shortcuts.
@@ -253,7 +273,7 @@ The AI can list nodes/edges/canvas/pending/scans, add/update/delete nodes and ed
---
## 18. Authentication (Local / OpenID Connect) 🔒
## 19. Authentication (Local / OpenID Connect) 🔒
**What:** Homelable protects the app behind a login. Two exclusive modes, set once in `.env` with `AUTH_MODE`:
- **`local`** (default) — a single username + bcrypt-hashed password. Nothing changes for existing installs.
+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
+21 -2
View File
@@ -21,6 +21,7 @@
<a href="#screenshots">Screenshots</a> ·
<a href="#features">Features</a> ·
<a href="#installation">Installation</a> ·
<a href="#rack-canvas">Rack Canvas</a> ·
<a href="#network-scanner">Network Scanner</a> ·
<a href="#zigbee2mqtt-import">Zigbee / Z-Wave</a> ·
<a href="#proxmox-ve-import">Proxmox</a> ·
@@ -56,7 +57,7 @@ If you are running <img width="22" height="22" align="top" alt="New_Home_Assist
## Features
From one-click **network scans** and **Proxmox / Zigbee / Z-Wave** imports to **live status monitoring**, floor plans, multi-canvas layouts and an **MCP server** for AI assistants — Homelable maps and watches your whole homelab.
From one-click **network scans** and **Proxmox / Zigbee / Z-Wave** imports to **live status monitoring**, floor plans, **rack canvases** with port-to-port patching, multi-canvas layouts and an **MCP server** for AI assistants — Homelable maps and watches your whole homelab.
Every feature, with how to turn it on and use it, is described in **[FEATURES.md](./FEATURES.md)**.
@@ -64,7 +65,25 @@ 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)**.
---
## Rack Canvas
Next to the network diagram, Homelable draws the **physical** side of your lab: racks, the gear mounted in them, and the patch cables between their ports. It is a canvas kind of its own — create it from the canvas switcher, **New Canvas → Kind → Rack**.
### Usage
1. **Add Rack** in the header drops a rack; double-click its frame to set U height, 19"/10" width, numbering direction and colours
2. **+ Device** in the sidebar mounts something — an entry from your Device Inventory, a new device (which joins the inventory), or an accessory (blank, shelf, cable manager)
3. Pick a **faceplate** from the visual catalog: servers, switches, routers, patch panels, UPS and PDUs, desktop NAS towers, shelves and blanks
4. Click **Patch**, then drag from one port to another to cable them — across racks if you need to. Click a cable and press Delete to unplug it
5. **Save Rack** when you're happy; nothing is written behind your back
Gear sits in a U range and part of a 12-column width grid, so half- and third-width machines share a U; a drop snaps to the nearest free slot. A mount can follow the status check of its matching diagram node, and **Import links** derives patches from the links already drawn on your diagrams.
> **Full documentation:** [docs/rack-canvas.md](./docs/rack-canvas.md)
---
+1 -1
View File
@@ -1 +1 @@
3.1.2
3.3.4
+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:
+62 -2
View File
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import CanvasState, Design, Edge, Node
from app.db.models import CanvasState, Design, Edge, Node, Rack, RackCable, RackDevice
from app.schemas.designs import DesignCopy, DesignCreate, DesignResponse, DesignUpdate
router = APIRouter()
@@ -118,6 +118,56 @@ async def copy_design(
)
)
# Copy the rack canvas, if the source has one. Racks, mounted gear and patches
# all get new ids; the inventory / node links are carried over untouched, since
# a copy documents the same hardware.
src_racks = (await db.execute(select(Rack).where(Rack.design_id == source_id))).scalars().all()
rack_id_map = {r.id: str(uuid.uuid4()) for r in src_racks}
rack_skip = {"id", "design_id", "created_at", "updated_at"}
for r in src_racks:
cols = {c.name: getattr(r, c.name) for c in Rack.__table__.columns if c.name not in rack_skip}
db.add(Rack(id=rack_id_map[r.id], design_id=new_design.id, **cols))
src_rack_devices = (
await db.execute(select(RackDevice).where(RackDevice.design_id == source_id))
).scalars().all()
rack_device_id_map = {d.id: str(uuid.uuid4()) for d in src_rack_devices}
rack_device_skip = {"id", "design_id", "rack_id", "created_at", "updated_at"}
for d in src_rack_devices:
if d.rack_id not in rack_id_map:
continue
cols = {
c.name: getattr(d, c.name)
for c in RackDevice.__table__.columns
if c.name not in rack_device_skip
}
db.add(
RackDevice(
id=rack_device_id_map[d.id],
design_id=new_design.id,
rack_id=rack_id_map[d.rack_id],
**cols,
)
)
src_cables = (await db.execute(select(RackCable).where(RackCable.design_id == source_id))).scalars().all()
cable_skip = {"id", "design_id", "from_device_id", "to_device_id", "created_at"}
for c_row in src_cables:
if c_row.from_device_id not in rack_device_id_map or c_row.to_device_id not in rack_device_id_map:
continue
cols = {
c.name: getattr(c_row, c.name) for c in RackCable.__table__.columns if c.name not in cable_skip
}
db.add(
RackCable(
id=str(uuid.uuid4()),
design_id=new_design.id,
from_device_id=rack_device_id_map[c_row.from_device_id],
to_device_id=rack_device_id_map[c_row.to_device_id],
**cols,
)
)
# Copy canvas state (viewport, custom style, and the floor plan carried in viewport).
src_state = await db.get(CanvasState, source_id)
db.add(
@@ -165,10 +215,20 @@ async def delete_design(
count = (await db.execute(select(Design))).scalars().all()
if len(count) <= 1:
raise HTTPException(400, "Cannot delete the only design")
# Delete associated canvas state, edges, nodes
# Delete associated canvas state, rack rows, edges, nodes. SQLite does not
# always enforce the ON DELETE CASCADE, so unwind by hand — cables before
# devices before racks.
cs = await db.get(CanvasState, design_id)
if cs:
await db.delete(cs)
for cable in (await db.execute(select(RackCable).where(RackCable.design_id == design_id))).scalars().all():
await db.delete(cable)
for rack_device in (
await db.execute(select(RackDevice).where(RackDevice.design_id == design_id))
).scalars().all():
await db.delete(rack_device)
for rack in (await db.execute(select(Rack).where(Rack.design_id == design_id))).scalars().all():
await db.delete(rack)
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
for e in edges:
await db.delete(e)
+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)
+52 -54
View File
@@ -21,7 +21,7 @@ from app.api.deps import get_current_user
from app.core.config import settings
from app.core.scheduler import reschedule_proxmox_sync, set_proxmox_sync_enabled
from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
from app.db.models import InventoryDevice, InventoryDeviceLink, Node, ScanRun
from app.schemas.proxmox import (
ProxmoxConfig,
ProxmoxConnectionRequest,
@@ -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,
@@ -245,7 +245,7 @@ async def _persist_pending_import(
nodes_raw: list[dict[str, Any]],
edges_raw: list[dict[str, Any]],
) -> ProxmoxImportPendingResponse:
"""Upsert Proxmox nodes/edges into pending_devices + pending_device_links.
"""Upsert Proxmox nodes/edges into device_inventory + device_inventory_links.
Two-tier identity (order matters):
1. Match an existing canvas Node or pending row by **IP** (merge into a
@@ -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()
@@ -328,14 +321,14 @@ async def _persist_pending_import(
async def _find_pending(
db: AsyncSession, ieee: str, ip: str | None, mac: str | None
) -> PendingDevice | None:
filters = [PendingDevice.ieee_address == ieee]
) -> InventoryDevice | None:
filters = [InventoryDevice.ieee_address == ieee]
if ip:
filters.append(PendingDevice.ip == ip)
filters.append(InventoryDevice.ip == ip)
if mac:
filters.append(PendingDevice.mac == mac)
filters.append(InventoryDevice.mac == mac)
return (
await db.execute(select(PendingDevice).where(or_(*filters)))
await db.execute(select(InventoryDevice).where(or_(*filters)))
).scalars().first()
@@ -346,8 +339,8 @@ def _new_pending(
n: dict[str, Any],
props: list[dict[str, Any]],
status: str,
) -> PendingDevice:
return PendingDevice(
) -> InventoryDevice:
return InventoryDevice(
ieee_address=ieee,
ip=ip,
mac=mac,
@@ -357,13 +350,18 @@ 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],
)
def _sources_after_merge(row: PendingDevice) -> list[str]:
def _sources_after_merge(row: InventoryDevice) -> list[str]:
"""Discovery sources for an inventory row after a Proxmox import merges in.
Must run BEFORE the ``pve-`` ieee is adopted onto the row, so it can tell
@@ -381,7 +379,7 @@ def _sources_after_merge(row: PendingDevice) -> list[str]:
def _refresh_pending(
pending: PendingDevice,
pending: InventoryDevice,
ieee: str,
ip: str | None,
mac: str | None,
@@ -441,8 +439,8 @@ async def _replace_links(
(``proxmox_cluster`` → 'cluster' edges).
"""
await db.execute(
sa_delete(PendingDeviceLink).where(
PendingDeviceLink.discovery_source.in_([_PROXMOX_GUEST_SOURCE, _PROXMOX_CLUSTER_SOURCE])
sa_delete(InventoryDeviceLink).where(
InventoryDeviceLink.discovery_source.in_([_PROXMOX_GUEST_SOURCE, _PROXMOX_CLUSTER_SOURCE])
)
)
recorded = 0
@@ -453,7 +451,7 @@ async def _replace_links(
if not src or not tgt or (src, tgt) in seen:
return
seen.add((src, tgt))
db.add(PendingDeviceLink(source_ieee=src, target_ieee=tgt, discovery_source=source))
db.add(InventoryDeviceLink(source_ieee=src, target_ieee=tgt, discovery_source=source))
recorded += 1
for e in edges_raw:
+354
View File
@@ -0,0 +1,354 @@
from datetime import datetime, timezone
from typing import Any, TypeVar
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import CanvasState, Design, InventoryDevice, Node, Rack, RackCable, RackDevice
from app.schemas.racks import (
RackCableResponse,
RackDeviceResponse,
RackInventoryItem,
RackInventoryResponse,
RackResponse,
RackSaveRequest,
RackServiceInfo,
RackStateResponse,
)
router = APIRouter()
# Device kinds that cannot be racked. An exclusion list rather than an allow list:
# new hardware types should show up in the tray by default, and only the obviously
# virtual / mesh / annotation kinds need to opt out.
_UNRACKABLE_TYPES = {
"vm",
"lxc",
"docker_container",
"mobile",
"laptop",
"light",
"socket",
"load",
"groupRect",
"group",
"text",
"zigbee_coordinator",
"zigbee_router",
"zigbee_enddevice",
"zwave_coordinator",
"zwave_router",
"zwave_enddevice",
}
_COMMON_PORTS = {22, 80, 443}
def _service_name(device: InventoryDevice) -> str | None:
"""Name a device after the app it runs, the way the Device Inventory does.
Ports everyone exposes (ssh, http, https) say nothing, and the generic web
category loses to a real match, so "jellyfin" wins over "http".
"""
candidates = [
s
for s in (device.services or [])
if isinstance(s, dict)
and s.get("category")
and s.get("service_name")
# A service with no port is not a fingerprint the inventory names a
# device after either — and `None not in _COMMON_PORTS` is True.
and s.get("port") is not None
and s.get("port") not in _COMMON_PORTS
]
if not candidates:
return None
named = next((s for s in candidates if str(s["category"]).lower() != "web"), candidates[0])
name = named.get("service_name")
return str(name) if name else None
def _device_label(device: InventoryDevice) -> str:
"""Inventory naming, mirrored: friendly name, host, app, IP, IEEE, id.
Must stay in step with `deviceLabel` in `InventoryDevicesModal.tsx` — a device
the user picked out of the inventory by one name should not turn up in the
rack picker under another.
"""
return (
device.friendly_name
or device.hostname
or _service_name(device)
or device.ip
or device.ieee_address
or device.id
)
# A mount prints its services as a short list, not a port scan dump.
_MAX_SERVICES = 12
def _services(raw: Any) -> list[RackServiceInfo]:
"""Fingerprinted services, deduped and trimmed for the rack's info panel."""
out: list[RackServiceInfo] = []
seen: set[tuple[int | None, str | None]] = set()
for entry in raw or []:
if not isinstance(entry, dict):
continue
raw_port = entry.get("port")
port = raw_port if isinstance(raw_port, int) else None
raw_name = entry.get("service_name") or entry.get("name")
name = str(raw_name) if raw_name else None
if port is None and name is None:
continue
if (port, name) in seen:
continue
seen.add((port, name))
out.append(RackServiceInfo(port=port, name=name))
if len(out) == _MAX_SERVICES:
break
return out
def _ip_tokens(ip: str | None) -> list[str]:
"""Split a device ``ip`` field into individual addresses.
Mirrors the scan routes: the canvas stores multiple addresses in one
comma-separated string, so matching must compare per-address.
"""
return [t.strip() for t in ip.split(",") if t.strip()] if ip else []
_Row = TypeVar("_Row", Rack, RackDevice, RackCable)
async def _owned(
db: AsyncSession, model: type[_Row], row_id: str, design_id: str
) -> _Row | None:
"""Fetch a rack row by id, refusing one that belongs to another design.
The upsert below sets `design_id` from the payload, so without this a save
carrying an id from design A would quietly move A's rack — devices and
cables included — into design B, and A would lose it with no error. Ids come
from the client, so a copied design or a stale tab is enough to collide.
"""
row = await db.get(model, row_id)
if row is not None and row.design_id != design_id:
raise HTTPException(409, f"{model.__name__} {row_id} belongs to another design")
return row
async def _require_design(db: AsyncSession, design_id: str) -> Design:
design = await db.get(Design, design_id)
if not design:
raise HTTPException(404, "Design not found")
return design
@router.get("", response_model=RackStateResponse)
async def load_racks(
design_id: str = Query(..., description="Rack design to load"),
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> RackStateResponse:
await _require_design(db, design_id)
racks = (await db.execute(select(Rack).where(Rack.design_id == design_id))).scalars().all()
devices = (
await db.execute(select(RackDevice).where(RackDevice.design_id == design_id))
).scalars().all()
cables = (await db.execute(select(RackCable).where(RackCable.design_id == design_id))).scalars().all()
state = await db.get(CanvasState, design_id)
return RackStateResponse(
racks=[RackResponse.model_validate(r) for r in racks],
devices=[RackDeviceResponse.model_validate(d) for d in devices],
cables=[RackCableResponse.model_validate(c) for c in cables],
viewport=state.viewport if state else {"x": 0, "y": 0, "zoom": 1},
)
@router.post("/save")
async def save_racks(
body: RackSaveRequest,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, bool]:
"""Persist the full rack state of one design: upsert what is sent, prune the rest."""
await _require_design(db, body.design_id)
rack_ids = {r.id for r in body.racks}
device_ids = {d.id for d in body.devices}
# A device must land in a rack that is part of the same payload, and a cable
# must join two devices in it — otherwise the prune below would orphan them.
for device in body.devices:
if device.rack_id not in rack_ids:
raise HTTPException(400, f"Device {device.id} references unknown rack {device.rack_id}")
for cable in body.cables:
if cable.from_device_id not in device_ids or cable.to_device_id not in device_ids:
raise HTTPException(400, f"Cable {cable.id} references a device outside this payload")
# Prune first (cables, then devices, then racks) so freed rows never collide
# with an upsert that reuses their geometry.
for existing_cable in (
await db.execute(select(RackCable).where(RackCable.design_id == body.design_id))
).scalars().all():
if existing_cable.id not in {c.id for c in body.cables}:
await db.delete(existing_cable)
for existing_device in (
await db.execute(select(RackDevice).where(RackDevice.design_id == body.design_id))
).scalars().all():
if existing_device.id not in device_ids:
await db.delete(existing_device)
for existing_rack in (
await db.execute(select(Rack).where(Rack.design_id == body.design_id))
).scalars().all():
if existing_rack.id not in rack_ids:
await db.delete(existing_rack)
await db.flush()
for rack_data in body.racks:
payload: dict[str, Any] = rack_data.model_dump()
payload["design_id"] = body.design_id
db_rack = await _owned(db, Rack, rack_data.id, body.design_id)
if db_rack:
for field, value in payload.items():
setattr(db_rack, field, value)
else:
db.add(Rack(**payload))
await db.flush() # racks must exist before devices point at them
for device_data in body.devices:
payload = device_data.model_dump()
payload["design_id"] = body.design_id
db_device = await _owned(db, RackDevice, device_data.id, body.design_id)
if db_device:
for field, value in payload.items():
setattr(db_device, field, value)
else:
db.add(RackDevice(**payload))
await db.flush() # devices must exist before cables point at them
for cable_data in body.cables:
payload = cable_data.model_dump()
payload["design_id"] = body.design_id
db_cable = await _owned(db, RackCable, cable_data.id, body.design_id)
if db_cable:
for field, value in payload.items():
setattr(db_cable, field, value)
else:
db.add(RackCable(**payload))
# Viewport lives on the design's shared CanvasState row, like the logical canvas.
state = await db.get(CanvasState, body.design_id)
if state:
state.viewport = body.viewport
state.saved_at = datetime.now(timezone.utc)
else:
db.add(CanvasState(design_id=body.design_id, viewport=body.viewport))
await db.commit()
return {"saved": True}
@router.get("/inventory", response_model=RackInventoryResponse)
async def rack_inventory(
design_id: str = Query(..., description="Rack design the tray is for"),
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> RackInventoryResponse:
"""Device Inventory entries that can be racked, flagged with what is already mounted.
Reads `device_inventory` — the inventory survives approval and node deletion,
so unracking or deleting a canvas node never removes the entry here.
"""
await _require_design(db, design_id)
devices = (
await db.execute(select(InventoryDevice).where(InventoryDevice.status != "hidden"))
).scalars().all()
mounts = (
await db.execute(
select(RackDevice.device_id, RackDevice.node_id).where(
RackDevice.design_id == design_id
)
)
).all()
mounted = {device_id for device_id, _ in mounts if device_id}
# A mount can name its canvas node itself, when the user linked one by hand
# in the rack. That beats the IEEE/IP guess below: the guess is what failed
# 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: 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_device: dict[str, Node] = {}
for node in nodes:
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
# 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,
label=_device_label(device),
suggested_type=device.suggested_type,
ip=device.ip,
status=device.status,
discovery_source=device.discovery_source,
mac=device.mac or device.ieee_address,
hostname=device.hostname,
os=device.os,
services=_services(device.services),
node_id=linked.id 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=node_device.last_seen if node_device else None,
racked=device.id in mounted,
)
)
return RackInventoryResponse(items=items)
+304 -148
View File
@@ -12,10 +12,26 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.config import settings
from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
from app.db.models import (
Design,
Edge,
InventoryDevice,
InventoryDeviceLink,
Node,
RackDevice,
ScanRun,
)
from app.schemas.nodes import NodeCreate
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
from app.services.node_dedupe import dedupe_nodes_by_ieee, find_duplicate_node
from app.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_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,
@@ -38,6 +54,15 @@ def _ip_tokens(ip: str | None) -> list[str]:
return [t.strip() for t in ip.split(",") if t.strip()] if ip else []
def _is_rack_only(device: InventoryDevice) -> bool:
"""True for inventory entries created from a rack canvas.
They describe a mount — a patch panel, a shelf, a chassis — not a host to
document on a logical canvas, so they are never approved onto one.
"""
return device.discovery_source == "rack" or "rack" in (device.discovery_sources or [])
def _is_wireless(node_type: str | None) -> bool:
"""Zigbee + Z-Wave mesh devices share online status / no ICMP check."""
return node_type in _ZIGBEE_TYPES or node_type in _ZWAVE_TYPES
@@ -224,74 +249,47 @@ def _agg(values: list[datetime], *, newest: bool) -> datetime | None:
async def _canvas_correlation(
db: AsyncSession, devices: list[PendingDevice]
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
async def _with_canvas_counts(
db: AsyncSession, devices: list[PendingDevice]
) -> list[PendingDevice]:
db: AsyncSession, devices: list[InventoryDevice]
) -> list[InventoryDevice]:
"""Attach transient canvas count + linked-node timestamps for the response."""
info = await _canvas_correlation(db, devices)
for d in devices:
@@ -304,28 +302,164 @@ async def _with_canvas_counts(
return devices
@router.get("/pending", response_model=list[PendingDeviceResponse])
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
@router.get("/pending", response_model=list[InventoryDeviceResponse])
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[InventoryDevice]:
# Inventory: every scanned device except the user-hidden ones. Approved devices
# stay listed so they keep showing with a canvas-presence badge.
result = await db.execute(select(PendingDevice).where(PendingDevice.status != "hidden"))
result = await db.execute(select(InventoryDevice).where(InventoryDevice.status != "hidden"))
return await _with_canvas_counts(db, list(result.scalars().all()))
@router.post("/pending", response_model=InventoryDeviceResponse, status_code=201)
async def create_pending(
body: InventoryDeviceCreate,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> InventoryDevice:
"""Add an inventory entry by hand, for hardware no scan can discover.
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=mac,
suggested_type=body.suggested_type,
model=body.model,
vendor=body.vendor,
properties=body.properties,
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()
await db.refresh(device)
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),
_: str = Depends(get_current_user),
) -> dict[str, int]:
from sqlalchemy import delete as sa_delete
result = await db.execute(sa_delete(PendingDevice).where(PendingDevice.status == "pending"))
result = await db.execute(sa_delete(InventoryDevice).where(InventoryDevice.status == "pending"))
await db.commit()
return {"deleted": result.rowcount}
@router.get("/hidden", response_model=list[PendingDeviceResponse])
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden"))
@router.delete("/pending/{device_id}", response_model=dict)
async def delete_pending(
device_id: str,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, bool]:
"""Remove one inventory entry.
Used by the rack canvas to drop the placeholder it created for a plate once
that plate is pointed at a real inventory row. Refuses while a rack still
mounts the device: SQLite runs with foreign keys off here, so the mount's
``device_id`` would be left naming a row that no longer exists rather than
being cleared by the ``ON DELETE SET NULL`` the schema declares.
"""
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")
mounted = (
await db.execute(select(RackDevice.id).where(RackDevice.device_id == device_id).limit(1))
).scalar_one_or_none()
if mounted:
raise HTTPException(status_code=409, detail="Device is mounted in a rack")
await db.delete(device)
await db.commit()
return {"deleted": True}
@router.get("/hidden", response_model=list[InventoryDeviceResponse])
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[InventoryDevice]:
result = await db.execute(select(InventoryDevice).where(InventoryDevice.status == "hidden"))
return await _with_canvas_counts(db, list(result.scalars().all()))
@@ -336,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
@@ -350,97 +484,90 @@ async def bulk_approve_devices(
# node was later deleted) must still be placeable on THIS design. Duplicates
# are guarded per-design below, not by the global status flag.
result = await db.execute(
select(PendingDevice).where(
PendingDevice.id.in_(payload.device_ids),
PendingDevice.status != "hidden",
select(InventoryDevice).where(
InventoryDevice.id.in_(payload.device_ids),
InventoryDevice.status != "hidden",
)
)
devices = result.scalars().all()
# What already sits on the target canvas, so we skip devices already placed
# here (by ip, mac or ieee_address) instead of creating duplicate nodes. We
# map to the existing node id so the skip report can point the user at it. A
# value may be a Node still pending flush (in-batch duplicate) — resolved to
# its id after the flush below. IPs are indexed per comma-separated token so
# a node whose ip is "fe80::1, 192.168.1.5" still matches a device scanned as
# 192.168.1.5 (issue #258).
# 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[PendingDevice] = []
approved_devices: list[InventoryDevice] = []
skipped_devices: list[dict[str, Any]] = []
for device in devices:
# Record which identifier collided so the caller can explain each skip
# (and, for existing on-canvas nodes, link to the node already there).
ip_hit = next((t for t in _ip_tokens(device.ip) if t in placed_ips), None)
if ip_hit is not None:
# Rack-only gear belongs to a rack canvas, never to a logical one.
if _is_rack_only(device):
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],
"label": device.hostname or device.friendly_name or "device",
"match": "rack", "value": "rack device", "_ref": None,
})
continue
if device.ieee_address is not None and device.ieee_address in placed_ieee:
# 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.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]
@@ -477,9 +604,9 @@ async def bulk_hide_devices(
_: str = Depends(get_current_user),
) -> dict[str, Any]:
result = await db.execute(
select(PendingDevice).where(
PendingDevice.id.in_(payload.device_ids),
PendingDevice.status == "pending",
select(InventoryDevice).where(
InventoryDevice.id.in_(payload.device_ids),
InventoryDevice.status == "pending",
)
)
devices = result.scalars().all()
@@ -495,7 +622,7 @@ async def restore_device(
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, Any]:
device = await db.get(PendingDevice, device_id)
device = await db.get(InventoryDevice, device_id)
if not device:
raise HTTPException(status_code=404, detail="Device not found")
if device.status != "hidden":
@@ -512,9 +639,9 @@ async def bulk_restore_devices(
_: str = Depends(get_current_user),
) -> dict[str, Any]:
result = await db.execute(
select(PendingDevice).where(
PendingDevice.id.in_(payload.device_ids),
PendingDevice.status == "hidden",
select(InventoryDevice).where(
InventoryDevice.id.in_(payload.device_ids),
InventoryDevice.status == "hidden",
)
)
devices = result.scalars().all()
@@ -537,7 +664,7 @@ async def approve_device(
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
node_design_id = first.id if first else None
device = await db.get(PendingDevice, device_id)
device = await db.get(InventoryDevice, device_id)
if not device:
raise HTTPException(status_code=404, detail="Device not found")
# A device's status is GLOBAL — it flips to "approved" the moment it lands on
@@ -549,6 +676,11 @@ async def approve_device(
# device is off-limits here.
if device.status == "hidden":
raise HTTPException(status_code=409, detail="Device is hidden")
if _is_rack_only(device):
raise HTTPException(
status_code=409,
detail="Rack devices cannot be placed on a logical canvas",
)
wireless = _is_wireless(node_data.type)
# A device already on THIS design (matched by ieee, ip OR mac) is NOT placed
@@ -569,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()
@@ -621,10 +774,10 @@ async def _is_proxmox_cluster_member(db: AsyncSession, ieee: str | None) -> bool
return False
found = (
await db.execute(
select(PendingDeviceLink.id)
select(InventoryDeviceLink.id)
.where(
PendingDeviceLink.discovery_source == "proxmox_cluster",
(PendingDeviceLink.source_ieee == ieee) | (PendingDeviceLink.target_ieee == ieee),
InventoryDeviceLink.discovery_source == "proxmox_cluster",
(InventoryDeviceLink.source_ieee == ieee) | (InventoryDeviceLink.target_ieee == ieee),
)
.limit(1)
)
@@ -635,7 +788,7 @@ async def _is_proxmox_cluster_member(db: AsyncSession, ieee: str | None) -> bool
async def _resolve_pending_links_for_ieee(
db: AsyncSession, ieee: str | None, design_id: str | None
) -> list[dict[str, str]]:
"""Materialize edges for any pending_device_links involving ``ieee`` on the
"""Materialize edges for any device_inventory_links involving ``ieee`` on the
canvas identified by ``design_id``.
For each link where the other endpoint already exists as a Node *on this
@@ -649,9 +802,9 @@ async def _resolve_pending_links_for_ieee(
return []
links_q = await db.execute(
select(PendingDeviceLink).where(
(PendingDeviceLink.source_ieee == ieee)
| (PendingDeviceLink.target_ieee == ieee)
select(InventoryDeviceLink).where(
(InventoryDeviceLink.source_ieee == ieee)
| (InventoryDeviceLink.target_ieee == ieee)
)
)
links = list(links_q.scalars().all())
@@ -666,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:
@@ -751,7 +907,7 @@ async def _resolve_pending_links_for_ieee(
async def hide_device(
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
) -> dict[str, bool]:
device = await db.get(PendingDevice, device_id)
device = await db.get(InventoryDevice, device_id)
if not device:
raise HTTPException(status_code=404, detail="Device not found")
device.status = "hidden"
@@ -763,7 +919,7 @@ async def hide_device(
async def ignore_device(
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
) -> dict[str, bool]:
device = await db.get(PendingDevice, device_id)
device = await db.get(InventoryDevice, device_id)
if not device:
raise HTTPException(status_code=404, detail="Device not found")
await db.delete(device)
+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:
+24 -6
View File
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.database import get_db
from app.db.models import Node, PendingDevice, ScanRun
from app.db.models import InventoryDevice, Node, ScanRun
router = APIRouter()
@@ -30,22 +30,38 @@ 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(
select(func.count())
.select_from(PendingDevice)
.where(PendingDevice.status == "pending")
.select_from(InventoryDevice)
.where(InventoryDevice.status == "pending")
)
).scalar_one()
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()
@@ -58,6 +74,8 @@ async def summary(
"online": counts.get("online", 0),
"offline": counts.get("offline", 0),
"unknown": counts.get("unknown", 0),
# Wire key kept from before the table was renamed to `device_inventory`:
# the stats payload is a published contract, and the MCP server reads it.
"pending_devices": pending,
"zigbee_devices": zigbee,
"last_scan_at": last_scan_at.isoformat() if last_scan_at else None,
+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,
}))
+27 -62
View File
@@ -13,7 +13,7 @@ from app.api.deps import get_current_user
from app.core.config import settings
from app.core.scheduler import reschedule_zigbee_sync, set_zigbee_sync_enabled
from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
from app.db.models import InventoryDevice, InventoryDeviceLink, Node, ScanRun
from app.schemas.scan import ScanRunResponse
from app.schemas.zigbee import (
ZigbeeConfig,
@@ -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()
@@ -185,14 +194,14 @@ async def _persist_pending_import(
nodes_raw: list[dict[str, Any]],
edges_raw: list[dict[str, Any]],
) -> ZigbeeImportPendingResponse:
"""Upsert nodes/edges into pending_devices + pending_device_links.
"""Upsert nodes/edges into device_inventory + device_inventory_links.
Coordinator auto-approves to a canvas Node. Other devices upsert by IEEE.
All zigbee-source links are wiped and re-inserted from the new map.
"""
# Repair any pre-existing duplicate nodes (same IEEE) before upserting, so
# the by-IEEE lookups below resolve to a single row.
await dedupe_nodes_by_ieee(db)
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,62 +222,17 @@ 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(PendingDevice).where(PendingDevice.ieee_address == ieee)
)
).scalar_one_or_none()
if inv is None:
db.add(
PendingDevice(
ieee_address=ieee,
friendly_name=n.get("friendly_name"),
hostname=n.get("friendly_name"),
suggested_type=n.get("type"),
device_subtype=n.get("device_type"),
model=n.get("model"),
vendor=n.get("vendor"),
lqi=n.get("lqi"),
status="approved",
discovery_source="zigbee",
)
)
pending_created += 1
else:
# Refresh metadata but never change the row's status (an approved
# device stays approved; a hidden one stays hidden).
inv.friendly_name = n.get("friendly_name") or inv.friendly_name
inv.suggested_type = n.get("type") or inv.suggested_type
inv.device_subtype = n.get("device_type") or inv.device_subtype
inv.model = n.get("model") or inv.model
inv.vendor = n.get("vendor") or inv.vendor
if n.get("lqi") is not None:
inv.lqi = n.get("lqi")
pending_updated += 1
continue
# 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(PendingDevice).where(PendingDevice.ieee_address == ieee)
select(InventoryDevice).where(InventoryDevice.ieee_address == ieee)
)
pending = result.scalar_one_or_none()
if pending is None:
db.add(
PendingDevice(
InventoryDevice(
ieee_address=ieee,
friendly_name=n.get("friendly_name"),
hostname=n.get("friendly_name"),
@@ -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.
@@ -303,7 +268,7 @@ async def _persist_pending_import(
# Replace all zigbee-source links with the freshly discovered set.
await db.execute(
sa_delete(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "zigbee")
sa_delete(InventoryDeviceLink).where(InventoryDeviceLink.discovery_source == "zigbee")
)
links_recorded = 0
@@ -315,7 +280,7 @@ async def _persist_pending_import(
continue
seen.add((src, tgt))
db.add(
PendingDeviceLink(
InventoryDeviceLink(
source_ieee=src,
target_ieee=tgt,
discovery_source="zigbee",
+26 -59
View File
@@ -13,7 +13,7 @@ from app.api.deps import get_current_user
from app.core.config import settings
from app.core.scheduler import reschedule_zwave_sync, set_zwave_sync_enabled
from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
from app.db.models import InventoryDevice, InventoryDeviceLink, Node, ScanRun
from app.schemas.scan import ScanRunResponse
from app.schemas.zwave import (
ZwaveConfig,
@@ -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()
@@ -182,14 +191,14 @@ async def _persist_pending_import(
nodes_raw: list[dict[str, Any]],
edges_raw: list[dict[str, Any]],
) -> ZwaveImportPendingResponse:
"""Upsert nodes/edges into pending_devices + pending_device_links.
"""Upsert nodes/edges into device_inventory + device_inventory_links.
Coordinator auto-approves to a canvas Node. Other devices upsert by Z-Wave
identity. All zwave-source links are wiped and re-inserted from the new map.
"""
# Repair any pre-existing same-canvas duplicate nodes before upserting, so
# the by-IEEE lookups below resolve cleanly.
await dedupe_nodes_by_ieee(db)
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,61 +217,17 @@ 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(PendingDevice).where(PendingDevice.ieee_address == ieee)
)
).scalar_one_or_none()
if inv is None:
db.add(
PendingDevice(
ieee_address=ieee,
friendly_name=n.get("friendly_name"),
hostname=n.get("friendly_name"),
suggested_type=n.get("type"),
device_subtype=n.get("device_type"),
model=n.get("model"),
vendor=n.get("vendor"),
lqi=n.get("lqi"),
status="approved",
discovery_source="zwave",
)
)
pending_created += 1
else:
# Refresh metadata but never change the row's status.
inv.friendly_name = n.get("friendly_name") or inv.friendly_name
inv.suggested_type = n.get("type") or inv.suggested_type
inv.device_subtype = n.get("device_type") or inv.device_subtype
inv.model = n.get("model") or inv.model
inv.vendor = n.get("vendor") or inv.vendor
if n.get("lqi") is not None:
inv.lqi = n.get("lqi")
pending_updated += 1
continue
# 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(PendingDevice).where(PendingDevice.ieee_address == ieee)
select(InventoryDevice).where(InventoryDevice.ieee_address == ieee)
)
pending = result.scalar_one_or_none()
if pending is None:
db.add(
PendingDevice(
InventoryDevice(
ieee_address=ieee,
friendly_name=n.get("friendly_name"),
hostname=n.get("friendly_name"),
@@ -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
@@ -292,7 +259,7 @@ async def _persist_pending_import(
# Replace all zwave-source links with the freshly discovered set.
await db.execute(
sa_delete(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "zwave")
sa_delete(InventoryDeviceLink).where(InventoryDeviceLink.discovery_source == "zwave")
)
links_recorded = 0
@@ -304,7 +271,7 @@ async def _persist_pending_import(
continue
seen.add((src, tgt))
db.add(
PendingDeviceLink(
InventoryDeviceLink(
source_ieee=src,
target_ieee=tgt,
discovery_source="zwave",
+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"}
+428 -30
View File
@@ -1,12 +1,14 @@
import json as _json
import logging
import shutil
import sqlite3
import uuid as _uuid_mod
from collections.abc import AsyncGenerator
from contextlib import suppress
from contextlib import closing, 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
@@ -60,9 +62,67 @@ def _backup_db() -> None:
logger.warning("Could not create DB backup at %s", backup_path)
# Tables renamed in 3.2.0. "Pending devices" was the scanner's word for a queue
# of finds awaiting approval; the same rows outlive approval, are edited by hand
# and are what a rack mounts, so the product calls them the Device Inventory.
# Only the names moved — every column, every row and every route is unchanged.
_RENAMED_TABLES: list[tuple[str, str]] = [
("pending_devices", "device_inventory"),
("pending_device_links", "device_inventory_links"),
]
async def _rename_legacy_tables(conn: AsyncConnection) -> None:
"""Rename the pre-3.2.0 inventory tables. Must run before `create_all`.
Order matters: `create_all` would otherwise create an empty
`device_inventory` beside the populated `pending_devices`, and every device
the user ever scanned would look lost.
Foreign keys are switched on for the rename so SQLite rewrites the
``REFERENCES pending_devices`` clause in `rack_devices` too; enforcement is
off at runtime, so a missed rewrite costs nothing today, but it would leave
the schema naming a table that no longer exists.
"""
for old, new in _RENAMED_TABLES:
rows = (
await conn.exec_driver_sql(
"SELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?)",
(old, new),
)
).fetchall()
names = {r[0] for r in rows}
if old not in names:
continue # Fresh install, or already migrated.
if new in names:
# A start that ran `create_all` before this migration existed left an
# empty new table beside the populated old one, and the app read the
# empty one. Clear it out of the way rather than stranding the data;
# a new table with rows in it means the rename already happened and
# something re-created the old one, which is not ours to resolve.
count = (await conn.exec_driver_sql(f"SELECT COUNT(*) FROM {new}")).scalar()
if count:
logger.warning(
"Both %s and %s hold rows; leaving them alone. Merge them by hand.", old, new
)
continue
logger.info("Dropping the empty %s left by an earlier start", new)
await _try_migrate(conn, f"DROP TABLE {new}", label=f"{new}.drop_empty")
logger.info("Migrating table %s -> %s", old, new)
with suppress(OperationalError):
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
await _try_migrate(conn, f"ALTER TABLE {old} RENAME TO {new}", label=f"{new}.rename")
# The index rides the rename under its old name; the model asks for the new
# one, so drop the stale duplicate rather than carrying both.
await _try_migrate(
conn, "DROP INDEX IF EXISTS ix_pending_devices_ieee_address", label="device_inventory.index",
)
async def init_db() -> None:
_backup_db()
async with engine.begin() as conn:
await _rename_legacy_tables(conn)
await conn.run_sync(Base.metadata.create_all)
# Add columns introduced after initial schema (idempotent)
with suppress(OperationalError):
@@ -114,11 +174,11 @@ async def init_db() -> None:
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN right_handles INTEGER NOT NULL DEFAULT 0")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_source TEXT")
await conn.exec_driver_sql("ALTER TABLE device_inventory ADD COLUMN discovery_source TEXT")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN properties JSON")
await conn.exec_driver_sql("ALTER TABLE device_inventory ADD COLUMN properties JSON")
with suppress(OperationalError):
await conn.exec_driver_sql("UPDATE pending_devices SET properties = '[]' WHERE properties IS NULL")
await conn.exec_driver_sql("UPDATE device_inventory SET properties = '[]' WHERE properties IS NULL")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE scan_runs ADD COLUMN kind TEXT NOT NULL DEFAULT 'ip'")
# --- Zigbee schema migrations (logged variant per CLAUDE.md feedback) ---
@@ -128,32 +188,32 @@ async def init_db() -> None:
"nodes.ieee_address.index",
"CREATE INDEX IF NOT EXISTS ix_nodes_ieee_address ON nodes(ieee_address)",
),
("pending_devices.ieee_address", "ALTER TABLE pending_devices ADD COLUMN ieee_address TEXT"),
("device_inventory.ieee_address", "ALTER TABLE device_inventory ADD COLUMN ieee_address TEXT"),
(
"pending_devices.ieee_address.index",
"CREATE INDEX IF NOT EXISTS ix_pending_devices_ieee_address "
"ON pending_devices(ieee_address)",
"device_inventory.ieee_address.index",
"CREATE INDEX IF NOT EXISTS ix_device_inventory_ieee_address "
"ON device_inventory(ieee_address)",
),
("pending_devices.friendly_name", "ALTER TABLE pending_devices ADD COLUMN friendly_name TEXT"),
("pending_devices.device_subtype", "ALTER TABLE pending_devices ADD COLUMN device_subtype TEXT"),
("pending_devices.model", "ALTER TABLE pending_devices ADD COLUMN model TEXT"),
("pending_devices.vendor", "ALTER TABLE pending_devices ADD COLUMN vendor TEXT"),
("pending_devices.lqi", "ALTER TABLE pending_devices ADD COLUMN lqi INTEGER"),
("device_inventory.friendly_name", "ALTER TABLE device_inventory ADD COLUMN friendly_name TEXT"),
("device_inventory.device_subtype", "ALTER TABLE device_inventory ADD COLUMN device_subtype TEXT"),
("device_inventory.model", "ALTER TABLE device_inventory ADD COLUMN model TEXT"),
("device_inventory.vendor", "ALTER TABLE device_inventory ADD COLUMN vendor TEXT"),
("device_inventory.lqi", "ALTER TABLE device_inventory ADD COLUMN lqi INTEGER"),
]
for label, sql in zigbee_migrations:
await _try_migrate(conn, sql, label=label)
# Drop NOT NULL on pending_devices.ip (Zigbee devices have no IP).
# Drop NOT NULL on device_inventory.ip (Zigbee devices have no IP).
# SQLite can't ALTER column nullability — rebuild the table if needed.
try:
info = await conn.exec_driver_sql("PRAGMA table_info(pending_devices)")
info = await conn.exec_driver_sql("PRAGMA table_info(device_inventory)")
cols = info.fetchall()
ip_col = next((c for c in cols if c[1] == "ip"), None)
# PRAGMA table_info row layout: (cid, name, type, notnull, dflt, pk)
if ip_col and ip_col[3] == 1:
logger.info("Migrating pending_devices: dropping NOT NULL on ip column")
logger.info("Migrating device_inventory: dropping NOT NULL on ip column")
await conn.exec_driver_sql("PRAGMA foreign_keys = OFF")
await conn.exec_driver_sql(
"CREATE TABLE pending_devices_new ("
"CREATE TABLE device_inventory_new ("
"id VARCHAR PRIMARY KEY,"
"ip VARCHAR,"
"mac VARCHAR, hostname VARCHAR, os VARCHAR, services JSON,"
@@ -171,25 +231,25 @@ async def init_db() -> None:
")"
)
await conn.exec_driver_sql(
"INSERT INTO pending_devices_new "
"INSERT INTO device_inventory_new "
"(id, ip, mac, hostname, os, services, suggested_type, status, "
"discovery_source, ieee_address, friendly_name, device_subtype, "
"model, vendor, lqi, discovered_at) "
"SELECT id, ip, mac, hostname, os, services, suggested_type, status, "
"discovery_source, ieee_address, friendly_name, device_subtype, "
"model, vendor, lqi, discovered_at FROM pending_devices"
"model, vendor, lqi, discovered_at FROM device_inventory"
)
await conn.exec_driver_sql("DROP TABLE pending_devices")
await conn.exec_driver_sql("DROP TABLE device_inventory")
await conn.exec_driver_sql(
"ALTER TABLE pending_devices_new RENAME TO pending_devices"
"ALTER TABLE device_inventory_new RENAME TO device_inventory"
)
await conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_pending_devices_ieee_address "
"ON pending_devices(ieee_address)"
"CREATE INDEX IF NOT EXISTS ix_device_inventory_ieee_address "
"ON device_inventory(ieee_address)"
)
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
except OperationalError as exc:
logger.warning("pending_devices ip-nullable rebuild failed: %s", exc)
logger.warning("device_inventory ip-nullable rebuild failed: %s", exc)
# --- end Zigbee schema migrations -------------------------------------
# --- Electrical designs schema migrations -----------------------------
# Create designs table (idempotent)
@@ -286,6 +346,22 @@ async def init_db() -> None:
logger.warning("canvas_state migration failed: %s", exc)
# --- end Electrical designs schema migrations --------------------------
# Rack cables gained editable annotations: a label the user can print on
# the canvas, plus the same free-form property records nodes carry.
rack_cable_migrations: list[tuple[str, str]] = [
(
"rack_cables.label_visible",
"ALTER TABLE rack_cables ADD COLUMN label_visible BOOLEAN NOT NULL DEFAULT 0",
),
("rack_cables.properties", "ALTER TABLE rack_cables ADD COLUMN properties JSON"),
]
for label, sql in rack_cable_migrations:
await _try_migrate(conn, sql, label=label)
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE rack_cables SET properties = '[]' WHERE properties IS NULL"
)
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
with suppress(OperationalError):
@@ -325,33 +401,355 @@ async def init_db() -> None:
# Proxmox import carries every source. Backfill from the legacy single
# discovery_source so existing rows show under their filter.
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_sources JSON")
await conn.exec_driver_sql("ALTER TABLE device_inventory ADD COLUMN discovery_sources JSON")
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE pending_devices SET discovery_sources = json_array(discovery_source) "
"UPDATE device_inventory SET discovery_sources = json_array(discovery_source) "
"WHERE discovery_sources IS NULL AND discovery_source IS NOT NULL"
)
# Legacy IP-scanned rows predating discovery_source have a NULL scalar
# but a real IP — treat them as an ARP scan so they keep the IP tag.
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE pending_devices SET discovery_sources = json_array('arp') "
"UPDATE device_inventory SET discovery_sources = json_array('arp') "
"WHERE discovery_sources IS NULL AND discovery_source IS NULL AND ip IS NOT NULL"
)
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE pending_devices SET discovery_sources = '[]' WHERE discovery_sources IS NULL"
"UPDATE device_inventory SET discovery_sources = '[]' WHERE discovery_sources IS NULL"
)
# Canonicalize stored MACs (lowercase, ':' separators) so cross-source
# dedup can match a Proxmox NIC MAC against an ARP-scanned one by equality.
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE pending_devices SET mac = lower(replace(mac, '-', ':')) WHERE mac IS NOT NULL"
"UPDATE device_inventory SET mac = lower(replace(mac, '-', ':')) WHERE mac IS NOT NULL"
)
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE nodes SET mac = lower(replace(mac, '-', ':')) WHERE mac IS NOT NULL"
)
# 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)",
),
# Which of the row's services and properties this canvas shows, and
# in what order. Seeded from the row further down, once the backfill
# has had its say — see `_seed_node_views`.
("nodes.display_view", "ALTER TABLE nodes ADD COLUMN display_view JSON"),
):
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()
await _seed_node_views()
# 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,"
"display_view JSON,"
"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, display_view, 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)
def _pre_split_backup() -> Path | None:
"""The newest backup still holding the per-node device columns, if any.
`_backup_db` copies the database *before* the migrations of each new
version, so a user who upgraded to 3.3.0 has a `homelab.db.back-3.3.0`
carrying the last state in which `nodes` still owned its own services and
properties. That copy is the only record of which canvas showed what, since
3.3.0's backfill unioned them all onto one inventory row. Newest first: it is
the state closest to the upgrade, so it is what the user last saw.
"""
db_path = Path(settings.sqlite_path)
candidates = sorted(
db_path.parent.glob(f"{db_path.name}.back-*"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
for path in candidates:
try:
with closing(sqlite3.connect(f"file:{path}?mode=ro", uri=True)) as conn:
cols = {row[1] for row in conn.execute("PRAGMA table_info(nodes)")}
except sqlite3.Error:
continue
if {"services", "properties"} <= cols:
return path
return None
def _views_from_backup() -> dict[str, dict[str, Any]]:
"""What each node drew, read out of the pre-3.3.0 backup. Empty when there is none.
Keyed by node id, which is a uuid and stable across every version. A backup
that cannot be opened, or holds nodes this database no longer has, simply
contributes nothing — the caller falls back to the inventory row.
"""
path = _pre_split_backup()
if path is None:
return {}
try:
with closing(sqlite3.connect(f"file:{path}?mode=ro", uri=True)) as conn:
rows = conn.execute("SELECT id, services, properties FROM nodes").fetchall()
except sqlite3.Error as exc:
logger.warning("Could not read the pre-3.3.0 node lists from %s: %s", path.name, exc)
return {}
out: dict[str, dict[str, Any]] = {}
for node_id, services, properties in rows:
drawn: dict[str, Any] = {}
for kind, raw in (("services", services), ("properties", properties)):
if isinstance(raw, str | bytes):
with suppress(ValueError):
decoded = _json.loads(raw)
if isinstance(decoded, list):
drawn[kind] = decoded
if drawn:
out[node_id] = drawn
if out:
logger.info("Recovering the per-canvas service/property layout from %s", path.name)
return out
async def _seed_node_views() -> None:
"""Give pre-existing nodes an explicit view of their inventory row (3.3.3).
Order and visibility for services and properties moved to the node, so one
device drawn on two canvases can be rendered two ways. A node from before
that has no view, and where it comes from decides whether the user gets
their arrangement back:
* upgrading from 3.2.0, the backfill has already seeded it from the node's
own columns — nothing here to do;
* upgrading from 3.3.0-3.3.2, those columns are gone and the row holds the
union of every canvas, so the view is recovered from the backup taken
before the 3.3.0 migration. That is the difference between a canvas coming
back as the user left it and coming back showing every other canvas'
properties;
* with no usable backup, the row itself is the seed: what shows today keeps
showing, and only what the row gains *later* is held back.
Never fatal: without a view the node simply shows the whole row, which is
the behaviour it has now.
"""
# Imported here: app.services imports app.db.models, which imports this module.
from app.services.inventory_sync import seed_node_views
try:
async with AsyncSessionLocal() as session:
seeded = await seed_node_views(session, drawn=_views_from_backup)
if seeded:
await session.commit()
logger.info("Seeded the service/property view of %d node(s)", seeded)
except Exception as exc: # pragma: no cover - defensive, boot must not die
logger.warning("Seeding the node service/property views failed: %s", exc)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
+153 -24
View File
@@ -28,44 +28,49 @@ 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
)
# How this canvas renders the device's list facts: which services and which
# properties it shows, and in what order. The facts themselves stay on the
# inventory row, so the same device drawn on two canvases can show two
# different subsets — a scanner-guessed service on one, none on the other.
# {"services": [{"key": "443|tcp|https", "visible": true}, …],
# "properties": [{"key": "rack", "visible": false}, …]}
# NULL only for canvas furniture and for a node with no inventory row yet;
# `inventory_sync.link_facts` fills both lists as soon as there is one.
display_view: Mapped[dict[str, Any] | None] = mapped_column(JSON, 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")
@@ -105,8 +110,106 @@ class CanvasState(Base):
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class PendingDevice(Base):
__tablename__ = "pending_devices"
class Rack(Base):
"""A physical rack on a `design_type='rack'` canvas.
Racks live per design, like nodes and edges. Geometry is expressed in rack
units: `u_height` is the number of mountable U, and a device's `u_start` is
always counted from the bottom rail — `numbering` only changes the printed
labels.
"""
__tablename__ = "racks"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String, nullable=False)
u_height: Mapped[int] = mapped_column(Integer, default=42)
# "19" or "10" (inches). Drives the drawn inner width.
width_standard: Mapped[str] = mapped_column(String, default="19")
# "bottom-up" or "top-down" — printed U labels only, never storage order.
numbering: Mapped[str] = mapped_column(String, default="bottom-up")
location: Mapped[str | None] = mapped_column(String, nullable=True)
# Frame/rail/interior colours + showNumbers/enclosed flags.
style: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
pos_x: Mapped[float] = mapped_column(Float, default=0)
pos_y: Mapped[float] = mapped_column(Float, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
class RackDevice(Base):
"""A piece of gear mounted in a rack.
Two independent, both-optional links back to the rest of the app:
* `device_id` — the Device Inventory entry (`device_inventory`). This is the
primary link: inventory rows survive approval *and* node deletion, so
unracking or deleting a canvas node never removes the inventory entry.
* `node_id` — the logical-canvas node, when one exists. Only used to resolve
live status and to seed cables from the network design's edges.
Both are ``SET NULL`` on delete and `label` is denormalized, so a rack keeps
rendering after an inventory purge.
"""
__tablename__ = "rack_devices"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), nullable=False)
rack_id: Mapped[str] = mapped_column(String, ForeignKey("racks.id", ondelete="CASCADE"), nullable=False)
device_id: Mapped[str | None] = mapped_column(
String, ForeignKey("device_inventory.id", ondelete="SET NULL"), nullable=True
)
node_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id", ondelete="SET NULL"), nullable=True)
label: Mapped[str] = mapped_column(String, nullable=False)
# 1-based, counted from the bottom rail.
u_start: Mapped[int] = mapped_column(Integer, default=1)
u_height: Mapped[int] = mapped_column(Integer, default=1)
# 12-column horizontal grid: full = 12, half = 6, third = 4, quarter = 3.
col_start: Mapped[int] = mapped_column(Integer, default=0)
col_span: Mapped[int] = mapped_column(Integer, default=12)
faceplate_id: Mapped[str] = mapped_column(String, nullable=False, default="blank-1u")
color: Mapped[str | None] = mapped_column(String, nullable=True)
status: Mapped[str] = mapped_column(String, default="unknown")
# [{id, label, type, x, y}] — positions are unit coordinates on the plate.
ports: Mapped[list[Any]] = mapped_column(JSON, default=list)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
class RackCable(Base):
"""A patch between two rack device ports.
Not an `Edge`: rack cables are port-to-port and may cross racks, so they are
their own relation rather than a canvas edge.
"""
__tablename__ = "rack_cables"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), nullable=False)
from_device_id: Mapped[str] = mapped_column(
String, ForeignKey("rack_devices.id", ondelete="CASCADE"), nullable=False
)
from_port_id: Mapped[str] = mapped_column(String, nullable=False)
to_device_id: Mapped[str] = mapped_column(
String, ForeignKey("rack_devices.id", ondelete="CASCADE"), nullable=False
)
to_port_id: Mapped[str] = mapped_column(String, nullable=False)
type: Mapped[str] = mapped_column(String, default="ethernet")
color: Mapped[str] = mapped_column(String, default="#39d353")
label: Mapped[str | None] = mapped_column(String, nullable=True)
# Print the label next to the run on the canvas.
label_visible: Mapped[bool] = mapped_column(Boolean, default=False)
# [{key, value, icon, visible}] — same records nodes carry; the visible ones
# are drawn beside the cable.
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class InventoryDevice(Base):
__tablename__ = "device_inventory"
# Permit the plain (non-Mapped[]) annotations on the transient request-only
# attributes below; without this SQLAlchemy 2.0 tries to map them as columns.
__allow_unmapped__ = True
@@ -138,6 +241,32 @@ class PendingDevice(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
@@ -149,16 +278,16 @@ class PendingDevice(Base):
node_last_seen: datetime | None = None
class PendingDeviceLink(Base):
class InventoryDeviceLink(Base):
"""Link between two Zigbee endpoints discovered during import.
Endpoints are addressed by IEEE (stable across re-imports). Either side may
already exist as a canvas Node (resolved via Node.ieee_address) or still be
a PendingDevice. On approval, the matching Edge is auto-created when both
a InventoryDevice. On approval, the matching Edge is auto-created when both
endpoints exist as canvas Nodes.
"""
__tablename__ = "pending_device_links"
__tablename__ = "device_inventory_links"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
source_ieee: Mapped[str] = mapped_column(String, nullable=False, index=True)
+2
View File
@@ -17,6 +17,7 @@ from app.api.routes import (
media,
nodes,
proxmox,
racks,
scan,
stats,
status,
@@ -77,6 +78,7 @@ app.include_router(nodes.router, prefix="/api/v1/nodes", tags=["nodes"])
app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"])
app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
app.include_router(designs.router, prefix="/api/v1/designs", tags=["designs"])
app.include_router(racks.router, prefix="/api/v1/racks", tags=["racks"])
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
+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
+15 -3
View File
@@ -1,15 +1,27 @@
from datetime import datetime
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
# Kept in sync with DesignType in frontend/src/types.
# "network" and "electrical" render the same React Flow canvas and differ only by
# palette and node set — the icon drives their presentation. "rack" is a genuinely
# different renderer (racks, mounted gear, port-to-port cables), so the frontend
# does branch on this field.
DESIGN_TYPES = {"network", "electrical", "rack"}
class DesignCreate(BaseModel):
name: str
icon: str = "dashboard"
# Vestigial: kept for backward compatibility. The UI no longer branches on it;
# the chosen icon now drives presentation. Defaults to a generic canvas.
design_type: str = "network"
@field_validator("design_type")
@classmethod
def _known_type(cls, v: str) -> str:
if v not in DESIGN_TYPES:
raise ValueError(f"design_type must be one of {sorted(DESIGN_TYPES)}")
return v
class DesignUpdate(BaseModel):
name: 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
+302
View File
@@ -0,0 +1,302 @@
from datetime import datetime, timezone
from typing import Any
from pydantic import BaseModel, field_validator, model_validator
# Kept in sync with the frontend rack model (frontend/src/types).
WIDTH_STANDARDS = {"19", "10"}
NUMBERINGS = {"bottom-up", "top-down"}
CABLE_TYPES = {"ethernet", "fiber"}
# 12-column horizontal grid: full = 12, half = 6, third = 4, quarter = 3.
RACK_COLUMNS = 12
class RackSave(BaseModel):
id: str
name: str
u_height: int = 42
width_standard: str = "19"
numbering: str = "bottom-up"
location: str | None = None
style: dict[str, Any] = {}
pos_x: float = 0
pos_y: float = 0
@field_validator("u_height")
@classmethod
def _positive_height(cls, v: int) -> int:
if not 1 <= v <= 100:
raise ValueError("u_height must be between 1 and 100")
return v
@field_validator("width_standard")
@classmethod
def _known_width(cls, v: str) -> str:
if v not in WIDTH_STANDARDS:
raise ValueError(f"width_standard must be one of {sorted(WIDTH_STANDARDS)}")
return v
@field_validator("numbering")
@classmethod
def _known_numbering(cls, v: str) -> str:
if v not in NUMBERINGS:
raise ValueError(f"numbering must be one of {sorted(NUMBERINGS)}")
return v
class RackDeviceSave(BaseModel):
id: str
rack_id: str
device_id: str | None = None
node_id: str | None = None
label: str
u_start: int = 1
u_height: int = 1
col_start: int = 0
col_span: int = RACK_COLUMNS
faceplate_id: str = "blank-1u"
color: str | None = None
status: str = "unknown"
ports: list[Any] = []
@field_validator("u_start", "u_height")
@classmethod
def _positive(cls, v: int) -> int:
if v < 1:
raise ValueError("u_start and u_height are 1-based and must be >= 1")
return v
@field_validator("col_start")
@classmethod
def _column_in_grid(cls, v: int) -> int:
if not 0 <= v < RACK_COLUMNS:
raise ValueError(f"col_start must be within the {RACK_COLUMNS}-column grid")
return v
@field_validator("col_span")
@classmethod
def _span_in_grid(cls, v: int) -> int:
if not 1 <= v <= RACK_COLUMNS:
raise ValueError(f"col_span must be between 1 and {RACK_COLUMNS}")
return v
@model_validator(mode="after")
def _fits_the_column_grid(self) -> "RackDeviceSave":
"""`col_start` and `col_span` are legal apart and still illegal together.
11 + 12 spans to column 23 of a 12-column grid — each field passes its
own check, and the frontend serializer clamps them independently, so
nothing caught it before this.
"""
if self.col_start + self.col_span > RACK_COLUMNS:
raise ValueError(
f"col_start + col_span must not exceed the {RACK_COLUMNS}-column grid"
)
return self
class RackCableSave(BaseModel):
id: str
from_device_id: str
from_port_id: str
to_device_id: str
to_port_id: str
type: str = "ethernet"
color: str = "#39d353"
label: str | None = None
label_visible: bool = False
# [{key, value, icon, visible}] — free-form, same shape as node properties.
properties: list[dict[str, Any]] = []
@field_validator("type")
@classmethod
def _known_type(cls, v: str) -> str:
if v not in CABLE_TYPES:
raise ValueError(f"type must be one of {sorted(CABLE_TYPES)}")
return v
@field_validator("properties", mode="before")
@classmethod
def _clean_properties(cls, v: Any) -> list[dict[str, Any]]:
"""Keep only usable records — a property with no key draws an empty badge."""
if not isinstance(v, list):
return []
return [p for p in v if isinstance(p, dict) and str(p.get("key", "")).strip()]
class RackSaveRequest(BaseModel):
"""Full rack state for one design — upserted, with anything missing pruned.
Same contract as ``POST /api/v1/canvas/save``: the client owns the state and
sends all of it on an explicit Save.
"""
design_id: str
racks: list[RackSave] = []
devices: list[RackDeviceSave] = []
cables: list[RackCableSave] = []
# Pan/zoom, stored on the design's shared CanvasState row like the logical canvas.
viewport: dict[str, Any] = {}
@model_validator(mode="after")
def _devices_fit_their_rack(self) -> "RackSaveRequest":
"""A mount must fit inside the rack it names.
`RackDeviceSave` validates each field in isolation and never sees the
rack, so `u_start: 40` in a 12U rack passed. A mount above the top rail
draws outside the chassis and nothing in the UI can drag it back, so the
server refuses to store it. Rack membership itself is checked in the
route, which knows what is already persisted.
"""
heights = {r.id: r.u_height for r in self.racks}
for device in self.devices:
u_height = heights.get(device.rack_id)
if u_height is None:
continue # unknown rack — the route reports that with a 400
if device.u_start + device.u_height - 1 > u_height:
raise ValueError(
f"Device {device.id} does not fit in rack {device.rack_id}: "
f"U {device.u_start}–{device.u_start + device.u_height - 1} "
f"of {u_height}U"
)
return self
class RackResponse(BaseModel):
id: str
design_id: str
name: str
u_height: int
width_standard: str
numbering: str
location: str | None = None
style: dict[str, Any] = {}
pos_x: float
pos_y: float
@field_validator("style", mode="before")
@classmethod
def _coerce_style(cls, v: Any) -> dict[str, Any]:
return v if isinstance(v, dict) else {}
model_config = {"from_attributes": True}
class RackDeviceResponse(BaseModel):
id: str
design_id: str
rack_id: str
device_id: str | None = None
node_id: str | None = None
label: str
u_start: int
u_height: int
col_start: int
col_span: int
faceplate_id: str
color: str | None = None
status: str
ports: list[Any] = []
@field_validator("ports", mode="before")
@classmethod
def _coerce_ports(cls, v: Any) -> list[Any]:
return v if isinstance(v, list) else []
model_config = {"from_attributes": True}
class RackCableResponse(BaseModel):
id: str
design_id: str
from_device_id: str
from_port_id: str
to_device_id: str
to_port_id: str
type: str
color: str
label: str | None = None
label_visible: bool = False
properties: list[dict[str, Any]] = []
@field_validator("properties", mode="before")
@classmethod
def _list_properties(cls, v: Any) -> list[dict[str, Any]]:
"""Rows written before the column existed read back as NULL."""
return v if isinstance(v, list) else []
@field_validator("label_visible", mode="before")
@classmethod
def _bool_label_visible(cls, v: Any) -> bool:
return bool(v)
model_config = {"from_attributes": True}
class RackStateResponse(BaseModel):
racks: list[RackResponse]
devices: list[RackDeviceResponse]
cables: list[RackCableResponse]
viewport: dict[str, Any] = {}
class RackServiceInfo(BaseModel):
"""One service fingerprinted on a device, as the rack view prints it."""
port: int | None = None
name: str | None = None
class RackInventoryItem(BaseModel):
"""A Device Inventory entry offered to the rack tray.
Carries the technical facts the logical canvas already knows about the same
hardware (`node_*`), so a mount can print them without the user leaving the
rack. Everything node-side is read-only here: the logical canvas owns it.
"""
id: str
label: str
suggested_type: str | None = None
ip: str | None = None
status: str
discovery_source: str | None = None
# Inventory-side technical detail, from discovery.
mac: str | None = None
hostname: str | None = None
os: str | None = None
services: list[RackServiceInfo] = []
# Live status of the matching canvas node, when the device is on one.
node_id: str | None = None
node_status: str | None = None
# What that node holds — the logical view of the same box.
node_label: str | None = None
node_type: str | None = None
node_ip: str | None = None
node_mac: str | None = None
node_hostname: str | None = None
node_os: str | None = None
node_check_method: str | None = None
node_design_id: str | None = None
node_design_name: str | None = None
node_last_seen: datetime | None = None
# True when this device is already mounted somewhere in this design.
racked: bool = False
@field_validator("node_last_seen")
@classmethod
def _utc(cls, v: datetime | None) -> datetime | None:
"""Stamp UTC on a naive timestamp before it goes on the wire.
SQLite has no timezone type: an aware datetime written to `last_seen`
reads back naive, and Pydantic then serializes it without an offset.
`new Date("2026-08-08T10:00:00")` in the browser is *local* time, so the
rack printed a last-seen shifted by the viewer's offset.
"""
if v is not None and v.tzinfo is None:
return v.replace(tzinfo=timezone.utc)
return v
class RackInventoryResponse(BaseModel):
items: list[RackInventoryItem]
+116 -2
View File
@@ -4,7 +4,7 @@ from typing import Any
from pydantic import BaseModel, field_validator
class PendingDeviceResponse(BaseModel):
class InventoryDeviceResponse(BaseModel):
id: str
ip: str | None
mac: str | None
@@ -27,6 +27,26 @@ class PendingDeviceResponse(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,15 +58,109 @@ class PendingDeviceResponse(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}
"""Sources a hand-made inventory entry may claim.
``rack`` marks gear created from a rack canvas. Those rows live in the Device
Inventory like any other, but they describe a physical mount rather than a host
on the network, so they are never placed on a logical canvas.
"""
MANUAL_SOURCES = {"manual", "rack"}
class InventoryDeviceCreate(BaseModel):
"""Manually add an entry to the Device Inventory.
Used when the user documents hardware no scan can find — a dumb switch, a
patch panel, a machine that is powered off. Lands with
`discovery_source="manual"` (or `"rack"`) so the inventory filters can tell
it apart.
"""
hostname: str
ip: str | None = None
mac: str | None = None
suggested_type: str | None = None
model: str | None = None
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
def _known_source(cls, v: str) -> str:
if v not in MANUAL_SOURCES:
raise ValueError(f"discovery_source must be one of {sorted(MANUAL_SOURCES)}")
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
+1 -1
View File
@@ -1,4 +1,4 @@
"""Helpers for the multi-valued ``PendingDevice.discovery_sources`` set.
"""Helpers for the multi-valued ``InventoryDevice.discovery_sources`` set.
A device discovered by more than one path (e.g. an IP scan *and* a Proxmox
import) accumulates every source that has seen it, so it surfaces under each
+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:
+960
View File
@@ -0,0 +1,960 @@
"""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 Callable, Mapping
from datetime import datetime
from typing import Any
from sqlalchemy import func, or_, select, text
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())
# --- Per-node view of the device's list facts -----------------------------
#
# The row owns the services and the properties; a node owns which of them it
# shows and in what order. Both are keyed by a stable string so the view
# survives an edit to a service's path or a property's value.
VIEW_LISTS = ("services", "properties")
def _service_view_key(svc: Any) -> str:
port, protocol, name = _service_key(svc) if isinstance(svc, dict) else (None, None, repr(svc))
return f"{port}|{protocol}|{name}"
def _property_view_key(prop: Any) -> str:
if not isinstance(prop, dict):
return repr(prop)
return str(prop.get("key") or "").lower()
_VIEW_KEY = {"services": _service_view_key, "properties": _property_view_key}
def view_entries(items: list[Any] | None, kind: str) -> list[dict[str, Any]]:
"""One list of device facts as a node's view of it: order plus visibility.
A service carries no ``visible`` of its own — one that reached a canvas was
always drawn — so it defaults to shown. A property carries an explicit flag
and keeps it. Duplicate keys collapse: the view addresses the row, and the
row holds one entry per key.
"""
key_of = _VIEW_KEY[kind]
out: list[dict[str, Any]] = []
seen: set[str] = set()
for item in items or []:
key = key_of(item)
if key in seen:
continue
seen.add(key)
visible = bool(item.get("visible", True)) if isinstance(item, dict) else True
out.append({"key": key, "visible": visible})
return out
def view_from_facts(facts: Mapping[str, Any]) -> dict[str, list[dict[str, Any]]]:
"""The view a node payload implies — only for the lists it actually sent.
The wire shape has no separate view: a client sends its services and its
properties in display order, each with its ``visible`` flag, exactly as it
draws them. That *is* the view, so it is read back out here rather than
asking clients for a second field.
"""
return {kind: view_entries(facts[kind], kind) for kind in VIEW_LISTS if kind in facts}
def view_of_device(device: InventoryDevice) -> dict[str, list[dict[str, Any]]]:
"""A view showing everything the row currently holds — the seed for a new node."""
return {
"services": view_entries(device.services, "services"),
"properties": view_entries(device.properties, "properties"),
}
def next_view(
current: Mapping[str, Any] | None,
incoming: Mapping[str, Any],
device: InventoryDevice | None,
*,
strict: bool = False,
) -> dict[str, Any] | None:
"""This node's view after a write: what it sent, then the row for the rest.
A list the write did not carry keeps the view it had. A node linked to a row
always ends up with both lists, so "not in the view" can mean one thing
only: this canvas does not show it. That is what keeps a service a later scan
discovers off every canvas until someone turns it on.
A node getting its *first* view is the exception: an empty list there means
the writer had nothing to say about it, not that the user hid everything —
creating a node for an already-scanned device sends no services and must
still draw the ones the row holds. ``strict`` turns that off for the one
caller whose empty list is a real answer: the legacy backfill, where the
node's own columns are the whole of what that canvas used to show.
"""
if device is None:
return dict(current) if current else None
out: dict[str, Any] = dict(current or {})
first_view = current is None
seed = view_of_device(device)
for kind in VIEW_LISTS:
entries = incoming.get(kind)
if entries or (entries is not None and (strict or not first_view)):
out[kind] = entries
elif kind not in out:
out[kind] = seed[kind]
return out
def apply_view(items: list[Any] | None, entries: Any, kind: str) -> list[Any]:
"""The row's facts as one node draws them: its order, its visibility.
Without a view — furniture, or a node whose row was linked by an older
version — everything shows, in the row's own order. With one, an item the
view does not list is appended hidden rather than dropped, so a service a
scan added is one toggle away instead of invisible.
A *non-empty* view that matches nothing the row still holds is treated as
having no view at all. It means the row was replaced wholesale under the
node — every key gone, every key new — and hiding the lot would leave a node
drawing nothing while its view claims otherwise. An empty view is different:
it is a real answer ("this canvas draws none of them") and keeps hiding
everything.
"""
key_of = _VIEW_KEY[kind]
facts = list(items or [])
if not isinstance(entries, list):
return facts
by_key: dict[str, Any] = {}
for item in facts:
by_key.setdefault(key_of(item), item)
out: list[Any] = []
taken: set[str] = set()
for entry in entries:
if not isinstance(entry, dict):
continue
key = str(entry.get("key"))
item = by_key.get(key)
if item is None or key in taken:
continue # Deleted from the row since — the view catches up on write.
taken.add(key)
out.append(_stamped(item, bool(entry.get("visible", True))))
if entries and not taken:
return facts
for item in facts:
if key_of(item) not in taken:
out.append(_stamped(item, False))
return out
def _stamped(item: Any, visible: bool) -> Any:
"""``item`` carrying this node's verdict on whether it is drawn.
A shown item that never had a ``visible`` key does not gain one: services
have always travelled without it, and readers treat its absence as shown.
Only hiding is news, and properties keep the explicit flag they arrived with.
"""
if not isinstance(item, dict):
return item
if visible and "visible" not in item:
return dict(item)
return {**item, "visible": visible}
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,
strict_view: bool = False,
) -> 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
# Order and visibility are this node's, not the device's, so they are taken
# from the full payload — never from the `changed_fields`/`only_changed`
# narrowing above, which exists to protect the *shared* row from a stale
# snapshot. A node's own view has no other writer to collide with.
node.display_view = next_view(
node.display_view, view_from_facts(facts), device, strict=strict_view
)
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} - _NODE_DERIVED_COLUMNS
return {k: v for k, v in payload.items() if k in allowed}
# Node columns the server derives rather than accepts: `display_view` is read
# back out of the services and properties a payload carries (see
# :func:`view_from_facts`), so a client sending one directly is ignored.
_NODE_DERIVED_COLUMNS = frozenset({"display_view"})
# 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
}
# An implementation detail of the split, not part of the wire shape: the
# view is reported *through* the services and properties it orders.
view = payload.pop("display_view", None) or {}
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"] = apply_view(device.services, view.get("services"), "services")
payload["properties"] = apply_view(device.properties, view.get("properties"), "properties")
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
# Legacy `nodes` columns SQLite stores as DATETIME. Read through raw SQL they come
# back as text, and writing text into a DateTime column raises
# ``TypeError: SQLite DateTime type only accepts Python datetime and date objects``.
_LEGACY_DATETIME_COLUMNS = ("last_seen", "last_scan")
def _decode_dt(value: Any) -> datetime | None:
"""Raw SQL hands back DATETIME columns as text; the ORM would have parsed them.
An unparseable stamp becomes ``None`` rather than an error: `last_seen` and
`last_scan` are observations the status checker refreshes within a minute,
and losing one must not cost a node its inventory row.
"""
if value is None or isinstance(value, datetime):
return value
if not isinstance(value, str) or not value.strip():
return None
try:
return datetime.fromisoformat(value.strip())
except ValueError:
logger.debug("Ignoring an unparseable legacy timestamp: %r", value)
return None
async def _row_is_missing_facts(db: AsyncSession, device_id: str, facts: Mapping[str, Any]) -> bool:
"""Does the linked row lack a fact the node's legacy columns still hold?
True only where the node has something to give and the row has nothing in
that field, so a node whose facts already made it across is left alone and
the backfill stays a no-op on later boots.
"""
device = await db.get(InventoryDevice, device_id)
if device is None:
return True # The row is gone; the node needs a new one.
for field in (*DEVICE_SCALARS, "label", "type", "ieee_address"):
if not _blank(facts.get(field)) and _blank(getattr(device, field, None)):
return True
return any(
facts.get(field) and not getattr(device, field, None)
for field in ("services", "properties")
)
def _with_later_properties(
view: dict[str, Any] | None, device: InventoryDevice
) -> dict[str, Any] | None:
"""``view``, plus the row's properties the pre-3.3.0 backup never saw.
They are what the user added while running 3.3.0-3.3.2 — the row was the
only place to add them then, and every canvas drew them. Recovering the old
view alone would take them off every canvas at once, which reads as data
loss. Appended in the row's order, after everything the backup placed.
"""
if view is None:
return None
entries = view.get("properties")
if not isinstance(entries, list):
return view
listed = {str(e.get("key")) for e in entries if isinstance(e, dict)}
later = [e for e in view_entries(device.properties, "properties") if e["key"] not in listed]
if not later:
return view
return {**view, "properties": [*entries, *later]}
async def seed_node_views(
db: AsyncSession, *, drawn: Callable[[], Mapping[str, Mapping[str, Any]]] | None = None
) -> int:
"""Give every linked node with no view one it can be held to.
Runs once, on the boot that adds `nodes.display_view`. Without it every
pre-existing node would fall through to "no view, show everything" and the
next scan would push a newly fingerprinted service onto all of them at once —
the leak this column exists to stop.
``drawn`` returns a map of node id to the services and properties that node
itself carried before 3.3.0 unioned them onto the row — the caller reads
them out of the pre-upgrade backup, and is only asked to when there is
actually a node to seed. Where it has an answer that answer wins, restoring
the arrangement the user made; where it does not, the row is the seed and
the canvas keeps showing exactly what it shows today. Does not commit.
The backup is 3.2.0-era, so it can only speak for what existed then. A
*property* the row has gained since — one the user added by hand while
running 3.3.0-3.3.2, when the row was all a canvas had — appears in no
backup entry, and seeding strictly from the backup would hide work the user
has been looking at for days. Those are appended visible, keeping the
recovered order and hidden flags for everything the backup does know.
Services are not treated that way: what a row gained since 3.3.0 is mostly a
scan's fingerprint, and holding it back is the whole point of the view.
"""
nodes = list(
(
await db.execute(
select(Node).where(
# A `device_id` naming a row that no longer exists cannot be
# seeded from anything, and leaving it in would match this
# query on every later boot — re-reading the backup file and
# logging a recovery that seeds nothing. Deleting a device
# leaves exactly that: SQLite runs with foreign keys off, so
# the `ON DELETE SET NULL` never fires on an existing table.
Node.device_id.in_(select(InventoryDevice.id)),
Node.display_view.is_(None),
)
)
)
.scalars()
.all()
)
if not nodes:
return 0
devices = await load_devices_for(db, nodes)
# Read the backup only now: on every later boot this function returns above
# and no file is opened at all.
was_drawn = drawn() if drawn is not None else {}
seeded = 0
for node in nodes:
device = devices.get(node.device_id or "")
if device is None: # pragma: no cover - the query already excluded these
continue
was = was_drawn.get(node.id)
# `strict`: an empty list in the backup is this canvas' real answer —
# it drew no service — and must not be read as "say nothing, show all".
if was:
view = next_view(None, view_from_facts(was), device, strict=True)
node.display_view = _with_later_properties(view, device)
else:
node.display_view = view_of_device(device)
seeded += 1
await db.flush()
return seeded
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))
# Linked nodes are read too, not just unlinked ones. A canvas saved while an
# earlier migration was stuck minted a *blank* inventory row from a UI that
# had no facts to show, and linked the node to it. Skipping those would count
# them as migrated and drop the columns that still hold their only copy of
# the ip, services and notes. A linked node is filled, never overwritten.
rows = (
await db.execute(
text(
f"SELECT id, label, type, design_id, device_id, {placeholders} FROM nodes "
f"WHERE 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 []
for field in _LEGACY_DATETIME_COLUMNS:
if field in facts:
facts[field] = _decode_dt(facts[field])
facts["label"] = row["label"]
facts["type"] = row["type"]
# One savepoint per node: a row the merge cannot write 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. The catch is deliberately broad — a node that cannot be read
# or written for *any* reason must not cost the others their link.
try:
async with db.begin_nested():
node = await db.get(Node, row["id"])
if node is None: # pragma: no cover - the row was just read
continue
if node.device_id and not await _row_is_missing_facts(db, node.device_id, facts):
continue # Already migrated, and its row holds the facts.
existing = await find_device_for(
db, ip=facts.get("ip"), mac=facts.get("mac"), ieee=facts.get("ieee_address")
)
# A node that is already linked only has its gaps filled: whatever
# is on the row was written after the migration and is newer.
device = await link_facts(
db,
node,
facts,
overwrite_scalars=node.device_id is None,
# The node's own columns are exactly what this canvas drew
# before the upgrade, empty lists included — so they define
# its view outright. Anything else the row carries (a service
# a scan fingerprinted, a property another canvas added)
# stays hidden here rather than appearing on every canvas.
strict_view=True,
)
if device is None:
continue
await db.flush()
except Exception 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:
+27 -30
View File
@@ -15,10 +15,11 @@ from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.models import Node, PendingDevice, ScanRun
from app.db.models import 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
from app.services.inventory_sync import merge_services
from app.services.mac_utils import normalize_mac
logger = logging.getLogger(__name__)
@@ -476,12 +477,12 @@ async def _dedupe_pending_by_ip(db: AsyncSession) -> int:
otherwise the oldest row, and deletes the rest. Returns the number deleted.
"""
rows = (await db.execute(
select(PendingDevice)
.where(PendingDevice.status != "hidden", PendingDevice.ip.isnot(None))
.order_by(PendingDevice.discovered_at)
select(InventoryDevice)
.where(InventoryDevice.status != "hidden", InventoryDevice.ip.isnot(None))
.order_by(InventoryDevice.discovered_at)
)).scalars().all()
by_ip: dict[str, list[PendingDevice]] = {}
by_ip: dict[str, list[InventoryDevice]] = {}
for row in rows:
if row.ip is None: # guarded by the query, but keeps the type checker happy
continue
@@ -507,7 +508,7 @@ async def run_scan(
run_id: str,
deep_scan: DeepScanOptions | None = None,
) -> None:
"""Execute scan for given CIDR ranges and populate pending_devices."""
"""Execute scan for given CIDR ranges and populate device_inventory."""
from app.api.routes.status import broadcast_scan_update
deep_scan = deep_scan or DeepScanOptions()
@@ -527,7 +528,7 @@ async def run_scan(
# Devices already on a canvas are intentionally NOT suppressed: they stay
# in the inventory and are badged "In N canvas" via per-request correlation.
hidden_ips_result = await db.execute(
select(PendingDevice.ip).where(PendingDevice.status == "hidden")
select(InventoryDevice.ip).where(InventoryDevice.status == "hidden")
)
hidden_ips: set[str] = {row[0] for row in hidden_ips_result.fetchall()}
@@ -570,13 +571,13 @@ async def run_scan(
# a duplicate — and so a device previously imported from Proxmox (which
# may have no IP but a known NIC MAC) reconciles with this scan instead
# of doubling up. Hidden rows are already skipped above.
match_cond = [PendingDevice.ip == ip]
match_cond = [InventoryDevice.ip == ip]
if norm_mac:
match_cond.append(PendingDevice.mac == norm_mac)
match_cond.append(InventoryDevice.mac == norm_mac)
existing_rows = (await db.execute(
select(PendingDevice)
.where(or_(*match_cond), PendingDevice.status != "hidden")
.order_by(PendingDevice.discovered_at)
select(InventoryDevice)
.where(or_(*match_cond), InventoryDevice.status != "hidden")
.order_by(InventoryDevice.discovered_at)
)).scalars().all()
if existing_rows:
@@ -591,7 +592,14 @@ async def run_scan(
keep.mac = norm_mac or keep.mac
keep.hostname = host.get("hostname") or keep.hostname
keep.os = host.get("os") or keep.os
keep.services = services
# Union, never replace. Since 3.3.0 the row is the only copy of
# a device's services — every canvas drawing it reads this list
# — so overwriting it with the fingerprint would delete services
# the user added by hand, on every canvas at once. What a scan
# finds is added; what it no longer sees stays. A service the
# user does not want on a canvas is hidden by that node's view,
# which is where "I deleted this" belongs.
keep.services = merge_services(keep.services, services)
# Don't downgrade a Proxmox-typed guest (vm/lxc) to the generic
# scan guess; the importer knows the true type.
if not (keep.ieee_address or "").startswith("pve-"):
@@ -599,8 +607,12 @@ 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(PendingDevice(
db.add(InventoryDevice(
ip=ip,
mac=norm_mac,
hostname=host.get("hostname"),
@@ -610,25 +622,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,423 @@
"""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
import shutil
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()
async def test_upgrade_keeps_each_canvas_drawing_its_own_services(db_320):
"""3.3.3: order and visibility become the node's, and the upgrade freezes them.
Two canvases drew the same host with different service lists, and a scan had
already fingerprinted a third the user put on neither. All three converge on
one inventory row — so each node must come out of the upgrade still drawing
what it drew, with the scanner's guess hidden on both.
"""
db_path, engine = db_320
await _build_320(engine)
ssh = '[{"port": 22, "protocol": "tcp", "service_name": "ssh"}]'
both = '[{"port": 22, "protocol": "tcp", "service_name": "ssh"}, ' \
'{"port": 443, "protocol": "tcp", "service_name": "https"}]'
async with engine.begin() as conn:
await conn.exec_driver_sql(f"UPDATE nodes SET services = '{ssh}' WHERE id = 'n1'")
await conn.exec_driver_sql(f"UPDATE nodes SET services = '{both}' WHERE id = 'n2'")
await database.init_db()
from app.db.models import InventoryDevice, Node
session_factory = database.async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
drawn = {}
for node_id in ("n1", "n2"):
node = await session.get(Node, node_id)
device = await session.get(InventoryDevice, node.device_id)
payload = inventory_sync.hydrated_node(node, device)
drawn[node_id] = [s["service_name"] for s in payload["services"] if s.get("visible", True)]
# Seeded, not left to "show the whole row".
assert node.display_view is not None
assert drawn == {"n1": ["ssh"], "n2": ["ssh", "https"]}
# What a scan finds next lands on the row, and on no canvas.
node = await session.get(Node, "n1")
device = await session.get(InventoryDevice, node.device_id)
device.services = [
*device.services,
{"port": 3001, "protocol": "tcp", "service_name": "Uptime Kuma"},
]
await session.commit()
payload = inventory_sync.hydrated_node(node, device)
assert [(s["service_name"], s.get("visible", True)) for s in payload["services"]] == [
("ssh", True), ("Uptime Kuma", False),
]
await engine.dispose()
async def _wind_back_to_3_3_2(engine, extra_service: dict) -> None:
"""A database that already took the 3.3.0 upgrade, leak included.
Both nodes point at one row holding the union of what each drew plus what a
scan found, and neither has a view — which is every 3.3.0-3.3.2 install.
"""
from app.db.models import InventoryDevice, Node
async with engine.begin() as conn:
await conn.exec_driver_sql("UPDATE nodes SET display_view = NULL")
session_factory = database.async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
node = await session.get(Node, "n1")
one = await session.get(InventoryDevice, node.device_id)
other = await session.get(InventoryDevice, (await session.get(Node, "n2")).device_id)
for device in (one, other):
device.services = [
{"port": 22, "protocol": "tcp", "service_name": "ssh"},
{"port": 443, "protocol": "tcp", "service_name": "https"},
extra_service,
]
await session.commit()
async def test_a_database_already_on_3_3_recovers_its_layout_from_the_backup(db_320):
"""The second upgrade path: 3.3.0-3.3.2, where the legacy columns are gone.
3.3.0 unioned every canvas' services onto one row, so the row can no longer
say who drew what — but the backup taken before that migration still can.
Recovering from it is the difference between the user getting their canvases
back and getting each canvas showing every other canvas' services.
"""
_, engine = db_320
await _build_320(engine)
ssh = '[{"port": 22, "protocol": "tcp", "service_name": "ssh"}]'
both = '[{"port": 22, "protocol": "tcp", "service_name": "ssh"}, ' \
'{"port": 443, "protocol": "tcp", "service_name": "https"}]'
async with engine.begin() as conn:
await conn.exec_driver_sql(f"UPDATE nodes SET services = '{ssh}' WHERE id = 'n1'")
await conn.exec_driver_sql(f"UPDATE nodes SET services = '{both}' WHERE id = 'n2'")
await database.init_db() # 3.2.0 -> 3.3.x, and the backup that predates it.
kuma = {"port": 3001, "protocol": "tcp", "service_name": "Uptime Kuma"}
await _wind_back_to_3_3_2(engine, kuma)
await database.init_db()
from app.db.models import InventoryDevice, Node
session_factory = database.async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
drawn = {}
for node_id in ("n1", "n2"):
node = await session.get(Node, node_id)
device = await session.get(InventoryDevice, node.device_id)
payload = inventory_sync.hydrated_node(node, device)
drawn[node_id] = [s["service_name"] for s in payload["services"] if s.get("visible", True)]
assert drawn == {"n1": ["ssh"], "n2": ["ssh", "https"]}
await engine.dispose()
async def test_a_property_added_while_on_3_3_survives_the_recovery(db_320):
"""The backup is 3.2.0-era and cannot know what the user added afterwards.
On 3.3.0-3.3.2 the row was the only place to add a property, and every
canvas drew it. Recovering the view strictly from the backup would hide it
on all of them at once — the "my properties disappeared" half of #347. The
scanner's service find is still held back: only properties are appended.
"""
_, engine = db_320
await _build_320(engine)
rack = '[{"key": "Rack", "value": "A", "icon": null, "visible": true}]'
ssh = '[{"port": 22, "protocol": "tcp", "service_name": "ssh"}]'
async with engine.begin() as conn:
await conn.exec_driver_sql(
f"UPDATE nodes SET properties = '{rack}', services = '{ssh}' WHERE id = 'n1'"
)
await database.init_db() # 3.2.0 -> 3.3.x, and the backup that predates it.
kuma = {"port": 3001, "protocol": "tcp", "service_name": "Uptime Kuma"}
await _wind_back_to_3_3_2(engine, kuma)
from app.db.models import InventoryDevice, Node
session_factory = database.async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
node = await session.get(Node, "n1")
device = await session.get(InventoryDevice, node.device_id)
# What the user re-added by hand while running 3.3.x.
device.properties = [
*device.properties,
{"key": "Ports", "value": "8", "icon": None, "visible": True},
]
await session.commit()
await database.init_db()
async with session_factory() as session:
node = await session.get(Node, "n1")
device = await session.get(InventoryDevice, node.device_id)
payload = inventory_sync.hydrated_node(node, device)
assert [(p["key"], p.get("visible", True)) for p in payload["properties"]] == [
("Rack", True), ("Ports", True),
]
# And the scan's find is still off this canvas.
assert [s["service_name"] for s in payload["services"] if s.get("visible", True)] == ["ssh"]
await engine.dispose()
async def test_without_a_usable_backup_the_row_is_the_seed(db_320):
"""No backup to recover from: keep showing what the canvas shows today.
Nothing is taken away — the user simply keeps the merged list they have been
looking at since 3.3.0, and only what the row gains *after* this boot is
held back.
"""
db_path, engine = db_320
await _build_320(engine)
await database.init_db()
kuma = {"port": 3001, "protocol": "tcp", "service_name": "Uptime Kuma"}
await _wind_back_to_3_3_2(engine, kuma)
for backup in db_path.parent.glob(f"{db_path.name}.back-*"):
backup.unlink()
await database.init_db()
from app.db.models import InventoryDevice, Node
session_factory = database.async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
node = await session.get(Node, "n1")
device = await session.get(InventoryDevice, node.device_id)
payload = inventory_sync.hydrated_node(node, device)
assert [s["service_name"] for s in payload["services"]] == ["ssh", "https", "Uptime Kuma"]
assert all(s.get("visible", True) for s in payload["services"])
# From here on the node is pinned: the next scan's find is held back.
device.services = [*device.services, {"port": 5001, "protocol": "tcp", "service_name": "Synology DSM HTTPS"}]
await session.commit()
payload = inventory_sync.hydrated_node(node, device)
assert [s["service_name"] for s in payload["services"] if not s.get("visible", True)] == [
"Synology DSM HTTPS"
]
await engine.dispose()
async def test_the_newest_backup_that_still_has_the_columns_is_the_one_read(db_320):
"""A 3.3.2 install has several backups; only some can answer.
`homelab.db.back-3.3.1` and `-3.3.2` were taken *after* the split and hold
nothing per node; `-3.3.0` was taken before it. Reading the wrong one would
either say nothing or resurrect a much older canvas.
"""
db_path, engine = db_320
await _build_320(engine)
async with engine.begin() as conn:
await conn.exec_driver_sql(
"UPDATE nodes SET services = "
"'[{\"port\": 22, \"protocol\": \"tcp\", \"service_name\": \"ssh\"}]' WHERE id = 'n1'"
)
# Ancient: same shape, but a canvas the user has long since moved on from.
old = db_path.parent / f"{db_path.name}.back-3.1.0"
shutil.copy2(db_path, old)
os.utime(old, (1, 1))
async with engine.begin() as conn:
await conn.exec_driver_sql(
"UPDATE nodes SET services = "
"'[{\"port\": 443, \"protocol\": \"tcp\", \"service_name\": \"https\"}]' WHERE id = 'n1'"
)
pre_split = db_path.parent / f"{db_path.name}.back-3.3.0"
shutil.copy2(db_path, pre_split)
await database.init_db() # drops the columns, and backs up under this version
# Post-split backups: newer, and unable to say who drew what.
for name in ("back-3.3.1", "back-3.3.2"):
shutil.copy2(db_path, db_path.parent / f"{db_path.name}.{name}")
assert database._pre_split_backup() == pre_split
assert database._views_from_backup()["n1"]["services"] == [
{"port": 443, "protocol": "tcp", "service_name": "https"}
]
await engine.dispose()
+5 -5
View File
@@ -3,13 +3,13 @@ import uuid
import pytest
from app.db.models import PendingDevice
from app.db.models import InventoryDevice
@pytest.fixture
async def pending_device(db_session):
import uuid
device = PendingDevice(
device = InventoryDevice(
id=str(uuid.uuid4()),
ip="192.168.1.100",
mac="aa:bb:cc:dd:ee:ff",
@@ -39,10 +39,10 @@ async def mem_db():
@pytest.fixture
async def two_pending_devices(db_session):
async def two_device_inventory(db_session):
devices = []
for i in range(2):
d = PendingDevice(
d = InventoryDevice(
id=str(uuid.uuid4()),
ip=f"192.168.1.{10 + i}",
mac=None,
@@ -62,7 +62,7 @@ async def two_pending_devices(db_session):
@pytest.fixture
async def zigbee_pending_device(db_session):
device = PendingDevice(
device = InventoryDevice(
id=str(uuid.uuid4()),
ip=None,
mac=None,
+41 -10
View File
@@ -1,7 +1,7 @@
"""Shared builders for scan test suite (pure helpers, no fixtures)."""
import uuid
from app.db.models import Design, Node, PendingDevice
from app.db.models import Design, InventoryDevice, Node
async def _add_design(db_session, name: str) -> str:
@@ -11,27 +11,58 @@ 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 Node, PendingDeviceLink
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)
pending = PendingDevice(
pending = InventoryDevice(
ieee_address="0xR1",
friendly_name="router_1",
suggested_type="zigbee_router",
@@ -42,7 +73,7 @@ async def _seed_zigbee_pending_pair(db_session):
db_session.add(pending)
db_session.add(
PendingDeviceLink(
InventoryDeviceLink(
source_ieee="0xCOORD",
target_ieee="0xR1",
discovery_source="zigbee",
+90 -75
View File
@@ -5,14 +5,14 @@ import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.db.models import Design, Node, PendingDevice
from tests.scan.helpers import _add_design, _node
from app.db.models import Design, InventoryDevice, 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 = PendingDevice(
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 = PendingDevice(
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 = PendingDevice(
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}
@@ -399,8 +399,8 @@ async def test_ignore_device(client: AsyncClient, headers, pending_device):
@pytest.mark.asyncio
async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_pending_devices):
ids = [d.id for d in two_pending_devices]
async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_device_inventory):
ids = [d.id for d in two_device_inventory]
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
assert res.status_code == 200
data = res.json()
@@ -418,7 +418,7 @@ async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_p
@pytest.mark.asyncio
async def test_bulk_approve_places_already_approved_device_on_another_design(
client: AsyncClient, headers, db_session, two_pending_devices
client: AsyncClient, headers, db_session, two_device_inventory
):
"""Regression: a device already approved (status='approved', e.g. placed on
another canvas) must still get a node on the design being approved onto.
@@ -426,7 +426,7 @@ async def test_bulk_approve_places_already_approved_device_on_another_design(
Previously bulk-approve filtered status=='pending', so selecting an
already-approved device created no node — the user saw fewer nodes than
they selected."""
ids = [d.id for d in two_pending_devices]
ids = [d.id for d in two_device_inventory]
design_a = await _add_design(db_session, "Canvas A")
design_b = await _add_design(db_session, "Canvas B")
@@ -458,13 +458,13 @@ async def test_bulk_approve_places_already_approved_device_on_another_design(
@pytest.mark.asyncio
async def test_bulk_approve_skips_device_already_on_target_design(
client: AsyncClient, headers, db_session, two_pending_devices
client: AsyncClient, headers, db_session, two_device_inventory
):
"""A device already on the target canvas (same ip) is not placed twice."""
ids = [d.id for d in two_pending_devices]
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,20 +480,21 @@ 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
async def test_bulk_approve_skips_device_matching_ip_in_comma_list(
client: AsyncClient, headers, db_session, two_pending_devices
client: AsyncClient, headers, db_session, two_device_inventory
):
"""The on-canvas node's ip holds an IPv6 before the IPv4; the device scanned
as the plain IPv4 is still recognised as already placed (issue #258)."""
ids = [d.id for d in two_pending_devices]
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(
@@ -509,14 +510,13 @@ async def test_bulk_approve_skips_device_matching_ip_in_comma_list(
@pytest.mark.asyncio
async def test_bulk_approve_reports_skipped_devices(
client: AsyncClient, headers, db_session, two_pending_devices
client: AsyncClient, headers, db_session, two_device_inventory
):
"""Bulk can't prompt per-device, so it reports each duplicate it skipped
(with the existing node id) instead of silently dropping it."""
ids = [d.id for d in two_pending_devices]
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}
]
@@ -568,7 +572,7 @@ async def test_bulk_approve_copies_mac_to_node_and_properties(
from sqlalchemy import select
from app.db.models import Node as NodeModel
device = PendingDevice(
device = InventoryDevice(
id=str(uuid.uuid4()),
ip="192.168.1.55",
mac="11:22:33:44:55:66",
@@ -587,28 +591,34 @@ 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}
]
@pytest.mark.asyncio
async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_pending_devices, db_session):
async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_device_inventory, db_session):
"""Approved devices with an IP must default to ping; otherwise scheduler skips them."""
from sqlalchemy import select
from app.db.models import Node as NodeModel
ids = [d.id for d in two_pending_devices]
ids = [d.id for d in two_device_inventory]
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
assert res.status_code == 200
nodes = (await db_session.execute(select(NodeModel))).scalars().all()
for n in nodes:
if n.ip:
assert n.check_method == "ping", f"node {n.id} created without check_method"
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,12 +634,14 @@ 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
async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers, two_pending_devices):
ids = [d.id for d in two_pending_devices]
async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers, two_device_inventory):
ids = [d.id for d in two_device_inventory]
# Approve first device individually first
await client.post(
f"/api/v1/scan/pending/{ids[0]}/approve",
@@ -645,15 +657,15 @@ async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers,
@pytest.mark.asyncio
async def test_bulk_approve_requires_auth(client: AsyncClient, two_pending_devices):
ids = [d.id for d in two_pending_devices]
async def test_bulk_approve_requires_auth(client: AsyncClient, two_device_inventory):
ids = [d.id for d in two_device_inventory]
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids})
assert res.status_code == 401
@pytest.mark.asyncio
async def test_bulk_hide_hides_devices(client: AsyncClient, headers, two_pending_devices):
ids = [d.id for d in two_pending_devices]
async def test_bulk_hide_hides_devices(client: AsyncClient, headers, two_device_inventory):
ids = [d.id for d in two_device_inventory]
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
assert res.status_code == 200
data = res.json()
@@ -665,8 +677,8 @@ async def test_bulk_hide_hides_devices(client: AsyncClient, headers, two_pending
@pytest.mark.asyncio
async def test_bulk_hide_skips_non_pending(client: AsyncClient, headers, two_pending_devices):
ids = [d.id for d in two_pending_devices]
async def test_bulk_hide_skips_non_pending(client: AsyncClient, headers, two_device_inventory):
ids = [d.id for d in two_device_inventory]
# Hide first device individually first
await client.post(f"/api/v1/scan/pending/{ids[0]}/hide", headers=headers)
# Bulk hide both — first is already hidden (not pending anymore)
@@ -678,8 +690,8 @@ async def test_bulk_hide_skips_non_pending(client: AsyncClient, headers, two_pen
@pytest.mark.asyncio
async def test_bulk_hide_requires_auth(client: AsyncClient, two_pending_devices):
ids = [d.id for d in two_pending_devices]
async def test_bulk_hide_requires_auth(client: AsyncClient, two_device_inventory):
ids = [d.id for d in two_device_inventory]
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids})
assert res.status_code == 401
@@ -690,7 +702,7 @@ async def test_bulk_approve_targets_requested_design(client, headers, db_session
first design — otherwise approved devices land on the wrong canvas."""
first = await _add_design(db_session, "Default") # first design (fallback)
active = await _add_design(db_session, "zwave") # the design the user is on
dev = PendingDevice(
dev = InventoryDevice(
id=str(uuid.uuid4()),
ieee_address="zwave-H-2",
friendly_name="Living Room Plug",
@@ -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
+45 -29
View File
@@ -5,7 +5,7 @@ import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.db.models import Node, PendingDevice
from app.db.models import InventoryDevice, Node
from tests.scan.helpers import _add_design, _seed_zigbee_pending_pair
@@ -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
@@ -184,7 +196,7 @@ async def test_approve_zigbee_skips_duplicate_edge(
"""Re-running the resolution does not create a second edge for the same pair."""
from sqlalchemy import select
from app.db.models import Edge, PendingDevice, PendingDeviceLink
from app.db.models import Edge, InventoryDevice, InventoryDeviceLink
coord, pending = await _seed_zigbee_pending_pair(db_session)
body = {"label": "router_1", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []}
@@ -193,7 +205,7 @@ async def test_approve_zigbee_skips_duplicate_edge(
# Simulate a second pending row + link between same coord and a new device,
# but keep an existing edge in place to verify dedupe also handles
# the swapped-direction case.
new_pending = PendingDevice(
new_pending = InventoryDevice(
ieee_address="0xR1B",
friendly_name="r1b",
suggested_type="zigbee_router",
@@ -202,7 +214,7 @@ async def test_approve_zigbee_skips_duplicate_edge(
)
db_session.add(new_pending)
db_session.add(
PendingDeviceLink(source_ieee="0xCOORD", target_ieee="0xR1B", discovery_source="zigbee")
InventoryDeviceLink(source_ieee="0xCOORD", target_ieee="0xR1B", discovery_source="zigbee")
)
await db_session.commit()
res = await client.post(
@@ -220,16 +232,16 @@ async def test_approve_zigbee_skips_when_other_endpoint_still_pending(
"""Both endpoints pending → no edge yet, link row preserved for later."""
from sqlalchemy import select
from app.db.models import Edge, PendingDevice, PendingDeviceLink
from app.db.models import Edge, InventoryDevice, InventoryDeviceLink
a = PendingDevice(
a = InventoryDevice(
ieee_address="0xA",
friendly_name="a",
suggested_type="zigbee_router",
status="pending",
discovery_source="zigbee",
)
b = PendingDevice(
b = InventoryDevice(
ieee_address="0xB",
friendly_name="b",
suggested_type="zigbee_enddevice",
@@ -238,7 +250,7 @@ async def test_approve_zigbee_skips_when_other_endpoint_still_pending(
)
db_session.add_all([a, b])
db_session.add(
PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
InventoryDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
)
await db_session.commit()
@@ -258,7 +270,7 @@ async def test_approve_zigbee_skips_when_other_endpoint_still_pending(
edges = (await db_session.execute(select(Edge))).scalars().all()
assert edges == []
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
links = (await db_session.execute(select(InventoryDeviceLink))).scalars().all()
assert len(links) == 1 # preserved for later resolution
@@ -271,16 +283,16 @@ async def test_approve_zigbee_resolves_link_after_second_approval(
be re-approved onto another canvas — it's topology, wiped only on reimport."""
from sqlalchemy import select
from app.db.models import Edge, PendingDevice, PendingDeviceLink
from app.db.models import Edge, InventoryDevice, InventoryDeviceLink
a = PendingDevice(
a = InventoryDevice(
ieee_address="0xA",
friendly_name="a",
suggested_type="zigbee_router",
status="pending",
discovery_source="zigbee",
)
b = PendingDevice(
b = InventoryDevice(
ieee_address="0xB",
friendly_name="b",
suggested_type="zigbee_enddevice",
@@ -289,7 +301,7 @@ async def test_approve_zigbee_resolves_link_after_second_approval(
)
db_session.add_all([a, b])
db_session.add(
PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
InventoryDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
)
await db_session.commit()
@@ -300,14 +312,14 @@ async def test_approve_zigbee_resolves_link_after_second_approval(
edges = (await db_session.execute(select(Edge))).scalars().all()
assert len(edges) == 1
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
links = (await db_session.execute(select(InventoryDeviceLink))).scalars().all()
assert len(links) == 1 # retained for re-approval onto other canvases
@pytest.mark.asyncio
async def test_single_approve_zwave_sets_wireless_fields(client, headers, db_session):
active = await _add_design(db_session, "zwave")
dev = PendingDevice(
dev = InventoryDevice(
id=str(uuid.uuid4()),
ieee_address="zwave-H-9",
friendly_name="Door Sensor",
@@ -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)
+172 -18
View File
@@ -6,8 +6,8 @@ from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from app.db.models import PendingDevice
from tests.scan.helpers import _add_design, _node
from app.db.models import InventoryDevice
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)
@@ -91,13 +91,13 @@ async def test_canvas_count_counts_distinct_designs_by_ip(client, headers, db_se
@pytest.mark.asyncio
async def test_canvas_count_correlates_by_ieee(client, headers, db_session):
device = PendingDevice(
device = InventoryDevice(
id=str(uuid.uuid4()), ieee_address="0x00124b001", discovery_source="zigbee",
suggested_type="zigbee_enddevice", services=[], status="pending",
)
db_session.add(device)
d1 = await _add_design(db_session, "Zigbee")
db_session.add(_node(d1, ieee="0x00124b001"))
await _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
@@ -260,3 +266,151 @@ async def test_update_scan_config_persists_deep_scan(client: AsyncClient, header
)
assert res.status_code == 200
assert saved == {"http_ranges": ["9000-9100"], "probe": True}
@pytest.mark.asyncio
async def test_create_pending_normalizes_the_mac(client: AsyncClient, headers):
# Dedup compares MACs by equality, so a hand-typed entry in any other
# notation would never match the scanned row and approve would build a
# duplicate node.
res = await client.post(
"/api/v1/scan/pending",
json={
"hostname": "printer",
"mac": "AA-BB-CC-11-22-33",
"discovery_source": "manual",
},
headers=headers,
)
assert res.status_code == 201, res.text
assert res.json()["mac"] == "aa:bb:cc:11:22:33"
@pytest.mark.asyncio
async def test_create_pending_keeps_a_missing_mac_null(client: AsyncClient, headers):
res = await client.post(
"/api/v1/scan/pending",
json={"hostname": "no-mac", "discovery_source": "manual"},
headers=headers,
)
assert res.status_code == 201, res.text
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
# plate is pointed at a real inventory row.
res = await client.delete(f"/api/v1/scan/pending/{pending_device.id}", headers=headers)
assert res.status_code == 200, res.text
assert res.json() == {"deleted": True}
listed = (await client.get("/api/v1/scan/pending", headers=headers)).json()
assert listed == []
@pytest.mark.asyncio
async def test_delete_pending_unknown_is_404(client: AsyncClient, headers):
res = await client.delete(f"/api/v1/scan/pending/{uuid.uuid4()}", headers=headers)
assert res.status_code == 404
@pytest.mark.asyncio
async def test_delete_pending_requires_auth(client: AsyncClient, pending_device):
res = await client.delete(f"/api/v1/scan/pending/{pending_device.id}")
assert res.status_code == 401
@pytest.mark.asyncio
async def test_delete_pending_refuses_a_mounted_device(client: AsyncClient, headers, pending_device):
# `rack_devices.device_id` is ON DELETE SET NULL: without this guard the
# delete would strip the mount of its device instead of erroring.
design = (
await client.post(
"/api/v1/designs",
json={"name": "Rack Room", "icon": "server", "design_type": "rack"},
headers=headers,
)
).json()["id"]
saved = await client.post(
"/api/v1/racks/save",
json={
"design_id": design,
"racks": [
{
"id": "rack-1",
"name": "Main",
"u_height": 12,
"width_standard": "19",
"numbering": "bottom-up",
"style": {},
"pos_x": 0,
"pos_y": 0,
}
],
"devices": [
{
"id": "dev-1",
"rack_id": "rack-1",
"device_id": pending_device.id,
"label": "my-server",
"u_start": 1,
"u_height": 1,
"col_start": 0,
"col_span": 12,
"faceplate_id": "server-1u",
"status": "unknown",
"ports": [],
}
],
"cables": [],
"viewport": {"x": 0, "y": 0, "zoom": 1},
},
headers=headers,
)
assert saved.status_code == 200, saved.text
res = await client.delete(f"/api/v1/scan/pending/{pending_device.id}", headers=headers)
assert res.status_code == 409
listed = (await client.get("/api/v1/scan/pending", headers=headers)).json()
assert [d["id"] for d in listed] == [pending_device.id]
+46 -39
View File
@@ -7,7 +7,7 @@ from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Node, PendingDevice, ScanRun
from app.db.models import InventoryDevice, Node, ScanRun
from app.services.scanner import _cancelled_runs, request_cancel, run_scan
@@ -118,7 +118,7 @@ async def test_run_scan_creates_new_pending_device(db_session: AsyncSession):
await run_scan(["192.168.1.0/24"], db_session, run_id)
result = await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
)
device = result.scalar_one_or_none()
assert device is not None
@@ -131,17 +131,7 @@ 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 = PendingDevice(
stale = InventoryDevice(
id=str(uuid.uuid4()),
ip="192.168.1.50",
mac=None,
@@ -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())
@@ -167,26 +166,30 @@ async def test_run_scan_keeps_stale_pending_for_canvas_nodes(db_session: AsyncSe
await run_scan(["192.168.1.0/24"], db_session, run_id)
result = await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
)
assert result.scalar_one_or_none() is not None
@pytest.mark.asyncio
async def test_run_scan_records_ip_already_in_canvas(db_session: AsyncSession):
"""A scanned IP that already exists as a canvas Node still produces a pending
device (no longer suppressed)."""
node = Node(
"""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,19 +203,23 @@ 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(PendingDevice).where(PendingDevice.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
async def test_run_scan_refreshes_approved_device_without_duplicating(db_session: AsyncSession):
"""Re-scanning an already-approved device updates its row in place instead of
spawning a fresh pending duplicate, and keeps it approved."""
approved = PendingDevice(
approved = InventoryDevice(
id=str(uuid.uuid4()), ip="192.168.1.50", mac=None, hostname="old",
os=None, services=[], suggested_type="server", status="approved",
)
@@ -228,7 +235,7 @@ async def test_run_scan_refreshes_approved_device_without_duplicating(db_session
await run_scan(["192.168.1.0/24"], db_session, run_id)
rows = (await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
)).scalars().all()
assert len(rows) == 1
assert rows[0].status == "approved"
@@ -240,7 +247,7 @@ async def test_run_scan_collapses_existing_duplicate_rows(db_session: AsyncSessi
"""Pre-existing duplicate inventory rows for one IP are collapsed to a single
row at scan start, even if the device is not re-discovered."""
for status in ("approved", "pending", "pending"):
db_session.add(PendingDevice(
db_session.add(InventoryDevice(
id=str(uuid.uuid4()), ip="192.168.1.77", mac=None, hostname=None,
os=None, services=[], suggested_type="server", status=status,
))
@@ -255,7 +262,7 @@ async def test_run_scan_collapses_existing_duplicate_rows(db_session: AsyncSessi
await run_scan(["192.168.1.0/24"], db_session, run_id)
rows = (await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.77")
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.77")
)).scalars().all()
assert len(rows) == 1
assert rows[0].status == "approved" # approved row is the one kept
@@ -264,7 +271,7 @@ async def test_run_scan_collapses_existing_duplicate_rows(db_session: AsyncSessi
@pytest.mark.asyncio
async def test_run_scan_skips_hidden_device(db_session: AsyncSession):
"""Devices previously hidden by the user must not re-appear in pending on re-scan."""
hidden = PendingDevice(
hidden = InventoryDevice(
id=str(uuid.uuid4()),
ip="192.168.1.50",
mac=None,
@@ -289,9 +296,9 @@ async def test_run_scan_skips_hidden_device(db_session: AsyncSession):
await run_scan(["192.168.1.0/24"], db_session, run_id)
result = await db_session.execute(
select(PendingDevice).where(
PendingDevice.ip == "192.168.1.50",
PendingDevice.status == "pending",
select(InventoryDevice).where(
InventoryDevice.ip == "192.168.1.50",
InventoryDevice.status == "pending",
)
)
assert result.scalar_one_or_none() is None
@@ -395,7 +402,7 @@ async def test_run_scan_cancelled_mid_scan_skips_remaining_cidrs(db_session: Asy
async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession):
"""Re-scanning the same IP updates services instead of creating a duplicate."""
# Pre-existing pending device with no services
existing = PendingDevice(
existing = InventoryDevice(
id=str(uuid.uuid4()),
ip="192.168.1.50",
mac=None,
@@ -421,7 +428,7 @@ async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession
# Should still be only one device
result = await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
)
devices = list(result.scalars().all())
assert len(devices) == 1
+131
View File
@@ -0,0 +1,131 @@
"""The pending_devices -> device_inventory rename, run at startup.
It is the one migration that must happen *before* `create_all`, so it gets its
own suite: get the order wrong and every device a user ever scanned reads as
gone, because the app looks at a freshly created empty table.
"""
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
from app.db.database import _rename_legacy_tables
pytestmark = pytest.mark.asyncio
def _legacy_ddl(table: str) -> str:
return f"CREATE TABLE {table} (id VARCHAR PRIMARY KEY, hostname VARCHAR)"
async def _engine(tmp_path, *, statements: list[str]):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'test.db'}")
async with engine.begin() as conn:
for sql in statements:
await conn.exec_driver_sql(sql)
return engine
async def _tables(engine) -> set[str]:
async with engine.begin() as conn:
rows = (
await conn.exec_driver_sql("SELECT name FROM sqlite_master WHERE type='table'")
).fetchall()
return {r[0] for r in rows}
async def _rename(engine) -> None:
async with engine.begin() as conn:
await _rename_legacy_tables(conn)
async def test_renames_the_legacy_tables_keeping_their_rows(tmp_path):
engine = await _engine(
tmp_path,
statements=[
_legacy_ddl("pending_devices"),
_legacy_ddl("pending_device_links"),
"INSERT INTO pending_devices (id, hostname) VALUES ('d1', 'nas')",
],
)
await _rename(engine)
assert await _tables(engine) == {"device_inventory", "device_inventory_links"}
async with engine.begin() as conn:
rows = (await conn.exec_driver_sql("SELECT hostname FROM device_inventory")).fetchall()
assert [r[0] for r in rows] == ["nas"]
await engine.dispose()
async def test_rewrites_the_reference_from_rack_devices(tmp_path):
# `rack_devices.device_id` names the inventory table. Left pointing at the
# old name, the schema references a table that no longer exists.
engine = await _engine(
tmp_path,
statements=[
_legacy_ddl("pending_devices"),
"CREATE TABLE rack_devices (id VARCHAR PRIMARY KEY, device_id VARCHAR "
"REFERENCES pending_devices(id) ON DELETE SET NULL)",
],
)
await _rename(engine)
async with engine.begin() as conn:
ddl = (
await conn.exec_driver_sql(
"SELECT sql FROM sqlite_master WHERE name='rack_devices'"
)
).scalar()
assert "device_inventory" in ddl
assert "pending_devices" not in ddl
await engine.dispose()
async def test_replaces_an_empty_table_left_by_an_earlier_start(tmp_path):
# A start that ran `create_all` before this migration existed created the new
# table empty, and the app read that one instead of the user's devices.
engine = await _engine(
tmp_path,
statements=[
_legacy_ddl("pending_devices"),
_legacy_ddl("device_inventory"),
"INSERT INTO pending_devices (id, hostname) VALUES ('d1', 'nas')",
],
)
await _rename(engine)
assert await _tables(engine) == {"device_inventory"}
async with engine.begin() as conn:
count = (await conn.exec_driver_sql("SELECT COUNT(*) FROM device_inventory")).scalar()
assert count == 1
await engine.dispose()
async def test_leaves_two_populated_tables_alone(tmp_path):
# Rows on both sides is not a state this migration created, and dropping
# either one would lose data.
engine = await _engine(
tmp_path,
statements=[
_legacy_ddl("pending_devices"),
_legacy_ddl("device_inventory"),
"INSERT INTO pending_devices (id, hostname) VALUES ('d1', 'old')",
"INSERT INTO device_inventory (id, hostname) VALUES ('d2', 'new')",
],
)
await _rename(engine)
assert await _tables(engine) == {"pending_devices", "device_inventory"}
await engine.dispose()
async def test_is_a_no_op_on_a_fresh_database(tmp_path):
engine = await _engine(tmp_path, statements=[_legacy_ddl("device_inventory")])
await _rename(engine)
await _rename(engine) # And still a no-op the second time round.
assert await _tables(engine) == {"device_inventory"}
await engine.dispose()
+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"}])
File diff suppressed because it is too large Load Diff
+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
+66 -44
View File
@@ -17,7 +17,7 @@ from app.api.routes.proxmox import (
)
from app.api.routes.scan import _is_proxmox_cluster_member, _resolve_pending_links_for_ieee
from app.core.config import settings
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink
from app.db.models import Design, Edge, InventoryDevice, InventoryDeviceLink, Node
@pytest.fixture(autouse=True)
@@ -212,7 +212,7 @@ async def test_persist_creates_pending(db_session) -> None:
result = await _persist_pending_import(db_session, nodes, edges)
assert result.pending_created == 2
assert result.links_recorded == 1
rows = (await db_session.execute(select(PendingDevice))).scalars().all()
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
assert {r.suggested_type for r in rows} == {"proxmox", "vm"}
# Specs carried as properties.
vm = next(r for r in rows if r.suggested_type == "vm")
@@ -222,31 +222,39 @@ 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(PendingDevice).where(PendingDevice.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
async def test_persist_merges_pending_scan_row_by_mac(db_session) -> None:
# Device previously found by an IP scan: arp source, MAC known, no ieee.
db_session.add(PendingDevice(
db_session.add(InventoryDevice(
id=str(uuid.uuid4()), ip="10.0.0.5", mac="bc:24:11:aa:bb:cc",
suggested_type="generic", status="pending",
discovery_source="arp", discovery_sources=["arp"],
@@ -257,7 +265,7 @@ async def test_persist_merges_pending_scan_row_by_mac(db_session) -> None:
# different casing. Must merge, not duplicate.
await _persist_pending_import(db_session, [_guest_node(101, None, mac="BC:24:11:AA:BB:CC")], [])
rows = (await db_session.execute(select(PendingDevice))).scalars().all()
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
assert len(rows) == 1
row = rows[0]
assert row.ieee_address == "pve-pve1-101" # adopted proxmox identity
@@ -269,7 +277,7 @@ async def test_persist_merges_pending_scan_row_by_mac(db_session) -> None:
async def test_persist_preserves_ip_tag_for_legacy_null_source_row(db_session) -> None:
# Legacy inventory row from an old IP scan, before discovery_source(s) were
# recorded: scalar NULL, sources empty — but it has an IP + MAC.
db_session.add(PendingDevice(
db_session.add(InventoryDevice(
id=str(uuid.uuid4()), ip="192.168.1.108", mac="bc:24:11:6c:96:52",
suggested_type="lxc", status="pending",
discovery_source=None, discovery_sources=[],
@@ -280,7 +288,7 @@ async def test_persist_preserves_ip_tag_for_legacy_null_source_row(db_session) -
db_session, [_guest_node(108, "192.168.1.108", mac="BC:24:11:6C:96:52")], []
)
rows = (await db_session.execute(select(PendingDevice))).scalars().all()
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
assert len(rows) == 1
# The IP tag must survive the proxmox merge even with no recorded origin.
assert set(rows[0].discovery_sources) == {"arp", "proxmox"}
@@ -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 = PendingDevice(
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(
@@ -308,7 +316,7 @@ async def test_persist_preserves_ip_tag_on_canvas_merge_legacy_row(db_session) -
)
inv_row = (await db_session.execute(
select(PendingDevice).where(PendingDevice.mac == "bc:24:11:6c:96:52")
select(InventoryDevice).where(InventoryDevice.mac == "bc:24:11:6c:96:52")
)).scalar_one()
assert set(inv_row.discovery_sources) == {"arp", "proxmox"}
@@ -320,7 +328,7 @@ async def test_persist_does_not_add_ip_tag_to_pure_proxmox_guest(db_session) ->
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], []) # re-sync
row = (await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101")
select(InventoryDevice).where(InventoryDevice.ieee_address == "pve-pve1-101")
)).scalar_one()
assert set(row.discovery_sources) == {"proxmox"}
@@ -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
@@ -351,27 +366,27 @@ async def test_persist_resync_updates_in_place(db_session) -> None:
await _persist_pending_import(db_session, nodes, [])
# Second sync: same device, new IP. Should update, not duplicate.
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.9")], [])
rows = (await db_session.execute(select(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101"))).scalars().all()
rows = (await db_session.execute(select(InventoryDevice).where(InventoryDevice.ieee_address == "pve-pve1-101"))).scalars().all()
assert len(rows) == 1
assert rows[0].ip == "10.0.0.9"
@pytest.mark.asyncio
async def test_persist_keeps_hidden_hidden(db_session) -> None:
db_session.add(PendingDevice(
db_session.add(InventoryDevice(
id=str(uuid.uuid4()), ieee_address="pve-pve1-101", ip="10.0.0.5",
suggested_type="vm", status="hidden", discovery_source="proxmox",
))
await db_session.commit()
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
row = (await db_session.execute(select(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101"))).scalar_one()
row = (await db_session.execute(select(InventoryDevice).where(InventoryDevice.ieee_address == "pve-pve1-101"))).scalar_one()
assert row.status == "hidden"
@pytest.mark.asyncio
async def test_pending_endpoint_tolerates_legacy_null_properties(client: AsyncClient, headers: dict, db_session) -> None:
# Legacy row: properties column NULL (added by migration on older DBs).
dev = PendingDevice(
dev = InventoryDevice(
id=str(uuid.uuid4()), ip="192.168.1.9", suggested_type="server",
status="pending", discovery_source="arp",
)
@@ -396,7 +411,7 @@ async def test_persist_records_cluster_links_between_hosts(db_session) -> None:
edges = [{"source": "pve-node-pve1", "target": "pve-pve1-101"}]
await _persist_pending_import(db_session, nodes, edges)
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
links = (await db_session.execute(select(InventoryDeviceLink))).scalars().all()
cluster = [ln for ln in links if ln.discovery_source == "proxmox_cluster"]
assert len(cluster) == 1
assert (cluster[0].source_ieee, cluster[0].target_ieee) == ("pve-node-a", "pve-node-b")
@@ -410,7 +425,7 @@ async def test_persist_records_cluster_links_between_hosts(db_session) -> None:
async def test_single_host_records_no_cluster_link(db_session) -> None:
await _persist_pending_import(db_session, [_host_node()], [])
links = (await db_session.execute(
select(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "proxmox_cluster")
select(InventoryDeviceLink).where(InventoryDeviceLink.discovery_source == "proxmox_cluster")
)).scalars().all()
assert links == []
@@ -420,14 +435,16 @@ 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(PendingDeviceLink(
db_session.add(InventoryDeviceLink(
id=str(uuid.uuid4()), source_ieee="pve-node-a", target_ieee="pve-node-b",
discovery_source="proxmox_cluster",
))
@@ -451,15 +468,20 @@ 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(PendingDeviceLink(
db_session.add(InventoryDeviceLink(
id=str(uuid.uuid4()), source_ieee="0xAAAA", target_ieee="0xBBBB",
discovery_source="zigbee",
))
@@ -476,7 +498,7 @@ async def test_link_survives_and_resolves_onto_second_design(db_session) -> None
assert {e.design_id for e in edges} == {da.id, db_.id}
assert all(e.type == "iot" for e in edges)
# The link row is still present for any further design.
assert (await db_session.execute(select(PendingDeviceLink))).scalars().one()
assert (await db_session.execute(select(InventoryDeviceLink))).scalars().one()
@pytest.mark.asyncio
@@ -484,6 +506,6 @@ async def test_persist_never_deletes(db_session) -> None:
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
# A later sync that no longer includes vm101 must not remove it.
await _persist_pending_import(db_session, [_guest_node(202, "10.0.0.6")], [])
rows = (await db_session.execute(select(PendingDevice))).scalars().all()
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
ieees = {r.ieee_address for r in rows}
assert "pve-pve1-101" in ieees and "pve-pve1-202" in ieees
+34
View File
@@ -0,0 +1,34 @@
"""The rackable exclusion list is duplicated in the frontend — keep them equal.
`GET /api/v1/racks/inventory` filters on `_UNRACKABLE_TYPES`, while the Device
Inventory modal's "Rackable" filter (used as the rack picker's default) filters
on `UNRACKABLE_TYPES` in `frontend/src/utils/rackable.ts`. If the two drift, the
picker offers devices the rack side then refuses.
"""
import re
from pathlib import Path
from app.api.routes.racks import _UNRACKABLE_TYPES
FRONTEND_FILE = (
Path(__file__).resolve().parents[2] / "frontend" / "src" / "utils" / "rackable.ts"
)
def _frontend_unrackable() -> set[str]:
src = FRONTEND_FILE.read_text()
match = re.search(r"export const UNRACKABLE_TYPES = new Set\(\[(.*?)\]\)", src, re.S)
assert match, (
"Could not locate UNRACKABLE_TYPES in frontend/src/utils/rackable.ts — "
"update this parser if the file's shape changed."
)
return set(re.findall(r"'([^']+)'", match.group(1)))
def test_unrackable_types_in_sync_with_frontend():
frontend = _frontend_unrackable()
assert frontend == _UNRACKABLE_TYPES, (
"racks.py _UNRACKABLE_TYPES is out of sync with frontend rackable.ts.\n"
f"Missing from the backend: {sorted(frontend - _UNRACKABLE_TYPES)}\n"
f"Missing from the frontend: {sorted(_UNRACKABLE_TYPES - frontend)}"
)
+804
View File
@@ -0,0 +1,804 @@
import pytest
from httpx import AsyncClient
from sqlalchemy import text
from app.db.models import InventoryDevice
pytestmark = pytest.mark.asyncio
async def _design(client: AsyncClient, headers: dict, *, name="Rack Room", design_type="rack") -> str:
res = await client.post(
"/api/v1/designs",
json={"name": name, "icon": "server", "design_type": design_type},
headers=headers,
)
assert res.status_code == 201, res.text
return res.json()["id"]
def _state(design_id: str, **overrides) -> dict:
"""A minimal but complete rack payload: one rack, two devices, one patch."""
payload = {
"design_id": design_id,
"racks": [
{
"id": "rack-1",
"name": "Main",
"u_height": 12,
"width_standard": "19",
"numbering": "bottom-up",
"location": "garage",
"style": {"frame": "#1c2129", "showNumbers": True},
"pos_x": 40,
"pos_y": 60,
}
],
"devices": [
{
"id": "dev-sw",
"rack_id": "rack-1",
"label": "sw-24",
"u_start": 10,
"u_height": 1,
"col_start": 0,
"col_span": 12,
"faceplate_id": "switch-24",
"status": "online",
"ports": [{"id": "p1", "label": "1", "type": "rj45", "x": 0.4, "y": 0.5}],
},
{
"id": "dev-nas",
"rack_id": "rack-1",
"label": "nas",
"u_start": 4,
"u_height": 2,
"col_start": 0,
"col_span": 12,
"faceplate_id": "nas-2u",
"status": "unknown",
"ports": [{"id": "p2", "label": "eth0", "type": "rj45", "x": 0.7, "y": 0.5}],
},
],
"cables": [
{
"id": "cbl-1",
"from_device_id": "dev-sw",
"from_port_id": "p1",
"to_device_id": "dev-nas",
"to_port_id": "p2",
"type": "ethernet",
"color": "#39d353",
}
],
"viewport": {"x": 10, "y": 20, "zoom": 1.5},
}
payload.update(overrides)
return payload
class TestDesignType:
async def test_creates_a_rack_design(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
res = await client.get("/api/v1/designs", headers=headers)
design = next(d for d in res.json() if d["id"] == design_id)
assert design["design_type"] == "rack"
async def test_rejects_an_unknown_design_type(self, client: AsyncClient, headers):
res = await client.post(
"/api/v1/designs",
json={"name": "Nope", "design_type": "spaceship"},
headers=headers,
)
assert res.status_code == 422
class TestSaveAndLoad:
async def test_round_trips_the_full_state(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
res = await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
assert res.status_code == 200, res.text
loaded = (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).json()
assert [r["name"] for r in loaded["racks"]] == ["Main"]
assert loaded["racks"][0]["u_height"] == 12
assert loaded["racks"][0]["style"]["showNumbers"] is True
assert {d["id"] for d in loaded["devices"]} == {"dev-sw", "dev-nas"}
assert loaded["devices"][0]["ports"][0]["type"] == "rj45"
assert loaded["cables"][0]["from_port_id"] == "p1"
assert loaded["viewport"] == {"x": 10, "y": 20, "zoom": 1.5}
async def test_keeps_a_mount_that_follows_its_node_check(
self, client: AsyncClient, headers
):
# "Check device" is stored as `auto` on the mount: the rack has no
# checker, it reads the linked node's live status at render time. The
# status column is free-form, so this only guards it staying that way.
design_id = await _design(client, headers)
state = _state(design_id)
state["devices"][0]["status"] = "auto"
res = await client.post("/api/v1/racks/save", json=state, headers=headers)
assert res.status_code == 200, res.text
loaded = (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).json()
mount = next(d for d in loaded["devices"] if d["id"] == "dev-sw")
assert mount["status"] == "auto"
async def test_prunes_what_the_client_dropped(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
trimmed = _state(design_id)
trimmed["devices"] = [d for d in trimmed["devices"] if d["id"] == "dev-sw"]
trimmed["cables"] = []
await client.post("/api/v1/racks/save", json=trimmed, headers=headers)
loaded = (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).json()
assert {d["id"] for d in loaded["devices"]} == {"dev-sw"}
assert loaded["cables"] == []
async def test_updates_an_existing_device_in_place(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
moved = _state(design_id)
moved["devices"][1]["u_start"] = 1
moved["devices"][1]["label"] = "nas-renamed"
await client.post("/api/v1/racks/save", json=moved, headers=headers)
loaded = (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).json()
nas = next(d for d in loaded["devices"] if d["id"] == "dev-nas")
assert (nas["u_start"], nas["label"]) == (1, "nas-renamed")
async def test_keeps_designs_isolated(self, client: AsyncClient, headers):
first = await _design(client, headers, name="Rack A")
second = await _design(client, headers, name="Rack B")
await client.post("/api/v1/racks/save", json=_state(first), headers=headers)
loaded = (await client.get(f"/api/v1/racks?design_id={second}", headers=headers)).json()
assert loaded["racks"] == []
assert loaded["devices"] == []
async def test_refuses_to_steal_a_row_from_another_design(
self, client: AsyncClient, headers
):
"""Ids come from the client, so a stale tab or a copy can collide.
The upsert sets `design_id` from the payload: without a guard, saving
design B with an id owned by design A moved A's rack — devices and
cables with it — into B, and A silently lost it.
"""
first = await _design(client, headers, name="Rack A")
second = await _design(client, headers, name="Rack B")
await client.post("/api/v1/racks/save", json=_state(first), headers=headers)
res = await client.post("/api/v1/racks/save", json=_state(second), headers=headers)
assert res.status_code == 409
kept = (await client.get(f"/api/v1/racks?design_id={first}", headers=headers)).json()
assert [r["id"] for r in kept["racks"]] == ["rack-1"]
assert len(kept["devices"]) == 2
async def test_rejects_an_unknown_design(self, client: AsyncClient, headers):
res = await client.post("/api/v1/racks/save", json=_state("nope"), headers=headers)
assert res.status_code == 404
async def test_rejects_a_device_pointing_outside_the_payload(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
broken = _state(design_id)
broken["devices"][0]["rack_id"] = "rack-ghost"
res = await client.post("/api/v1/racks/save", json=broken, headers=headers)
assert res.status_code == 400
async def test_rejects_a_cable_pointing_outside_the_payload(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
broken = _state(design_id)
broken["cables"][0]["to_device_id"] = "dev-ghost"
res = await client.post("/api/v1/racks/save", json=broken, headers=headers)
assert res.status_code == 400
@pytest.mark.parametrize(
"patch",
[
{"u_height": 0},
{"width_standard": "23"},
{"numbering": "sideways"},
],
)
async def test_rejects_invalid_rack_geometry(self, client: AsyncClient, headers, patch):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["racks"][0].update(patch)
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
assert res.status_code == 422
@pytest.mark.parametrize(
"patch",
[
{"u_start": 0},
{"col_start": 12},
{"col_span": 13},
],
)
async def test_rejects_invalid_device_geometry(self, client: AsyncClient, headers, patch):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["devices"][0].update(patch)
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
assert res.status_code == 422
async def test_rejects_a_device_taller_than_its_rack(self, client: AsyncClient, headers):
# Each field is legal on its own; only the rack says otherwise. A mount
# above the top rail draws outside the chassis with no way to drag it back.
design_id = await _design(client, headers)
payload = _state(design_id)
payload["devices"][0]["u_start"] = 40 # rack is 12U
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
assert res.status_code == 422
async def test_rejects_a_device_whose_height_overruns_the_rack(
self, client: AsyncClient, headers
):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["devices"][0].update({"u_start": 11, "u_height": 4}) # 11..14 of 12U
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
assert res.status_code == 422
async def test_accepts_a_device_that_ends_on_the_top_rail(
self, client: AsyncClient, headers
):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["devices"][0].update({"u_start": 11, "u_height": 2}) # 11..12 of 12U
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
assert res.status_code == 200, res.text
async def test_rejects_a_span_that_overruns_the_column_grid(
self, client: AsyncClient, headers
):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["devices"][0].update({"col_start": 11, "col_span": 12})
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
assert res.status_code == 422
async def test_accepts_a_half_width_pair_sharing_one_u(
self, client: AsyncClient, headers
):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["devices"][0].update({"col_start": 0, "col_span": 6})
payload["devices"][1].update({"col_start": 6, "col_span": 6})
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
assert res.status_code == 200, res.text
async def test_rejects_an_unknown_cable_type(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["cables"][0]["type"] = "power"
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
assert res.status_code == 422
async def test_round_trips_cable_annotations(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["cables"][0].update(
{
"label": "Uplink to core",
"label_visible": True,
"properties": [
{"key": "Length", "value": "2 m", "icon": None, "visible": True},
{"key": "VLAN", "value": "20", "icon": None, "visible": False},
],
}
)
assert (
await client.post("/api/v1/racks/save", json=payload, headers=headers)
).status_code == 200
cable = (
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
).json()["cables"][0]
assert cable["label"] == "Uplink to core"
assert cable["label_visible"] is True
assert [p["key"] for p in cable["properties"]] == ["Length", "VLAN"]
assert cable["properties"][1]["visible"] is False
async def test_defaults_cable_annotations_when_absent(
self, client: AsyncClient, headers
):
# A client that predates the feature sends neither key; the response must
# still carry usable values rather than nulls.
design_id = await _design(client, headers)
assert (
await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
).status_code == 200
cable = (
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
).json()["cables"][0]
assert cable["label_visible"] is False
assert cable["properties"] == []
async def test_drops_cable_properties_with_no_key(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["cables"][0]["properties"] = [
{"key": " ", "value": "orphan", "icon": None, "visible": True},
{"key": "Length", "value": "2 m", "icon": None, "visible": True},
]
assert (
await client.post("/api/v1/racks/save", json=payload, headers=headers)
).status_code == 200
cable = (
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
).json()["cables"][0]
assert [p["key"] for p in cable["properties"]] == ["Length"]
async def test_updates_cable_annotations_on_a_second_save(
self, client: AsyncClient, headers
):
design_id = await _design(client, headers)
payload = _state(design_id)
payload["cables"][0]["properties"] = [
{"key": "Length", "value": "2 m", "icon": None, "visible": True}
]
await client.post("/api/v1/racks/save", json=payload, headers=headers)
payload["cables"][0]["properties"] = []
payload["cables"][0]["label_visible"] = True
await client.post("/api/v1/racks/save", json=payload, headers=headers)
cable = (
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
).json()["cables"][0]
assert cable["properties"] == []
assert cable["label_visible"] is True
async def test_requires_auth(self, client: AsyncClient):
assert (await client.get("/api/v1/racks?design_id=any")).status_code == 401
class TestInventory:
async def test_lists_rackable_devices_only(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
await client.post(
"/api/v1/scan/pending",
json={"hostname": "pve-01", "suggested_type": "proxmox"},
headers=headers,
)
await client.post(
"/api/v1/scan/pending",
json={"hostname": "phone", "suggested_type": "mobile"},
headers=headers,
)
res = await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
labels = [i["label"] for i in res.json()["items"]]
assert "pve-01" in labels
assert "phone" not in labels
async def test_names_a_device_the_way_the_inventory_does(
self, client: AsyncClient, headers, db_session
):
"""A scan find with no hostname is named after the app it runs, not its IP.
The rack picker used to show "192.168.1.63 · 192.168.1.63" where the
Device Inventory showed "jellyfin".
"""
design_id = await _design(client, headers)
db_session.add(
InventoryDevice(
id="pd-services",
ip="192.168.1.63",
services=[
{"port": 443, "category": "web", "service_name": "https"},
{"port": 8096, "category": "media", "service_name": "jellyfin"},
],
suggested_type="server",
)
)
db_session.add(InventoryDevice(id="pd-bare", ip="192.168.1.64", suggested_type="server"))
# A portless service is no fingerprint: the inventory skips it, so must
# this — `None not in _COMMON_PORTS` is True and used to let it through.
db_session.add(
InventoryDevice(
id="pd-portless",
ip="192.168.1.65",
services=[{"port": None, "category": "media", "service_name": "jellyfin"}],
suggested_type="server",
)
)
await db_session.commit()
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
assert next(i for i in items if i["id"] == "pd-services")["label"] == "jellyfin"
# Nothing better to say: the IP stays the label, and the UI stops doubling it.
assert next(i for i in items if i["id"] == "pd-bare")["label"] == "192.168.1.64"
assert next(i for i in items if i["id"] == "pd-portless")["label"] == "192.168.1.65"
async def test_flags_devices_already_mounted(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
created = (
await client.post(
"/api/v1/scan/pending",
json={"hostname": "sw-24", "suggested_type": "switch"},
headers=headers,
)
).json()
payload = _state(design_id)
payload["devices"][0]["device_id"] = created["id"]
await client.post("/api/v1/racks/save", json=payload, headers=headers)
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
assert next(i for i in items if i["id"] == created["id"])["racked"] is True
async def test_resolves_the_canvas_node_for_live_status(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
node = (
await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "nas", "ip": "192.168.1.9", "status": "online"},
headers=headers,
)
).json()
await client.post(
"/api/v1/scan/pending",
json={"hostname": "nas", "ip": "192.168.1.9", "suggested_type": "nas"},
headers=headers,
)
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["label"] == "nas")
assert entry["node_id"] == node["id"]
assert entry["node_status"] == "online"
async def test_reports_what_the_canvas_node_knows(
self, client: AsyncClient, headers, db_session
):
"""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={
"type": "nas",
"label": "nas-truenas",
"design_id": network,
"ip": "192.168.1.9",
"mac": "aa:bb:cc:dd:ee:ff",
"hostname": "nas.lan",
"os": "TrueNAS SCALE",
"status": "online",
"check_method": "http",
},
headers=headers,
)
items = (
await client.get(f"/api/v1/racks/inventory?design_id={rack_design}", headers=headers)
).json()["items"]
entry = next(i for i in items if i["ip"] == "192.168.1.9")
assert entry["mac"] == "aa:bb:cc:dd:ee:ff"
assert entry["os"] == "TrueNAS"
assert entry["services"] == [{"port": 445, "name": "smb"}]
assert entry["node_label"] == "nas-truenas"
assert entry["node_type"] == "nas"
# 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"
async def test_stamps_last_seen_with_an_offset(self, client: AsyncClient, headers, db_session):
"""SQLite reads `last_seen` back naive, and a naive ISO string is *local*
time to `new Date()` — the panel printed it shifted by the viewer's offset.
"""
design_id = await _design(client, headers)
node = (
await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "seen", "ip": "192.168.1.77"},
headers=headers,
)
).json()
await db_session.execute(
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"] == 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):
"""A link the user picked in the rack beats the IEEE/IP guess.
The guess is what failed on gear with no MAC and no address, and it is
wrong — not merely absent — when two hosts have swapped IPs.
"""
design_id = await _design(client, headers)
picked = (
await client.post(
"/api/v1/nodes",
json={"type": "pdu", "label": "pdu-main", "status": "online"},
headers=headers,
)
).json()
guessed = (
await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "someone-else", "ip": "192.168.1.90"},
headers=headers,
)
).json()
device = (
await client.post(
"/api/v1/scan/pending",
json={"hostname": "pdu", "ip": "192.168.1.90", "suggested_type": "generic"},
headers=headers,
)
).json()
payload = _state(design_id)
payload["devices"][0]["device_id"] = device["id"]
payload["devices"][0]["node_id"] = picked["id"]
await client.post("/api/v1/racks/save", json=payload, headers=headers)
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"] == device["id"])
assert entry["node_id"] == picked["id"] != guessed["id"]
assert entry["node_label"] == "pdu-main"
assert entry["node_status"] == "online"
async def test_falls_back_when_the_named_node_is_gone(self, client: AsyncClient, headers):
"""A mount keeps its `node_id` in the payload the client last sent, so a
node deleted meanwhile must not report a node that no longer exists.
"""
design_id = await _design(client, headers)
guessed = (
await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "nas", "ip": "192.168.1.91"},
headers=headers,
)
).json()
device = (
await client.post(
"/api/v1/scan/pending",
json={"hostname": "nas", "ip": "192.168.1.91", "suggested_type": "nas"},
headers=headers,
)
).json()
payload = _state(design_id)
payload["devices"][0]["device_id"] = device["id"]
payload["devices"][0]["node_id"] = "node-that-was-deleted"
await client.post("/api/v1/racks/save", json=payload, headers=headers)
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"] == device["id"])
assert entry["node_id"] == guessed["id"]
async def test_leaves_the_node_fields_empty_without_a_match(
self, client: AsyncClient, headers
):
design_id = await _design(client, headers)
await client.post(
"/api/v1/scan/pending",
json={"hostname": "pdu-main", "suggested_type": "generic"},
headers=headers,
)
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["label"] == "pdu-main")
assert entry["node_id"] is None
assert entry["node_label"] is None
assert entry["node_design_name"] is None
assert entry["services"] == []
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={
"type": "server",
"label": "media",
"ip": "192.168.1.63",
"services": [{"port": 8096, "service_name": "jellyfin", "category": "media"}],
},
headers=headers,
)
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-noservices")
assert entry["services"] == [{"port": 8096, "name": "jellyfin"}]
async def test_manual_entry_is_tagged_as_such(self, client: AsyncClient, headers):
created = (
await client.post(
"/api/v1/scan/pending",
json={"hostname": "patch-panel", "suggested_type": "generic"},
headers=headers,
)
).json()
assert created["discovery_source"] == "manual"
assert created["status"] == "pending"
listed = (await client.get("/api/v1/scan/pending", headers=headers)).json()
assert any(d["id"] == created["id"] for d in listed)
class TestRackSourcedInventory:
"""Gear created from a rack canvas shares the Device Inventory, but never a
logical canvas: it documents a mount, not a host."""
async def _rack_device(self, client: AsyncClient, headers, hostname: str = "patch-house"):
return (
await client.post(
"/api/v1/scan/pending",
json={"hostname": hostname, "discovery_source": "rack"},
headers=headers,
)
).json()
async def test_records_the_rack_source(self, client: AsyncClient, headers):
created = await self._rack_device(client, headers)
assert created["discovery_source"] == "rack"
assert created["discovery_sources"] == ["rack"]
async def test_rejects_an_unknown_source(self, client: AsyncClient, headers):
res = await client.post(
"/api/v1/scan/pending",
json={"hostname": "spoof", "discovery_source": "zigbee"},
headers=headers,
)
assert res.status_code == 422
async def test_approve_refuses_rack_gear(self, client: AsyncClient, headers):
created = await self._rack_device(client, headers)
res = await client.post(
f"/api/v1/scan/pending/{created['id']}/approve",
json={"type": "generic", "label": "patch-house"},
headers=headers,
)
assert res.status_code == 409
assert "rack" in res.json()["detail"].lower()
async def test_bulk_approve_skips_rack_gear(self, client: AsyncClient, headers):
rack_device = await self._rack_device(client, headers)
scanned = (
await client.post(
"/api/v1/scan/pending",
json={"hostname": "nuc", "ip": "192.168.1.77", "suggested_type": "server"},
headers=headers,
)
).json()
res = await client.post(
"/api/v1/scan/pending/bulk-approve",
json={"device_ids": [rack_device["id"], scanned["id"]]},
headers=headers,
)
body = res.json()
assert body["approved"] == 1
assert body["device_ids"] == [scanned["id"]]
skipped = next(s for s in body["skipped_devices"] if s["device_id"] == rack_device["id"])
assert skipped["match"] == "rack"
async def test_rack_gear_is_offered_to_the_rack_inventory(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
created = await self._rack_device(client, headers, hostname="blank-panel")
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
assert any(i["id"] == created["id"] for i in items)
class TestDesignLifecycle:
async def test_deleting_a_design_removes_its_rack_rows(self, client: AsyncClient, headers):
keeper = await _design(client, headers, name="Keeper", design_type="network")
design_id = await _design(client, headers)
await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
assert (await client.delete(f"/api/v1/designs/{design_id}", headers=headers)).status_code == 204
assert (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).status_code == 404
# The other design is untouched.
assert (await client.get(f"/api/v1/racks?design_id={keeper}", headers=headers)).status_code == 200
async def test_unmounting_keeps_the_inventory_entry(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
created = (
await client.post(
"/api/v1/scan/pending",
json={"hostname": "sw-24", "suggested_type": "switch"},
headers=headers,
)
).json()
payload = _state(design_id)
payload["devices"][0]["device_id"] = created["id"]
await client.post("/api/v1/racks/save", json=payload, headers=headers)
# Unmount: the device disappears from the rack payload entirely.
unmounted = _state(design_id)
unmounted["devices"] = [d for d in unmounted["devices"] if d["id"] != "dev-sw"]
unmounted["cables"] = []
await client.post("/api/v1/racks/save", json=unmounted, headers=headers)
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"] == created["id"])
assert entry["racked"] is False
async def test_copying_a_rack_design_duplicates_its_racks(self, client: AsyncClient, headers):
design_id = await _design(client, headers)
await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
copy = (
await client.post(
f"/api/v1/designs/{design_id}/copy",
json={"name": "Rack Room (copy)", "icon": "server"},
headers=headers,
)
).json()
assert copy["design_type"] == "rack"
loaded = (await client.get(f"/api/v1/racks?design_id={copy['id']}", headers=headers)).json()
assert [r["name"] for r in loaded["racks"]] == ["Main"]
assert len(loaded["devices"]) == 2
assert len(loaded["cables"]) == 1
# Fresh ids, so editing the copy cannot touch the original.
assert loaded["racks"][0]["id"] != "rack-1"
assert {d["id"] for d in loaded["devices"]}.isdisjoint({"dev-sw", "dev-nas"})
# Cables still join the copied devices, not the originals.
copied_ids = {d["id"] for d in loaded["devices"]}
assert loaded["cables"][0]["from_device_id"] in copied_ids
assert loaded["cables"][0]["to_device_id"] in copied_ids
+87 -31
View File
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
from sqlalchemy.pool import StaticPool
from app.db.database import Base
from app.db.models import Node, PendingDevice, ScanRun
from app.db.models import InventoryDevice, Node, ScanRun
# ---------------------------------------------------------------------------
# Helpers
@@ -588,7 +588,7 @@ async def test_run_scan_adds_nmap_devices_as_pending(mem_db):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
result = await session.execute(sa_select(PendingDevice))
result = await session.execute(sa_select(InventoryDevice))
devices = result.scalars().all()
assert any(d.ip == "192.168.1.5" for d in devices)
@@ -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,15 +679,15 @@ 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
async def test_run_scan_mdns_only_device_added(mem_db):
"""Devices found only by mDNS (not nmap) should appear in pending_devices."""
"""Devices found only by mDNS (not nmap) should appear in device_inventory."""
from app.services.scanner import run_scan
run_id = _make_run_id()
@@ -691,7 +704,7 @@ async def test_run_scan_mdns_only_device_added(mem_db):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.80"))
result = await session.execute(sa_select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.80"))
device = result.scalar_one_or_none()
assert device is not None
@@ -709,7 +722,7 @@ async def test_run_scan_merges_proxmox_row_by_mac(mem_db):
async with mem_db() as session:
session.add(_make_scan_run(run_id))
# Previously imported from Proxmox: no IP, known NIC MAC, vm type.
session.add(PendingDevice(
session.add(InventoryDevice(
id="pve-row", ieee_address="pve-pve1-101", ip=None,
mac="bc:24:11:aa:bb:cc", suggested_type="vm", status="pending",
discovery_source="proxmox", discovery_sources=["proxmox"],
@@ -727,7 +740,7 @@ async def test_run_scan_merges_proxmox_row_by_mac(mem_db):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
rows = (await session.execute(sa_select(PendingDevice))).scalars().all()
rows = (await session.execute(sa_select(InventoryDevice))).scalars().all()
assert len(rows) == 1 # merged, not duplicated
row = rows[0]
@@ -737,6 +750,48 @@ async def test_run_scan_merges_proxmox_row_by_mac(mem_db):
assert set(row.discovery_sources) == {"proxmox", "arp"} # both filters
@pytest.mark.asyncio
async def test_run_scan_keeps_services_the_fingerprint_cannot_see(mem_db):
"""A re-scan unions its fingerprint onto the row — it never replaces it.
Since 3.3.0 the row is the only copy of a device's services and every canvas
drawing it reads that list, so overwriting it with what nmap happened to
match would delete hand-added services everywhere at once (#347).
"""
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
session.add(InventoryDevice(
id="row-1", ip="192.168.1.60", status="approved",
discovery_source="arp", discovery_sources=["arp"],
services=[
{"port": 9000, "protocol": "tcp", "service_name": "Portainer", "path": "/#!/home"},
{"port": 22, "protocol": "tcp", "service_name": "ssh"},
],
))
await session.commit()
nmap_hosts = [{"ip": "192.168.1.60", "hostname": "docker.lan", "mac": None, "os": None,
"open_ports": [{"port": 22, "protocol": "tcp", "banner": "OpenSSH 9.2"}]}]
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
row = await session.get(InventoryDevice, "row-1")
by_port = {s["port"]: s for s in row.services}
assert by_port[9000]["service_name"] == "Portainer" # hand-added, untouched
assert by_port[9000]["path"] == "/#!/home"
assert 22 in by_port # what the scan saw is still there
assert row.status == "approved"
@pytest.mark.asyncio
async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db):
"""If nmap and mDNS both find the same IP, it should not be double-counted."""
@@ -756,7 +811,7 @@ async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.10"))
result = await session.execute(sa_select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.10"))
devices = result.scalars().all()
assert len(devices) == 1 # not duplicated
@@ -771,11 +826,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": []}]
@@ -787,7 +843,7 @@ async def test_run_scan_keeps_canvas_nodes(mem_db):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.100"))
result = await session.execute(sa_select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.100"))
device = result.scalar_one_or_none()
assert device is not None
assert device.status == "pending"
@@ -801,7 +857,7 @@ async def test_run_scan_skips_hidden_devices(mem_db):
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
hidden = PendingDevice(ip="192.168.1.55", status="hidden")
hidden = InventoryDevice(ip="192.168.1.55", status="hidden")
session.add(hidden)
await session.commit()
@@ -815,7 +871,7 @@ async def test_run_scan_skips_hidden_devices(mem_db):
async with mem_db() as session:
result = await session.execute(
sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.55", PendingDevice.status == "pending")
sa_select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.55", InventoryDevice.status == "pending")
)
assert result.scalar_one_or_none() is None
@@ -943,7 +999,7 @@ async def test_run_scan_probe_enriches_services(mem_db):
)
async with mem_db() as session:
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.50"))
result = await session.execute(sa_select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50"))
device = result.scalar_one_or_none()
assert device is not None
+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"),
+18 -10
View File
@@ -9,7 +9,7 @@ from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.models import Node, PendingDevice, ScanRun
from app.db.models import InventoryDevice, Node, ScanRun
@pytest.fixture(autouse=True)
@@ -68,16 +68,24 @@ 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"),
PendingDevice(ip="10.0.0.1", status="pending"),
PendingDevice(ip="10.0.0.2", status="pending"),
PendingDevice(ip="10.0.0.3", status="hidden"), # excluded
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
ScanRun(status="success", finished_at=finished),
ScanRun(status="success",
finished_at=datetime(2026, 5, 13, 10, 0, tzinfo=timezone.utc)),
+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", []) == []
+78 -57
View File
@@ -338,7 +338,7 @@ async def test_persist_pending_import_coordinator_goes_to_pending(
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import Node, PendingDevice
from app.db.models import InventoryDevice, Node
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
assert result.device_count == 3
@@ -354,7 +354,7 @@ async def test_persist_pending_import_coordinator_goes_to_pending(
# Coordinator sits in the pending inventory.
coord = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xCOORD")
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xCOORD")
)
).scalar_one()
assert coord.status == "pending"
@@ -385,14 +385,14 @@ async def test_persist_pending_import_replaces_links(db_session) -> None:
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import PendingDeviceLink
from app.db.models import InventoryDeviceLink
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
new_edges = [{"source": "0xCOORD", "target": "0xR1"}]
await _persist_pending_import(db_session, _PENDING_NODES[:2], new_edges)
rows = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
rows = (await db_session.execute(select(InventoryDeviceLink))).scalars().all()
assert len(rows) == 1
assert (rows[0].source_ieee, rows[0].target_ieee) == ("0xCOORD", "0xR1")
@@ -403,7 +403,7 @@ async def test_persist_pending_import_sets_coordinator_pending_fields(db_session
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import PendingDevice
from app.db.models import InventoryDevice
nodes_with_meta = [dict(n) for n in _PENDING_NODES]
nodes_with_meta[0]["vendor"] = "TI"
@@ -413,7 +413,7 @@ async def test_persist_pending_import_sets_coordinator_pending_fields(db_session
coord = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xCOORD")
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xCOORD")
)
).scalar_one()
assert coord.vendor == "TI"
@@ -432,19 +432,22 @@ async def test_persist_pending_import_backfills_inventory_for_approved_node(
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import Node, PendingDevice
from app.db.models import InventoryDevice, Node
# Simulate: router was approved earlier → exists as a canvas Node, but with
# no matching pending_devices row (e.g. a legacy auto-placed device).
approved = Node(
label="router_1",
type="zigbee_router",
status="online",
check_method="none",
# no matching device_inventory row (e.g. a legacy auto-placed device).
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()
@@ -455,7 +458,7 @@ async def test_persist_pending_import_backfills_inventory_for_approved_node(
# An inventory row is backfilled as "approved" (it is on a canvas).
inv = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xR1")
)
).scalar_one()
assert inv.status == "approved"
@@ -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"}
@@ -481,23 +486,24 @@ async def test_persist_pending_import_preserves_hidden_inventory_for_approved_no
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import Node, PendingDevice
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(PendingDevice(
# 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)
inv = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xR1")
)
).scalar_one()
assert inv.status == "hidden" # stays hidden
@@ -509,7 +515,7 @@ async def test_persist_pending_import_revives_orphaned_approved_device(
) -> None:
"""Regression for #167: approve → delete node → re-import must re-list device.
When a device was approved (PendingDevice.status="approved") and its canvas
When a device was approved (InventoryDevice.status="approved") and its canvas
Node was later deleted, the orphaned "approved" row must be reset to
"pending" on re-import so it shows up in the Pending list again — instead of
being silently swallowed (re-import reports "found" but Pending stays empty).
@@ -517,11 +523,11 @@ async def test_persist_pending_import_revives_orphaned_approved_device(
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import PendingDevice
from app.db.models import InventoryDevice
# Simulate prior approve: a PendingDevice marked approved, but NO matching
# Simulate prior approve: a InventoryDevice marked approved, but NO matching
# Node exists (the user deleted the canvas node afterwards).
orphan = PendingDevice(
orphan = InventoryDevice(
ieee_address="0xR1",
friendly_name="router_1",
hostname="router_1",
@@ -541,7 +547,7 @@ async def test_persist_pending_import_revives_orphaned_approved_device(
# No new row created for 0xR1 — the existing one was updated/revived.
revived = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xR1")
)
).scalar_one()
assert revived.status == "pending"
@@ -552,7 +558,7 @@ async def test_persist_pending_import_revives_orphaned_approved_device(
# It is now visible to the Pending list (status filter == "pending").
listed = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.status == "pending")
select(InventoryDevice).where(InventoryDevice.status == "pending")
)
).scalars().all()
assert {p.ieee_address for p in listed} == {"0xCOORD", "0xR1", "0xE1"}
@@ -566,9 +572,9 @@ async def test_persist_pending_import_keeps_hidden_hidden_on_reimport(
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import PendingDevice
from app.db.models import InventoryDevice
hidden = PendingDevice(
hidden = InventoryDevice(
ieee_address="0xR1",
friendly_name="router_1",
suggested_type="zigbee_router",
@@ -583,7 +589,7 @@ async def test_persist_pending_import_keeps_hidden_hidden_on_reimport(
still_hidden = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xR1")
)
).scalar_one()
assert still_hidden.status == "hidden"
@@ -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).
@@ -644,13 +655,17 @@ async def test_persist_pending_import_refreshes_approved_coordinator_node(
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import Node, PendingDevice
from app.db.models import InventoryDevice, Node
# Legacy: coordinator auto-placed as a canvas Node, no pending_devices row.
db_session.add(Node(
label="Coordinator", type="zigbee_coordinator", status="online",
check_method="none", ieee_address="0xCOORD", services=[], properties=[],
))
# Legacy: coordinator auto-placed as a canvas Node, no device_inventory row.
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"
@@ -669,7 +686,7 @@ async def test_persist_pending_import_refreshes_approved_coordinator_node(
# Inventory row backfilled as approved → now visible in the inventory list.
inv = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xCOORD")
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xCOORD")
)
).scalar_one()
assert inv.status == "approved"
@@ -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
+36 -32
View File
@@ -294,7 +294,7 @@ async def test_persist_coordinator_goes_to_pending(db_session) -> None:
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import Node, PendingDevice
from app.db.models import InventoryDevice, Node
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
assert result.device_count == 3
@@ -308,7 +308,7 @@ async def test_persist_coordinator_goes_to_pending(db_session) -> None:
assert nodes == []
coord = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-1")
select(InventoryDevice).where(InventoryDevice.ieee_address == "zwave-0xh-1")
)
).scalar_one()
assert coord.status == "pending"
@@ -333,12 +333,12 @@ async def test_persist_replaces_links(db_session) -> None:
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import PendingDeviceLink
from app.db.models import InventoryDeviceLink
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
new_edges = [{"source": "zwave-0xh-1", "target": "zwave-0xh-2"}]
await _persist_pending_import(db_session, _PENDING_NODES[:2], new_edges)
rows = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
rows = (await db_session.execute(select(InventoryDeviceLink))).scalars().all()
assert len(rows) == 1
assert (rows[0].source_ieee, rows[0].target_ieee) == ("zwave-0xh-1", "zwave-0xh-2")
@@ -349,7 +349,7 @@ async def test_persist_sets_coordinator_pending_fields(db_session) -> None:
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import PendingDevice
from app.db.models import InventoryDevice
nodes = [dict(n) for n in _PENDING_NODES]
nodes[0]["vendor"] = "Aeotec"
@@ -357,7 +357,7 @@ async def test_persist_sets_coordinator_pending_fields(db_session) -> None:
await _persist_pending_import(db_session, nodes, _PENDING_EDGES)
coord = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-1")
select(InventoryDevice).where(InventoryDevice.ieee_address == "zwave-0xh-1")
)
).scalar_one()
assert coord.vendor == "Aeotec"
@@ -372,31 +372,31 @@ async def test_persist_backfills_inventory_for_approved_node(db_session) -> None
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import Node, PendingDevice
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)
inv = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
select(InventoryDevice).where(InventoryDevice.ieee_address == "zwave-0xh-2")
)
).scalar_one()
assert inv.status == "approved"
assert inv.suggested_type == "zwave_router"
refreshed = (
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-2"))
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"}
@@ -407,9 +407,9 @@ async def test_persist_revives_orphaned_approved_device(db_session) -> None:
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import PendingDevice
from app.db.models import InventoryDevice
orphan = PendingDevice(
orphan = InventoryDevice(
ieee_address="zwave-0xh-2",
friendly_name="Wall Plug",
suggested_type="zwave_router",
@@ -423,7 +423,7 @@ async def test_persist_revives_orphaned_approved_device(db_session) -> None:
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
revived = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
select(InventoryDevice).where(InventoryDevice.ieee_address == "zwave-0xh-2")
)
).scalar_one()
assert revived.status == "pending"
@@ -437,9 +437,9 @@ async def test_persist_keeps_hidden_hidden(db_session) -> None:
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import PendingDevice
from app.db.models import InventoryDevice
hidden = PendingDevice(
hidden = InventoryDevice(
ieee_address="zwave-0xh-2",
friendly_name="Wall Plug",
suggested_type="zwave_router",
@@ -453,7 +453,7 @@ async def test_persist_keeps_hidden_hidden(db_session) -> None:
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
still_hidden = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
select(InventoryDevice).where(InventoryDevice.ieee_address == "zwave-0xh-2")
)
).scalar_one()
assert still_hidden.status == "hidden"
@@ -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"
# ---------------------------------------------------------------------------
+247
View File
@@ -0,0 +1,247 @@
# Rack Canvas
A rack canvas is the **physical** counterpart to the network diagram: real racks,
the gear mounted in them, and the patch cables between their ports. It is a
canvas kind of its own — you create it like any other canvas, and Homelable swaps
the renderer when you switch to it.
Where the diagram answers *what talks to what*, the rack answers *what is where*:
which U a machine sits in, what plate it wears, which switch port its uplink
lands on.
> Works with **and** without the backend. In the no-backend standalone/demo build
> a rack canvas persists to your browser's local storage; the Device Inventory
> picker and the live status check are backend-only (marked 🔒 below).
---
## Feature Overview
- **Racks** — any U height, 19" or 10" wide, bottom-up or top-down numbering, per-rack frame / rail / interior colours, open or enclosed.
- **Mounted gear** — a device occupies a U range and part of a 12-column width grid, so half- and third-width machines share a U.
- **Faceplates** — a visual catalog of plates (servers, switches, routers, patch panels, UPS, PDUs, desktop NAS towers, shelves, blanks, cable managers) drawn as vector artwork that scales with the rack.
- **Ports & patching** — RJ45 and SFP ports per device, cabled port-to-port by dragging from one to the other, across racks if needed.
- **Device Inventory** 🔒 — a mount points at a real inventory entry, so a rack shows the same devices your scans found, and gear you rack by hand joins that inventory.
- **Live status** 🔒 — a mount can follow the status check already configured on the matching diagram node, and light its plate LED accordingly.
- **Import links** 🔒 — derive patches from the physical links already drawn on your diagrams, as often as you like.
---
## Create a rack canvas
1. Open the **canvas switcher** at the top of the left sidebar.
2. Click **New Canvas**.
3. Under **Kind**, pick **Rack** (the other kind, *Diagram*, is the usual node/link canvas).
4. Name it, pick an icon, and create.
A canvas cannot change kind afterwards — a diagram and a rack store different
things. Deleting a rack canvas deletes its racks, mounts and cables with it;
duplicating a canvas duplicates them too.
---
## Add a rack
**Add Rack** in the header drops a new rack on the canvas. Drag it anywhere; a
canvas can hold as many as you like, and cables can run between them.
**Double-click the rack chrome** (the frame, not a device) to open its settings:
| Setting | What it does |
|---|---|
| Name / Location | Free text — "Baie salon", "Garage", … |
| U height | How many mountable U the rack has (42 by default). |
| Width | 19" or 10" — drives the drawn inner width. |
| Numbering | `bottom-up` or `top-down`. **Labels only** — gear never moves. |
| Show U numbers | Print the U scale on the rails. |
| Enclosed | Draw side panels / a closed chassis. |
| Colours | Frame, rails and interior. Defaults come from your app theme. |
| Delete | Removes the rack and everything mounted in it. |
---
## Mount a device
Click **+ Device** in the left sidebar, or drag an accessory from the sidebar
tray straight onto a rack. The device modal offers three sources:
- **Device Inventory entry** 🔒 — pick something already discovered. The field
opens the real Device Inventory, with its search, source and type filters, and
it opens pre-filtered to **rackable** hardware (no light bulbs, no sockets).
A device already mounted on this canvas is refused.
- **New device** — for hardware no scan can find (a patch panel, a PDU, an
unmanaged switch). It is created as a normal Device Inventory entry tagged
**Rack devices**, so you can search, filter, hide and delete it like any other.
- **Accessory** — rack-only furniture: blanking plate, shelf, cable manager.
These are not devices and never appear in the inventory.
Then set:
| Field | Notes |
|---|---|
| Label | Shown on the plate. |
| Faceplate | Opens the visual catalog — see below. |
| U position / height | `U` is counted **from the bottom rail**, whatever the numbering setting prints. |
| Column / width | Full, half, third, sixth or quarter of the rack width. |
| Status | A fixed colour, or **Check device** 🔒 — see below. |
| Colour | Overrides the plate's default tint. |
| Ports | The list of RJ45 / SFP ports, seeded by the faceplate and editable afterwards. |
**Double-click a plate** to reopen this modal on an existing mount; a single
click only selects it. **Unmount** takes the device out of the rack — it stays in
the Device Inventory.
### Linked device 🔒
Under the port list, a mount that stands for a real device prints what is known
about that box: name, type, hostname, IP, MAC, OS, the status check it runs, the
canvas it is drawn on, when it was last seen, and the services discovery
fingerprinted on it.
It is read-only — edit those facts in the Device Inventory or on the logical
canvas, not in the rack — and an accessory shows nothing at all. A device that is
on no diagram still prints everything discovery found about it; only the
canvas-side lines go missing, and the panel says *Not on a logical canvas.*
**Link to another device…** points the plate at a different Device Inventory
entry — the whole inventory, not just the devices you approved onto a diagram.
Use it when you created the gear from the rack (the entry starts empty, with just
a name) or when the plate ended up on the wrong twin of two look-alike hosts. The
plate takes the entry's IP, MAC, services, status and — unless you renamed it —
its name, **Check device** becomes available if that device is on a diagram, and
the link is saved with the rest of the canvas. A device already mounted elsewhere
in the design is not offered: one entry, one plate. The empty placeholder the
rack created for the plate is removed from the inventory once nothing uses it.
### Placement rules
- A drop **snaps to the nearest free slot**; a placement that cannot work
previews in red.
- Drag a mounted device inside its rack to move it — same snapping.
- **Growing** a device, by hand or by picking a taller plate, relocates it to the
nearest slot that takes the new size. Only a rack with no such slot refuses the
edit, and it tells you so.
---
## Faceplates
The **Faceplate** field opens a catalog, not a dropdown of names: every plate is
drawn with the real renderer, at its real relative width and U height, grouped by
family and searchable.
| Family | Examples |
|---|---|
| Servers | 1U, 1U with drive bays, 2U with bays, 4U storage, half-width SFF, third-width mini PC |
| Network | 8 / 24 / 48-port switches, 1U router |
| Patch | 24-port copper panel, 12-port fibre panel |
| Storage | 2U NAS, desktop NAS towers in 2, 4 and 5 bays |
| Power | 2U UPS, 1U PDU |
| Accessories | Blanking plate, shelf, cable manager |
Picking a plate **reseeds the ports** it comes with; your own port edits are kept
until you change plate again. Plates are drawn as vector artwork in relative
coordinates, so they stay correct at any rack width and U height, and their
colours follow the active app theme.
---
## Ports and patching
Ports are **RJ45** or **SFP/SFP+**, drawn as real jack artwork at a fixed size so
plates of different heights line up. Power outlets are artwork only — they are
never a cable endpoint.
Patch-facing gear (switches, patch panels) shows its ports permanently;
everything else reveals them on hover, on selection, or when cables are shown.
To cable:
1. Click **Patch** in the header.
2. **Drag from one port to another** — a dashed rubber band follows the pointer.
Clicking the two ports in turn works just as well. **Escape** drops a
half-drawn patch.
3. Repeat. One cable per port; copper or fibre follows the port you start from.
4. Click **Exit patching** when done — the canvas returns to the cable visibility
it had before.
To unplug: **click the cable** (it gets an accent halo), then press
**Delete/Backspace** or click **Unplug** in the header. A stray click never
destroys a patch.
A header select controls what you see: cables **on hover**, **always**, or
**hidden**. Cables
pan and zoom with the canvas and may run from one rack to another. A cable you
have selected stays drawn whatever that select says.
### Documenting a cable
**Click a cable** — patching or not — and a panel opens on the right:
| Field | What it does |
|---|---|
| Endpoints | The two devices and ports the run connects. Read-only: move a patch by unplugging it and drawing it again. |
| Type | Ethernet or fibre. A cable still wearing its type's default colour is recoloured to match; a colour you picked yourself is kept. |
| Colour | The sheath colours you actually have on the shelf, plus a free hex field for anything else. |
| Label | A name for the run — "Uplink to core", a patch-panel reference. **Show on canvas** prints it next to the cable. |
| Properties | Anything else worth recording: length, VLAN, speed, category, patch reference. Same editor as a node's properties on a diagram — a label, a value, an optional icon, and an eye that decides whether it shows on the canvas. |
| Unplug cable | Removes the patch. The devices and their ports stay. |
What you tick as visible is drawn on a small plate at the middle of the run, so a
rack photo exports with its cable lengths and VLANs on it. Everything else stays
in the panel, one click away.
### Import links 🔒
**Import links** derives patches from the physical links (ethernet, fibre, vlan,
cluster) already drawn on your diagrams, matching both ends to mounts that point
at those nodes. Run it as often as you like: a pair of devices already cabled
is left alone, so a second run after racking more gear only adds what is
missing.
---
## Status 🔒
A mount's **Status** is either pinned by hand (online / offline / unknown) or set
to **Check device**: the plate then follows the status check already configured
on the matching diagram node (ping, HTTP, SSH, …) and its LED lights accordingly.
The rack runs no checker of its own — it reads the result of the one your diagram
already performs. So:
- **Check device** is only offered when the mount resolves to a diagram node.
- Losing that link drops the mount back to `unknown`, rather than leaving it on a
status nothing answers for.
- The refresh polls every 60 s, and only while at least one mount is on
**Check device**.
---
## Saving
Like the diagram canvas, a rack canvas is **saved explicitly** — **Save Rack** in
the left sidebar, or the header's save button. Nothing is written behind your
back unless you turn on **Autosave** in Settings.
The sidebar footer counts racks, mounts, cables and free U instead of the
diagram's online/offline tally. **PNG export** works here too.
---
## Known limits
- **Front view only** — no rear view, no half-depth pairing.
- No 0U side-mounted PDU.
- No power draw or outlet budgeting.
- No undo/redo on a rack canvas (the diagram canvas keeps its own).
- Rack canvases are not shown by the read-only [Live View](../README.md#live-view-read-only-public-canvas).
- The MCP server exposes diagram nodes and links only, not racks.
---
## See also
- [FEATURES.md](../FEATURES.md) — every feature, one page.
- [Device Inventory](../FEATURES.md#12-device-inventory-) — where racked devices live.
- `frontend/src/rack/README.md` — the developer reference for this canvas.
+12 -12
View File
@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "3.1.2",
"version": "3.3.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "3.1.2",
"version": "3.3.4",
"dependencies": {
"@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4",
@@ -21,7 +21,7 @@
"clsx": "^2.1.1",
"dagre": "^0.8.5",
"html-to-image": "^1.11.13",
"js-yaml": "^4.1.1",
"js-yaml": "^4.3.1",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -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": {
@@ -6884,9 +6884,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"funding": [
{
"type": "github",
@@ -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": {
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "3.1.2",
"version": "3.3.4",
"type": "module",
"scripts": {
"dev": "vite",
@@ -27,7 +27,7 @@
"clsx": "^2.1.1",
"dagre": "^0.8.5",
"html-to-image": "^1.11.13",
"js-yaml": "^4.1.1",
"js-yaml": "^4.3.1",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+135 -49
View File
@@ -32,7 +32,7 @@ import { TextModal, type TextFormData } from '@/components/modals/TextModal'
import { ThemeModal } from '@/components/modals/ThemeModal'
import { CustomStyleModal } from '@/components/modals/CustomStyleModal'
import { SearchModal } from '@/components/modals/SearchModal'
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
import { DeviceInventoryModal } from '@/components/modals/DeviceInventoryModal'
import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal'
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
import { ConfirmAddToGroupModal } from '@/components/modals/ConfirmAddToGroupModal'
@@ -49,10 +49,13 @@ import { decideCanvasLoad, isNewUserCanvas } from '@/utils/canvasLoadDecision'
import { WalkthroughActionsProvider, type WalkthroughActionApi } from '@/walkthrough/actions'
import { WalkthroughInvite } from '@/walkthrough/WalkthroughInvite'
import { WalkthroughOverlay } from '@/walkthrough/WalkthroughOverlay'
import { DEMO_SCAN_RUNS, DEMO_PENDING_DEVICES } from '@/walkthrough/demoTourData'
import { DEMO_SCAN_RUNS, DEMO_INVENTORY_DEVICES } from '@/walkthrough/demoTourData'
import { useStatusPolling } from '@/hooks/useStatusPolling'
import { bootstrapAuth } from '@/auth/bootstrap'
import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig, NodeType } from '@/types'
import { RackCanvas } from '@/rack/components/RackCanvas'
import { RackCablePanel } from '@/rack/components/RackCablePanel'
import { useRackStore } from '@/rack/store'
import type { NodeData, EdgeData, CustomStyleDef, DesignType, FloorMapConfig, NodeType } from '@/types'
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types'
import type { ProxmoxNode, ProxmoxEdge } from '@/components/proxmox/types'
@@ -61,12 +64,22 @@ 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)
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
const { activeDesignId, setDesigns, setActiveDesign } = useDesignStore()
const { activeDesignId, activeDesignType, setDesigns, setActiveDesign } = useDesignStore()
const isRackDesign = activeDesignType === 'rack'
const rackDirty = useRackStore((s) => s.hasUnsavedChanges)
const rackEditSeq = useRackStore((s) => s.editSeq)
/** Kind of a design by id, read straight from the store (no stale closure). */
const designTypeOf = useCallback(
(id: string | null | undefined): DesignType =>
useDesignStore.getState().designs.find((d) => d.id === id)?.design_type ?? 'network',
[],
)
useStatusPolling()
@@ -103,14 +116,14 @@ export default function App() {
const [styleEditorType, setStyleEditorType] = useState<NodeType | null>(null)
const [searchOpen, setSearchOpen] = useState(false)
const [scanHistoryOpen, setScanHistoryOpen] = useState(false)
const [pendingModalOpen, setPendingModalOpen] = useState(false)
const [pendingModalStatus, setPendingModalStatus] = useState<'pending' | 'hidden'>('pending')
const [pendingHighlightId, setPendingHighlightId] = useState<string | undefined>(undefined)
const openPendingModal = useCallback((deviceId?: string, status: 'pending' | 'hidden' = 'pending') => {
setPendingHighlightId(undefined)
setPendingModalStatus(status)
setPendingModalOpen(true)
if (deviceId) setTimeout(() => setPendingHighlightId(deviceId), 0)
const [inventoryModalOpen, setInventoryModalOpen] = useState(false)
const [inventoryModalStatus, setInventoryModalStatus] = useState<'pending' | 'hidden'>('pending')
const [inventoryHighlightId, setInventoryHighlightId] = useState<string | undefined>(undefined)
const openInventoryModal = useCallback((deviceId?: string, status: 'pending' | 'hidden' = 'pending') => {
setInventoryHighlightId(undefined)
setInventoryModalStatus(status)
setInventoryModalOpen(true)
if (deviceId) setTimeout(() => setInventoryHighlightId(deviceId), 0)
}, [])
const [shortcutsOpen, setShortcutsOpen] = useState(false)
const [addNodeOpen, setAddNodeOpen] = useState(false)
@@ -120,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)
@@ -134,6 +148,18 @@ export default function App() {
const handleSave = useCallback(async (designIdOverride?: string, options?: { silent?: boolean }): Promise<boolean> => {
try {
const saveDesignId = designIdOverride ?? activeDesignId
// Rack canvases own their own state and persistence path.
if (designTypeOf(saveDesignId) === 'rack') {
// Named explicitly: the design-switch flow saves the *old* design, and
// the store refuses rather than writing under whichever one it holds.
const ok = await useRackStore.getState().save(saveDesignId)
if (ok) {
if (!options?.silent) toast.success('Rack canvas saved')
} else {
toast.error('Save failed')
}
return ok
}
if (STANDALONE) {
if (!saveDesignId) return false
// Floor plans are backend-only (upload/serve), so standalone never persists one.
@@ -142,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
@@ -154,7 +183,7 @@ export default function App() {
toast.error('Save failed')
return false
}
}, [nodes, edges, markSaved, activeTheme, customStyle, activeDesignId, floorMap])
}, [nodes, edges, markSaved, activeTheme, customStyle, activeDesignId, floorMap, designTypeOf])
// Keep a ref so the keydown handler always calls the latest version
const handleSaveRef = useRef(handleSave)
@@ -165,12 +194,12 @@ export default function App() {
useAutosave({
enabled: autosave.enabled,
delaySeconds: autosave.delay,
hasUnsavedChanges,
hasUnsavedChanges: isRackDesign ? rackDirty : hasUnsavedChanges,
designId: loadedDesignId,
// Debounce resets on each real user edit (editSeq), not on raw nodes/edges
// identity — live status polling churns those arrays without a user edit and
// would otherwise keep re-arming (and starving) the timer during monitoring.
changeSignals: [editSeq],
changeSignals: [isRackDesign ? rackEditSeq : editSeq],
getLiveDesignId: () => loadedDesignIdRef.current,
onSave: (designId) => { void handleSaveRef.current(designId, { silent: true }) },
})
@@ -196,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
@@ -257,6 +289,24 @@ export default function App() {
setLoadedDesignId(designId)
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
/**
* Load whichever canvas the design holds. Rack designs bypass the node/edge
* canvas entirely — different store, different endpoints.
*/
const loadAnyDesign = useCallback(async (designId: string) => {
if (designTypeOf(designId) !== 'rack') {
if (STANDALONE) loadStandaloneCanvas(designId)
else await loadCanvasFromApi(designId)
return
}
// The rack store owns the fetch; App only records provenance afterwards, so
// none of these run synchronously inside the calling effect.
await useRackStore.getState().loadDesign(designId)
setIsNewUser(false)
setLoadError(false)
setLoadedDesignId(designId)
}, [designTypeOf, loadStandaloneCanvas, loadCanvasFromApi])
const loadDesignsAndCanvas = useCallback(async () => {
// Prefer a design id explicitly requested via the URL (?design=<id>), so a
// refresh or shared link opens that design. Ignore it when it doesn't match
@@ -269,7 +319,7 @@ export default function App() {
const targetId = fromUrl ?? activeDesignId ?? designs[0]?.id
if (targetId) {
setActiveDesign(targetId)
loadStandaloneCanvas(targetId)
void loadAnyDesign(targetId)
}
return
}
@@ -281,7 +331,7 @@ export default function App() {
const targetId = fromUrl ?? activeDesignId ?? loadedDesigns[0]?.id
if (targetId) {
setActiveDesign(targetId)
await loadCanvasFromApi(targetId)
await loadAnyDesign(targetId)
}
} catch {
// Backend unreachable/errored — surface it. Do NOT seed the demo: that would
@@ -289,7 +339,7 @@ export default function App() {
setLoadError(true)
toast.error('Could not reach backend — check the server and retry')
}
}, [setDesigns, setActiveDesign, loadCanvasFromApi, loadStandaloneCanvas, activeDesignId])
}, [setDesigns, setActiveDesign, loadAnyDesign, activeDesignId])
// Keep a ref so the auth effect can call the latest loader without listing it
// as a dependency (which would re-fire on every design switch).
@@ -309,7 +359,7 @@ export default function App() {
setScanConfigOpen(false)
setScanHistoryOpen(false)
setTourScanHistoryDemo(false)
setPendingModalOpen(false)
setInventoryModalOpen(false)
setTourInventoryDemo(false)
setEditNodeId(null)
setThemeModalOpen(false)
@@ -328,7 +378,7 @@ export default function App() {
},
openInventoryDemo: () => {
setTourInventoryDemo(true)
openPendingModal(undefined, 'pending')
openInventoryModal(undefined, 'pending')
},
editFirstNode: () => {
const first = useCanvasStore.getState().nodes.find((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
@@ -349,7 +399,7 @@ export default function App() {
},
openStyle: () => setThemeModalOpen(true),
openZigbeeImport: () => setZigbeeImportOpen(true),
}), [openPendingModal])
}), [openInventoryModal])
// Load designs + canvas on auth (or immediately in standalone mode, which has
// no auth gate).
@@ -376,7 +426,7 @@ export default function App() {
}
// Standalone has no auth gate; backed mode requires authentication.
const ready = STANDALONE || isAuthenticated
const loadForDesign = STANDALONE ? loadStandaloneCanvas : loadCanvasFromApi
const loadForDesign = loadAnyDesign
if (ready && activeDesignId && initialLoadDone.current) {
const oldId = prevDesignRef.current
// If the previous design was deleted (no longer in the list), don't try to
@@ -390,7 +440,7 @@ export default function App() {
const targetId = activeDesignId
handleSave(oldId).then((ok) => {
if (ok) {
loadForDesign(targetId)
void loadForDesign(targetId)
} else {
// Save failed: don't load the new design — that would overwrite the
// unsaved in-memory canvas. Revert the selection back to the old
@@ -401,7 +451,12 @@ export default function App() {
}
})
} else {
loadForDesign(activeDesignId)
// Loading a design is exactly the "synchronize with an external system"
// case: it fetches and then publishes the result into React state. The
// rule can't see through the loader, so silence it here rather than
// deferring the fetch by a tick.
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadForDesign(activeDesignId)
}
}
if (activeDesignId) {
@@ -450,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.
@@ -957,7 +1014,7 @@ export default function App() {
onSave={handleSave}
onOpenSettings={() => setSettingsOpen(true)}
onOpenHistory={() => setScanHistoryOpen(true)}
onOpenPending={openPendingModal}
onOpenInventory={openInventoryModal}
/>
<div className="flex flex-col flex-1 min-w-0">
{loadError && (
@@ -990,17 +1047,34 @@ export default function App() {
/>
<div className="flex flex-1 min-h-0">
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
<CanvasContainer
onConnect={handleEdgeConnect}
onEdgeDoubleClick={handleEdgeDoubleClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeDragStart={snapshotHistory}
onRequestAddToGroup={setPendingGroupAdd}
onRequestAddToContainer={setPendingContainerAdd}
onOpenPending={(deviceId) => openPendingModal(deviceId)}
/>
{isRackDesign ? (
<RackCanvas />
) : (
<CanvasContainer
onConnect={handleEdgeConnect}
onEdgeDoubleClick={handleEdgeDoubleClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeDragStart={snapshotHistory}
onRequestAddToGroup={setPendingGroupAdd}
onRequestAddToContainer={setPendingContainerAdd}
onRequestAddToZone={setPendingZoneAdd}
onOpenInventory={(deviceId) => openInventoryModal(deviceId)}
/>
)}
</div>
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
{/* Rack designs keep the full width for mounted gear — a mount is
edited in its own modal — but a selected cable has no plate to
double-click, so it gets the rail. */}
{isRackDesign
? <RackCablePanel />
: (selectedNodeId || selectedNodeIds.length > 1) && (
<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>
</div>
@@ -1078,7 +1152,7 @@ export default function App() {
open={zigbeeImportOpen}
onClose={() => setZigbeeImportOpen(false)}
onAddToCanvas={handleZigbeeAddToCanvas}
onPendingImported={() => {
onInventoryImported={() => {
toast.success('Zigbee import started — check Scan History for results')
}}
/>
@@ -1089,7 +1163,7 @@ export default function App() {
open={zwaveImportOpen}
onClose={() => setZwaveImportOpen(false)}
onAddToCanvas={handleZwaveAddToCanvas}
onPendingImported={() => {
onInventoryImported={() => {
toast.success('Z-Wave import started — check Scan History for results')
}}
/>
@@ -1100,7 +1174,7 @@ export default function App() {
open={proxmoxImportOpen}
onClose={() => setProxmoxImportOpen(false)}
onAddToCanvas={handleProxmoxAddToCanvas}
onPendingImported={() => {
onInventoryImported={() => {
toast.success('Proxmox import started — check Scan History for results')
}}
/>
@@ -1199,7 +1273,7 @@ export default function App() {
<SearchModal
open={searchOpen}
onClose={() => setSearchOpen(false)}
onOpenPending={(deviceId) => openPendingModal(deviceId)}
onOpenInventory={(deviceId) => openInventoryModal(deviceId)}
/>
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
@@ -1226,16 +1300,28 @@ 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)} />
<PendingDevicesModal
open={pendingModalOpen}
onClose={() => setPendingModalOpen(false)}
highlightId={pendingHighlightId}
initialStatus={pendingModalStatus}
demoDevices={tourInventoryDemo ? DEMO_PENDING_DEVICES : undefined}
<DeviceInventoryModal
open={inventoryModalOpen}
onClose={() => setInventoryModalOpen(false)}
highlightId={inventoryHighlightId}
initialStatus={inventoryModalStatus}
demoDevices={tourInventoryDemo ? DEMO_INVENTORY_DEVICES : undefined}
/>
<ExportModal
+62
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',
@@ -61,7 +62,29 @@ export const mediaApi = {
delete: (filename: string) => api.delete(`/media/${filename}`),
}
/**
* A canvas node as `GET /nodes` reports it — the fields a reader needs to tell
* two hosts apart. The canvas itself keeps its own richer React Flow shape; this
* is only for pickers and lookups.
*/
export interface ApiCanvasNode {
id: string
design_id: string | null
label: string
type: string
ip: string | null
mac: string | null
hostname: string | null
os: string | null
status: string
check_method: string | null
last_seen: string | null
}
export const nodesApi = {
/** Every node, across every design. `label` filters case-insensitively. */
list: (label?: string) =>
api.get<ApiCanvasNode[]>('/nodes', { params: label ? { label } : {} }),
create: (data: object) => api.post('/nodes', data),
update: (id: string, data: object) => api.patch(`/nodes/${id}`, data),
delete: (id: string) => api.delete(`/nodes/${id}`),
@@ -106,12 +129,40 @@ export interface DuplicateNodeConflict {
value: string
}
/**
* The Device Inventory rides the scan routes.
*
* `/scan/pending/*` is what the table was called when it was a queue of finds
* awaiting approval; the rows are the Device Inventory now, but the paths are a
* published contract the MCP server also calls, so they stay. The method names
* follow the paths rather than the concept, so a route is easy to find.
*/
export const scanApi = {
trigger: (deepScan?: Partial<DeepScanConfig>) => api.post('/scan/trigger', deepScan ?? {}),
pending: () => api.get('/scan/pending'),
/** Add an inventory entry by hand, for hardware no scan can discover. */
createPending: (data: {
hostname: string
ip?: string | null
mac?: string | null
suggested_type?: string | null
model?: string | null
vendor?: string | null
/** "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'),
/** Remove one inventory entry. 409 when a rack still mounts it. */
deletePending: (id: string) => api.delete<{ deleted: boolean }>(`/scan/pending/${id}`),
approve: (id: string, nodeData: object) =>
api.post<{
approved: boolean
@@ -220,6 +271,17 @@ export const designsApi = {
delete: (id: string) => api.delete(`/designs/${id}`),
}
export const racksApi = {
load: (designId: string) =>
api.get<import('@/utils/rackSerializer').ApiRackState>('/racks', { params: { design_id: designId } }),
save: (payload: import('@/utils/rackSerializer').RackSavePayload) =>
api.post<{ saved: boolean }>('/racks/save', payload),
inventory: (designId: string) =>
api.get<{ items: import('@/utils/rackSerializer').ApiInventoryItem[] }>('/racks/inventory', {
params: { design_id: designId },
}),
}
export interface ZigbeeConfigData {
mqtt_host: string
mqtt_port: number
@@ -34,10 +34,11 @@ interface CanvasContainerProps {
onNodeDragStart?: () => void
onRequestAddToGroup?: (payload: { nodeId: string; groupId: string }) => void
onRequestAddToContainer?: (payload: { nodeId: string; containerId: string }) => void
onOpenPending?: (deviceId: string) => void
onRequestAddToZone?: (payload: { nodeId: string; zoneId: string }) => void
onOpenInventory?: (deviceId: string) => void
}
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onRequestAddToGroup, onRequestAddToContainer, onOpenPending }: 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}>
@@ -202,7 +219,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
color={theme.colors.canvasDotColor}
/>
<FloorMapLayer />
<SearchBar onOpenPending={onOpenPending} />
<SearchBar onOpenInventory={onOpenInventory} />
<AlignmentGuides guides={guides} />
<Controls>
<ControlButton
+7 -7
View File
@@ -4,23 +4,23 @@ import { Search, X } from 'lucide-react'
import { useCanvasStore } from '@/stores/canvasStore'
import { scanApi } from '@/api/client'
import { NODE_TYPE_LABELS } from '@/types'
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
import type { InventoryEntry } from '@/components/modals/InventoryDeviceModal'
interface SearchBarProps {
onOpenPending?: (deviceId: string) => void
onOpenInventory?: (deviceId: string) => void
}
export function SearchBar({ onOpenPending }: SearchBarProps) {
export function SearchBar({ onOpenInventory }: SearchBarProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [pendingDevices, setPendingDevices] = useState<PendingDevice[]>([])
const [inventoryDevices, setInventoryEntrys] = useState<InventoryEntry[]>([])
const inputRef = useRef<HTMLInputElement>(null)
const { nodes, setSelectedNode } = useCanvasStore()
const { setCenter } = useReactFlow()
useEffect(() => {
if (!open) return
scanApi.pending().then((res) => setPendingDevices(res.data)).catch(() => {})
scanApi.pending().then((res) => setInventoryEntrys(res.data)).catch(() => {})
}, [open])
useEffect(() => {
@@ -86,7 +86,7 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
: []
const pendingResults = q
? pendingDevices.filter((d) =>
? inventoryDevices.filter((d) =>
d.ip?.toLowerCase().includes(q) ||
d.hostname?.toLowerCase().includes(q) ||
d.friendly_name?.toLowerCase().includes(q) ||
@@ -215,7 +215,7 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
return (
<button
key={d.id}
onClick={() => { onOpenPending?.(d.id); setOpen(false); setQuery('') }}
onClick={() => { onOpenInventory?.(d.id); setOpen(false); setQuery('') }}
style={{
width: '100%',
display: 'flex',
@@ -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', () => {
@@ -40,7 +40,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
const colors = resolveNodeColors(data, activeTheme)
const statusColor = theme.colors.statusColors[data.status]
const isOnline = data.status === 'online'
const services = data.services ?? []
// Hiding a service is a property of this node, not of the device: another
// canvas drawing the same device answers for itself.
const services = (data.services ?? []).filter((svc) => svc.visible !== false)
const showServices = data.custom_colors?.show_services === true
const serviceHost = data.ip ? primaryIp(data.ip) : data.hostname
@@ -148,7 +150,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 +166,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>
)
})}
@@ -0,0 +1,63 @@
/**
* A service hidden on this node stays on the device.
*
* Order and visibility are the node's, the facts are the inventory row's, so
* the same device drawn on two canvases can show two different service lists.
* The node card only draws what its own copy says is visible.
*/
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ReactFlowProvider } from '@xyflow/react'
import { Server } from 'lucide-react'
import { BaseNode } from '../BaseNode'
import type { NodeData, ServiceInfo } from '@/types'
vi.mock('@/stores/canvasStore', async () => {
const actual = await vi.importActual<typeof import('@/stores/canvasStore')>('@/stores/canvasStore')
return {
...actual,
useCanvasStore: (selector: (s: Record<string, unknown>) => unknown) =>
selector({ hideIp: false, serviceStatuses: {} }),
}
})
const ssh: ServiceInfo = { port: 22, protocol: 'tcp', service_name: 'ssh' }
const kuma: ServiceInfo = { port: 3001, protocol: 'tcp', service_name: 'Uptime Kuma' }
function renderNode(services: ServiceInfo[]) {
const data: NodeData = {
label: 'NAS',
type: 'nas',
status: 'online',
services,
// Services only render when the node is set to show them.
custom_colors: { show_services: true },
}
return render(
<ReactFlowProvider>
<BaseNode
{...({ id: 'n1', data, selected: false, icon: Server } as React.ComponentProps<typeof BaseNode>)}
/>
</ReactFlowProvider>,
)
}
describe('BaseNode services', () => {
it('draws a service that carries no visible flag', () => {
renderNode([ssh, kuma])
expect(screen.getByText('ssh')).toBeInTheDocument()
expect(screen.getByText('Uptime Kuma')).toBeInTheDocument()
})
it('leaves out one this node hid, keeping the rest', () => {
renderNode([ssh, { ...kuma, visible: false }])
expect(screen.getByText('ssh')).toBeInTheDocument()
expect(screen.queryByText('Uptime Kuma')).not.toBeInTheDocument()
})
it('draws them in the order the node carries', () => {
const { container } = renderNode([kuma, ssh])
const names = [...container.querySelectorAll('span.font-medium')].map((n) => n.textContent)
expect(names.filter((n) => n === 'ssh' || n === 'Uptime Kuma')).toEqual(['Uptime Kuma', 'ssh'])
})
})
@@ -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,
@@ -0,0 +1,332 @@
/**
* Editable list of `NodeProperty` records — key, value, optional icon, and a
* per-property "show it on the canvas" toggle.
*
* Extracted from `DetailPanel` so the rack cable panel gets the same editor
* rather than a second implementation of it. The component owns only its form
* and drag state; the array itself belongs to the caller, which receives every
* mutation as a whole new array through `onChange`.
*/
import { createElement, useState } from 'react'
import { Eye, EyeOff, GripVertical, Pencil, Plus, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
import type { NodeProperty } from '@/types'
export type PropForm = { key: string; value: string; icon: string | null; visible: boolean }
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
interface PropertyListProps {
properties: NodeProperty[]
/** Receives the full next array — the caller decides how to persist it. */
onChange: (next: NodeProperty[]) => void
/** Wording of the per-property visibility toggle ("Show on node" by default). */
visibleLabel?: string
/** Placeholder examples, so a cable does not suggest "CPU Model". */
keyPlaceholder?: string
valuePlaceholder?: string
/** One-click keys pre-filling the add form (e.g. Length, VLAN). */
suggestions?: string[]
/** Section heading. */
title?: string
emptyHint?: string
}
export function PropertyList({
properties,
onChange,
visibleLabel = 'Show on node',
keyPlaceholder = 'Label (e.g. CPU Model)',
valuePlaceholder = 'Value — optional (e.g. i7-12700K)',
suggestions,
title = 'Properties',
emptyHint = 'No properties — click Add to define one.',
}: PropertyListProps) {
const [adding, setAdding] = useState(false)
const [newProp, setNewProp] = useState<PropForm>(EMPTY_PROP)
const [editingIndex, setEditingIndex] = useState<number | null>(null)
const [editProp, setEditProp] = useState<PropForm>(EMPTY_PROP)
const [dragIndex, setDragIndex] = useState<number | null>(null)
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
const handleAdd = () => {
// 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 },
])
setNewProp(EMPTY_PROP)
setAdding(false)
}
const handleRemove = (index: number) => {
onChange(properties.filter((_, i) => i !== index))
// `editingIndex` is a position into an array the caller owns, so any
// mutation from outside the form invalidates it: removing an earlier row
// shifted the open form onto its neighbour, and Save then overwrote the
// wrong property. Close the form rather than try to track the move.
setEditingIndex(null)
}
const handleToggleVisible = (index: number) => {
onChange(properties.map((p, i) => (i === index ? { ...p, visible: !p.visible } : p)))
}
const handleStartEdit = (index: number) => {
const p = properties[index]
if (!p) return
setEditProp({ key: p.key, value: p.value, icon: p.icon, visible: p.visible })
setEditingIndex(index)
setAdding(false)
}
const handleSaveEdit = () => {
if (editingIndex === null || !editProp.key.trim()) return
onChange(
properties.map((p, i) =>
i === editingIndex
? { key: editProp.key.trim(), value: editProp.value.trim(), icon: editProp.icon, visible: editProp.visible }
: p
)
)
setEditingIndex(null)
}
const handleReorder = (from: number, to: number) => {
if (from === to || from < 0 || to < 0 || from >= properties.length || to >= properties.length) return
const reordered = [...properties]
const [moved] = reordered.splice(from, 1)
reordered.splice(to, 0, moved)
onChange(reordered)
// Same positional hazard as a remove — see handleRemove.
setEditingIndex(null)
}
const startAddWith = (key: string) => {
setEditingIndex(null)
setNewProp({ ...EMPTY_PROP, key })
setAdding(true)
}
return (
<div className="px-4 py-3 border-t border-border">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground">{title}{properties.length > 0 ? ` (${properties.length})` : ''}</span>
<button
onClick={() => { setAdding((v) => !v); setEditingIndex(null) }}
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer"
>
<Plus size={10} /> Add
</button>
</div>
{suggestions && suggestions.length > 0 && !adding && (
<div className="flex flex-wrap gap-1 mb-2">
{suggestions.map((key) => (
<button
key={key}
onClick={() => startAddWith(key)}
title={`Add a ${key} property`}
className="rounded border border-[#30363d] px-1.5 py-0.5 text-[10px] text-muted-foreground hover:border-[#00d4ff] hover:text-[#00d4ff] transition-colors cursor-pointer"
>
+ {key}
</button>
))}
</div>
)}
{adding && (
<PropertyForm
form={newProp}
onChange={setNewProp}
onConfirm={handleAdd}
onCancel={() => { setAdding(false); setNewProp(EMPTY_PROP) }}
confirmLabel="Add"
visibleLabel={visibleLabel}
keyPlaceholder={keyPlaceholder}
valuePlaceholder={valuePlaceholder}
/>
)}
{properties.length > 0 && (
<div className="flex flex-col gap-1.5">
{properties.map((prop, i) =>
editingIndex === i ? (
<PropertyForm
key={`edit-${i}`}
form={editProp}
onChange={setEditProp}
onConfirm={handleSaveEdit}
onCancel={() => setEditingIndex(null)}
confirmLabel="Save"
visibleLabel={visibleLabel}
keyPlaceholder={keyPlaceholder}
valuePlaceholder={valuePlaceholder}
/>
) : (
<PropertyBadge
key={`${prop.key}-${i}`}
prop={prop}
visibleLabel={visibleLabel}
draggable={properties.length > 1}
isDragging={dragIndex === i}
isDragOver={dragOverIndex === i && dragIndex !== i}
onDragStart={() => setDragIndex(i)}
onDragEnter={() => { if (dragIndex !== null) setDragOverIndex(i) }}
onDragEnd={() => { setDragIndex(null); setDragOverIndex(null) }}
onDrop={() => {
if (dragIndex !== null) handleReorder(dragIndex, i)
setDragIndex(null)
setDragOverIndex(null)
}}
onToggleVisible={() => handleToggleVisible(i)}
onEdit={() => handleStartEdit(i)}
onRemove={() => handleRemove(i)}
/>
)
)}
</div>
)}
{properties.length === 0 && !adding && (
<p className="text-[10px] text-muted-foreground/50">{emptyHint}</p>
)}
</div>
)
}
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
onCancel: () => void
confirmLabel: string
visibleLabel?: string
keyPlaceholder?: string
valuePlaceholder?: string
}) {
return (
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
<Input
value={form.key}
onChange={(e) => onChange({ ...form, key: e.target.value })}
placeholder={keyPlaceholder}
className="bg-[#21262d] border-[#30363d] text-xs h-7"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
/>
<Input
value={form.value}
onChange={(e) => onChange({ ...form, value: e.target.value })}
placeholder={valuePlaceholder}
className="bg-[#21262d] border-[#30363d] text-xs h-7"
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
/>
{/* Icon picker */}
<div className="flex flex-wrap gap-1 pt-0.5">
<button
onClick={() => onChange({ ...form, icon: null })}
title="No icon"
className={`w-6 h-6 rounded flex items-center justify-center text-[10px] border transition-colors ${
form.icon === null ? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]' : 'border-[#30363d] text-muted-foreground hover:border-[#8b949e]'
}`}
>
–
</button>
{PROPERTY_ICON_NAMES.map((name) => {
const Icon = PROPERTY_ICONS[name]
const active = form.icon === name
return (
<button
key={name}
onClick={() => onChange({ ...form, icon: name })}
title={name}
className={`w-6 h-6 rounded flex items-center justify-center border transition-colors ${
active ? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]' : 'border-[#30363d] text-muted-foreground hover:border-[#8b949e]'
}`}
>
{createElement(Icon, { size: 11 })}
</button>
)
})}
</div>
{/* Visible toggle */}
<label className="flex items-center gap-2 cursor-pointer pt-0.5">
<input
type="checkbox"
checked={form.visible}
onChange={(e) => onChange({ ...form, visible: e.target.checked })}
className="accent-[#00d4ff] w-3 h-3"
/>
<span className="text-[10px] text-muted-foreground">{visibleLabel}</span>
</label>
<div className="flex gap-1.5">
<Button size="sm" className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={onConfirm}>
{confirmLabel}
</Button>
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button>
</div>
</div>
)
}
export function PropertyBadge({ prop, visibleLabel = 'node', draggable, isDragging, isDragOver, onDragStart, onDragEnter, onDragEnd, onDrop, onToggleVisible, onEdit, onRemove }: {
prop: NodeProperty
/** Used to word the eye tooltip — "Show on node" / "Show on canvas". */
visibleLabel?: string
draggable: boolean
isDragging: boolean
isDragOver: boolean
onDragStart: () => void
onDragEnter: () => void
onDragEnd: () => void
onDrop: () => void
onToggleVisible: () => void
onEdit: () => void
onRemove: () => void
}) {
const Icon = resolvePropertyIcon(prop.icon)
const where = visibleLabel.replace(/^Show on /i, '')
return (
<div
draggable={draggable}
onDragStart={onDragStart}
onDragEnter={onDragEnter}
onDragOver={(e) => e.preventDefault()}
onDragEnd={onDragEnd}
onDrop={(e) => { e.preventDefault(); onDrop() }}
className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors"
style={{
background: '#21262d',
borderColor: isDragOver ? '#00d4ff' : '#30363d',
opacity: isDragging ? 0.4 : 1,
}}
>
<div className="flex items-center gap-1.5 min-w-0">
{draggable && (
<span className="shrink-0 cursor-grab active:cursor-grabbing text-[#8b949e] hover:text-[#00d4ff]" title="Drag to reorder">
{createElement(GripVertical, { size: 11 })}
</span>
)}
{Icon && createElement(Icon, { size: 11, className: 'shrink-0 text-muted-foreground' })}
<span className="font-medium truncate text-foreground" title={prop.key}>{prop.key}</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
onClick={onToggleVisible}
title={prop.visible ? `Hide on ${where}` : `Show on ${where}`}
className="text-[#8b949e] hover:text-[#00d4ff] transition-colors"
>
{prop.visible ? <Eye size={10} /> : <EyeOff size={10} />}
</button>
<button onClick={onEdit} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff]" title="Edit property">
<Pencil size={10} />
</button>
<button onClick={onRemove} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149]" title="Remove property">
<X size={10} />
</button>
</div>
</div>
)
}
@@ -0,0 +1,152 @@
/**
* Shared property editor — used by the node DetailPanel and the rack cable
* panel, so a positional bug here lands on both canvases.
*/
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { PropertyList } from '../PropertyList'
import type { NodeProperty } from '@/types'
const props = (): NodeProperty[] => [
{ key: 'A', value: '1', icon: null, visible: true },
{ key: 'B', value: '2', icon: null, visible: true },
{ key: 'C', value: '3', icon: null, visible: true },
]
/** The form's confirm button; the section header carries an "Add" too. */
const confirm = (name: string) => {
const buttons = screen.getAllByRole('button', { name })
return buttons[buttons.length - 1]
}
describe('PropertyList', () => {
it('renders each property with its key and value', () => {
render(<PropertyList properties={props()} onChange={vi.fn()} />)
expect(screen.getByTitle('A')).toBeInTheDocument()
expect(screen.getByTitle('3')).toBeInTheDocument()
})
it('adds a property', () => {
const onChange = vi.fn()
render(<PropertyList properties={[]} onChange={onChange} />)
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
fireEvent.change(screen.getByPlaceholderText(/^Label/), { target: { value: 'GPU' } })
fireEvent.change(screen.getByPlaceholderText(/^Value/), { target: { value: 'RTX' } })
fireEvent.click(confirm('Add'))
expect(onChange).toHaveBeenCalledWith([
{ key: 'GPU', value: 'RTX', icon: null, visible: true },
])
})
it('adds a property with a label and no value', () => {
const onChange = vi.fn()
render(<PropertyList properties={[]} onChange={onChange} />)
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
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} />)
fireEvent.click(screen.getAllByTitle('Hide on node')[1])
expect(onChange.mock.calls[0][0][1].visible).toBe(false)
})
it('closes the open edit form when another property is removed', () => {
// The form index is a position into an array the caller owns. Removing an
// earlier row shifted the open form onto its neighbour, and Save then
// overwrote the wrong property.
const onChange = vi.fn()
const { rerender } = render(<PropertyList properties={props()} onChange={onChange} />)
fireEvent.click(screen.getAllByTitle('Edit property')[1]) // editing B
expect(screen.getByDisplayValue('B')).toBeInTheDocument()
fireEvent.click(screen.getAllByTitle('Remove property')[0]) // remove A
const next = onChange.mock.calls[0][0]
rerender(<PropertyList properties={next} onChange={onChange} />)
// No form at all — index 1 now points at C, so a surviving form would sit
// on the wrong property and Save would overwrite it.
expect(screen.queryByRole('button', { name: 'Save' })).toBeNull()
expect(next.map((p: NodeProperty) => p.key)).toEqual(['B', 'C'])
})
it('closes the open edit form when the list is reordered', () => {
const onChange = vi.fn()
const { rerender } = render(<PropertyList properties={props()} onChange={onChange} />)
fireEvent.click(screen.getAllByTitle('Edit property')[2]) // editing C
expect(screen.getByDisplayValue('C')).toBeInTheDocument()
const rows = screen.getAllByTitle('Drag to reorder')
fireEvent.dragStart(rows[0])
fireEvent.dragEnter(rows[1])
fireEvent.drop(rows[1])
rerender(<PropertyList properties={onChange.mock.calls[0][0]} onChange={onChange} />)
expect(screen.queryByRole('button', { name: 'Save' })).toBeNull()
})
it('saves an edit onto the property it was opened on', () => {
const onChange = vi.fn()
render(<PropertyList properties={props()} onChange={onChange} />)
fireEvent.click(screen.getAllByTitle('Edit property')[1])
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '22' } })
fireEvent.click(confirm('Save'))
expect(onChange.mock.calls[0][0]).toEqual([
{ key: 'A', value: '1', icon: null, visible: true },
{ key: 'B', value: '22', icon: null, visible: true },
{ key: 'C', value: '3', icon: null, visible: true },
])
})
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)
})
it('pre-fills the add form from a suggestion', () => {
render(<PropertyList properties={[]} onChange={vi.fn()} suggestions={['Length']} />)
fireEvent.click(screen.getByText('+ Length'))
expect(screen.getByDisplayValue('Length')).toBeInTheDocument()
})
})
@@ -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)}
+45 -1
View File
@@ -4,11 +4,22 @@ import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { DESIGN_ICONS, DEFAULT_DESIGN_ICON, resolveDesignIcon } from '@/utils/designIcons'
import type { Design, FloorMapConfig } from '@/types'
import type { Design, DesignType, FloorMapConfig } from '@/types'
/** Canvas kinds offered on create. `electrical` shares the network renderer. */
const CANVAS_KINDS: { value: DesignType; label: string; hint: string }[] = [
{ value: 'network', label: 'Diagram', hint: 'Nodes and links on a free canvas' },
{ value: 'rack', label: 'Rack', hint: 'Racks, mounted gear and patching' },
]
export interface DesignFormData {
name: string
icon: string
/**
* Kind of canvas to create. Only offered in create mode — a canvas cannot
* change kind afterwards, since network and rack store different things.
*/
designType?: DesignType
/**
* Floor plan for THIS canvas. Only present when the floor-plan section is
* shown (edit mode on the active canvas). `null` means "remove the floor
@@ -60,9 +71,12 @@ export function DesignModal({
}: DesignModalProps) {
const [name, setName] = useState(initial?.name ?? '')
const [icon, setIcon] = useState(initial?.icon ?? DEFAULT_DESIGN_ICON)
const [designType, setDesignType] = useState<DesignType>(initial?.designType ?? 'network')
// "Copy from existing" is create-mode only (no floor-plan section shown).
const canCopy = !showFloorMap && sourceDesigns.length > 0
// Editing never changes the kind; `initial` is only passed in edit mode.
const canPickKind = !initial && !showFloorMap
const [fromExisting, setFromExisting] = useState(false)
const [sourceId, setSourceId] = useState<string>(sourceDesigns[0]?.id ?? '')
@@ -103,6 +117,7 @@ export function DesignModal({
if (!trimmed) return
if (canCopy && fromExisting && !sourceId) return
const data: DesignFormData = { name: trimmed, icon }
if (canPickKind) data.designType = designType
if (canCopy && fromExisting && sourceId) {
data.sourceId = sourceId
}
@@ -146,6 +161,35 @@ export function DesignModal({
/>
</div>
{/* A copy inherits the source's kind, so the picker would be a lie. */}
{canPickKind && !fromExisting && (
<div className="space-y-1.5">
<Label>Kind</Label>
<div className="grid grid-cols-2 gap-1.5">
{CANVAS_KINDS.map((kind) => {
const selected = kind.value === designType
return (
<button
key={kind.value}
type="button"
aria-pressed={selected}
title={kind.hint}
onClick={() => setDesignType(kind.value)}
className={`rounded-md border px-2 py-2 text-left transition-colors cursor-pointer ${
selected
? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]'
: 'border-border text-muted-foreground hover:text-foreground hover:border-[#30363d]'
}`}
>
<span className="block text-xs font-medium">{kind.label}</span>
<span className="block text-[10px] leading-tight opacity-70">{kind.hint}</span>
</button>
)
})}
</div>
</div>
)}
<div className="space-y-1.5">
<Label>Icon</Label>
<div className="grid grid-cols-8 gap-1.5">
@@ -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,8 +9,9 @@ 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 { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
import { InventoryDeviceModal, type InventoryEntry } from '@/components/modals/InventoryDeviceModal'
import type { NodeType, ServiceInfo } from '@/types'
import { applyAutoEdges, type AutoEdge } from '@/utils/autoEdges'
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
@@ -18,15 +19,24 @@ import { buildZwaveProperties, isZwaveType } from '@/utils/zwaveProperties'
import { buildMacProperty } from '@/utils/macProperty'
import { formatRelative, formatTimestamp } from '@/utils/timeFormat'
import { getCenteredPosition } from '@/utils/viewportCenter'
import { sourceBuckets, orderedSources, SOURCE_META, type SourceBucket } from '@/utils/pendingSources'
import { sourceBuckets, orderedSources, isRackDevice, SOURCE_META, type SourceBucket } from '@/utils/deviceSources'
import { isRackable } from '@/utils/rackable'
interface PendingDevicesModalProps {
interface DeviceInventoryModalProps {
open: boolean
onClose: () => void
highlightId?: string
initialStatus?: 'pending' | 'hidden'
/** Getting Started tour: render these canned devices and skip the backend fetch. */
demoDevices?: PendingDevice[]
demoDevices?: InventoryEntry[]
/**
* Picker mode. Clicking a card hands the device back instead of opening its
* detail modal, so another feature (the rack canvas) can reuse this list —
* search, filters, badges and all — rather than reimplementing a `<select>`.
*/
onPick?: (device: InventoryEntry) => void
/** Start with the Rackable filter on (what the rack picker wants). */
initialRackableOnly?: boolean
}
const PORT_COLORS: Record<number, string> = {
@@ -63,26 +73,21 @@ 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])
function specialServiceName(d: PendingDevice): string | undefined {
/**
* 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,
)
@@ -91,8 +96,9 @@ function specialServiceName(d: PendingDevice): string | undefined {
return (nonWeb ?? candidates[0])?.service_name ?? undefined
}
function deviceLabel(d: PendingDevice): string {
return d.friendly_name ?? d.hostname ?? specialServiceName(d) ?? d.ip ?? d.ieee_address ?? 'device'
function deviceLabel(d: InventoryEntry): string {
// 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
@@ -115,10 +121,10 @@ function injectAutoEdges(edges: AutoEdge[] | undefined) {
}))
}
export function PendingDevicesModal({ open, onClose, highlightId, initialStatus = 'pending', demoDevices }: PendingDevicesModalProps) {
const [devices, setDevices] = useState<PendingDevice[]>([])
export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus = 'pending', demoDevices, onPick, initialRackableOnly = false }: DeviceInventoryModalProps) {
const [devices, setDevices] = useState<InventoryEntry[]>([])
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<PendingDevice | null>(null)
const [selected, setSelected] = useState<InventoryEntry | null>(null)
const [selectMode, setSelectMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
@@ -129,13 +135,16 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
const [showOnCanvas, setShowOnCanvas] = useState(true)
// Optionally restrict to devices that have at least one detected service.
const [withServicesOnly, setWithServicesOnly] = useState(false)
// Hardware only — what a rack can hold. On by default when the rack canvas
// opens the inventory to pick a mount.
const [rackableOnly, setRackableOnly] = useState(initialRackableOnly)
const { addNode, scanEventTs } = useCanvasStore()
const setSelectedNode = useCanvasStore((s) => s.setSelectedNode)
const activeDesignId = useDesignStore((s) => s.activeDesignId)
const highlightRef = useRef<HTMLButtonElement>(null)
// Set when a single approve is refused because the same host is already on
// this design — the user decides: link to the existing node or duplicate.
const [dupPrompt, setDupPrompt] = useState<{ device: PendingDevice; conflict: DuplicateNodeConflict } | null>(null)
const [dupPrompt, setDupPrompt] = useState<{ device: InventoryEntry; conflict: DuplicateNodeConflict } | null>(null)
const load = useCallback(async () => {
// Tour/demo mode: show injected devices, never touch the backend.
@@ -162,12 +171,15 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
setSearch('')
} else {
setStatusFilter(initialStatus)
// Reopening from the rack picker must re-arm the filter even if the user
// turned it off during the previous visit.
setRackableOnly(initialRackableOnly)
}
}, [open, initialStatus])
}, [open, initialStatus, initialRackableOnly])
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])
@@ -175,10 +187,11 @@ export function PendingDevicesModal({ 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
if (rackableOnly && !isRackable(d)) return false
if (q) {
const hay = [
d.friendly_name, d.hostname, d.ip, d.mac, d.ieee_address, d.vendor, d.model,
@@ -188,7 +201,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
}
return true
})
}, [devices, search, sourceFilter, typeFilter, statusFilter, showOnCanvas, withServicesOnly])
}, [devices, search, sourceFilter, typeFilter, statusFilter, showOnCanvas, withServicesOnly, rackableOnly])
useEffect(() => {
if (!highlightId || loading || !open) return
@@ -203,13 +216,16 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
})
}
const handleCardClick = (d: PendingDevice) => {
const handleCardClick = (d: InventoryEntry) => {
// Picker mode wins over everything else: the caller opened this list to get
// one device back, not to approve or hide anything.
if (onPick) { onPick(d); return }
if (selectMode) { toggleSelect(d.id); return }
if (statusFilter === 'hidden') { handleRestore(d); return }
setSelected(d)
}
const handleRestore = async (device: PendingDevice) => {
const handleRestore = async (device: InventoryEntry) => {
try {
await scanApi.restore(device.id)
setDevices((prev) => prev.filter((d) => d.id !== device.id))
@@ -275,9 +291,15 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
// force=true is sent only after the user confirms they want a duplicate node
// on this design (the backend otherwise 409s to let us ask).
const approveDevice = async (device: PendingDevice, force = false) => {
const approveDevice = async (device: InventoryEntry, force = false) => {
// Rack gear documents a mount, not a host: it stays out of logical canvases.
// The backend refuses it too — this is the friendly half of the guard.
if (isRackDevice(device)) {
toast.error('Rack devices belong to a rack canvas, not a logical one')
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
@@ -329,7 +351,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
}
}
const handleApprove = (device: PendingDevice) => approveDevice(device, false)
const handleApprove = (device: InventoryEntry) => approveDevice(device, false)
const goToExistingNode = (nodeId: string) => {
setSelectedNode(nodeId)
@@ -338,7 +360,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
onClose()
}
const handleHide = async (device: PendingDevice) => {
const handleHide = async (device: InventoryEntry) => {
try {
await scanApi.hide(device.id)
setDevices((prev) => prev.filter((d) => d.id !== device.id))
@@ -349,7 +371,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
}
}
const handleIgnore = async (device: PendingDevice) => {
const handleIgnore = async (device: InventoryEntry) => {
try {
await scanApi.ignore(device.id)
setDevices((prev) => prev.filter((d) => d.id !== device.id))
@@ -360,7 +382,14 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
}
const handleBulkApprove = async () => {
const ids = [...selectedIds]
const rackIds = new Set(devices.filter(isRackDevice).map((d) => d.id))
const ids = [...selectedIds].filter((id) => !rackIds.has(id))
const skippedRack = selectedIds.size - ids.length
if (skippedRack > 0) {
toast.error(
`${skippedRack} rack device${skippedRack > 1 ? 's' : ''} skipped — they belong to a rack canvas`,
)
}
if (ids.length === 0) return
try {
const res = await scanApi.bulkApprove(ids, activeDesignId)
@@ -373,7 +402,7 @@ export function PendingDevicesModal({ 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({
@@ -446,6 +475,9 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
}
if (inField) return
if (e.key === '/') { e.preventDefault(); searchRef.current?.focus() }
// Bulk actions are meaningless in picker mode — the card click returns a
// device, so select mode must stay unreachable from the keyboard too.
else if (onPick) return
else if (e.key.toLowerCase() === 's') { e.preventDefault(); if (selectMode) exitSelectMode(); else enterSelectMode() }
else if (e.key.toLowerCase() === 'a' && selectMode) { e.preventDefault(); selectAllVisible() }
else if (e.key === 'Enter' && selectMode && selectedIds.size > 0) {
@@ -461,7 +493,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
// statusFilter is included so Enter dispatches the correct bulk action
// (approve vs restore) even if the device list doesn't change on switch.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, selectMode, selectedIds, filtered, statusFilter])
}, [open, selectMode, selectedIds, filtered, statusFilter, onPick])
return (
<>
@@ -473,7 +505,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
<DialogHeader className="px-4 py-3 border-b border-border shrink-0">
<div className="flex items-center justify-between gap-3">
<DialogTitle className="text-base font-semibold flex items-center gap-2">
{statusFilter === 'pending' ? 'Device Inventory' : 'Hidden Devices'}
{onPick ? 'Pick a Device' : statusFilter === 'pending' ? 'Device Inventory' : 'Hidden Devices'}
<span className="text-muted-foreground font-normal text-xs">
({filtered.length}{filtered.length !== devices.length && ` of ${devices.length}`})
</span>
@@ -482,7 +514,8 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
<button onClick={load} className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors" title="Refresh">
<RefreshCw size={14} />
</button>
{statusFilter === 'pending' && devices.length > 0 && (
{/* Never offer a destructive bulk clear from a picker. */}
{!onPick && statusFilter === 'pending' && devices.length > 0 && (
<button
onClick={handleClearAll}
className="text-muted-foreground hover:text-[#f85149] p-1.5 rounded transition-colors"
@@ -551,6 +584,20 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
>
Proxmox
</button>
<button
onClick={() => setSourceFilter('rack')}
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'rack' ? 'bg-[#39d353]/20 text-[#39d353]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
title="Gear created from a rack canvas"
>
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}
@@ -595,6 +642,16 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
<ServerCog size={12} />
With services
</button>
<button
onClick={() => setRackableOnly((v) => !v)}
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded border transition-colors ${rackableOnly ? 'bg-[#39d353]/20 text-[#39d353] border-[#39d353]/50' : 'bg-[#0d1117] text-muted-foreground border-border hover:text-foreground'}`}
title="Only show hardware you could mount in a rack (no VMs, containers or mesh devices)"
aria-pressed={rackableOnly}
>
<Server size={12} />
Rackable
</button>
{!onPick && (
<button
onClick={() => selectMode ? exitSelectMode() : enterSelectMode()}
className={`text-xs px-2.5 py-1.5 rounded border transition-colors ${selectMode ? 'bg-[#00d4ff]/20 text-[#00d4ff] border-[#00d4ff]/50' : 'bg-[#0d1117] text-muted-foreground border-border hover:text-foreground'}`}
@@ -602,6 +659,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
>
{selectMode ? 'Exit select' : 'Select mode'}
</button>
)}
</div>
{/* Body */}
@@ -738,19 +796,25 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
</DialogContent>
</Dialog>
<PendingDeviceModal
<InventoryDeviceModal
device={selected}
onClose={() => setSelected(null)}
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)
}}
/>
</>
)
}
interface DeviceCardProps {
device: PendingDevice
device: InventoryEntry
selected: boolean
selectMode: boolean
highlighted: boolean
@@ -760,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.
@@ -839,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 && (
@@ -0,0 +1,773 @@
/**
* 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 { 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'
// Home is `@/types` now — re-exported because most call sites import it here.
export type { InventoryEntry } from '@/types'
interface InventoryDeviceModalProps {
device: InventoryEntry | null
onClose: () => void
onApprove: (device: InventoryEntry) => void
onHide: (device: InventoryEntry) => void
onIgnore: (device: InventoryEntry) => void
/** 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> = {
hypervisor: '#ff6e00',
nas: '#39d353',
automation: '#a855f7',
containers: '#00d4ff',
network: '#39d353',
security: '#f85149',
monitoring: '#e3b341',
database: '#a855f7',
web: '#00d4ff',
media: '#ff6e00',
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'
}
/** 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 (
<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>
)
}
/** 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 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 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="!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>
{!editing && (
<Button
size="sm"
variant="ghost"
className={`shrink-0 gap-1.5 text-[#00d4ff] hover:text-[#00d4ff] hover:bg-[#00d4ff]/10 ${modalStyles['modal-interactive']}`}
onClick={() => setEditing(true)}
>
<Pencil size={13} /> Edit
</Button>
)}
</div>
</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}
// Whether a canvas draws a property is that node's own
// answer; the flag here only decides what a node drawing
// this device from now on starts out showing.
visibleLabel="Show on new nodes"
// 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 (
@@ -1,209 +0,0 @@
import { Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import type { NodeProperty } from '@/types'
interface Service {
port: number
protocol: string
service_name: string
icon?: string | null
category?: string | null
}
export interface PendingDevice {
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
}
interface PendingDeviceModalProps {
device: PendingDevice | null
onClose: () => void
onApprove: (device: PendingDevice) => void
onHide: (device: PendingDevice) => void
onIgnore: (device: PendingDevice) => 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,
}
const CATEGORY_COLORS: Record<string, string> = {
hypervisor: '#ff6e00',
nas: '#39d353',
automation: '#a855f7',
containers: '#00d4ff',
network: '#39d353',
security: '#f85149',
monitoring: '#e3b341',
database: '#a855f7',
web: '#00d4ff',
media: '#ff6e00',
iot: '#e3b341',
}
function categoryColor(category: string | null | undefined) {
if (!category) return '#8b949e'
return CATEGORY_COLORS[category.toLowerCase()] ?? '#8b949e'
}
function InfoRow({ label, value }: { label: string; value: string }) {
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>
</div>
)
}
export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnore }: PendingDeviceModalProps) {
if (!device) return null
const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
const isZigbee = device.discovery_source === 'zigbee'
const titleLabel = device.friendly_name ?? device.hostname ?? device.ip ?? device.ieee_address ?? 'Pending device'
const handleApprove = () => { onApprove(device) }
const handleHide = () => { onHide(device); onClose() }
const handleIgnore = () => { onIgnore(device); onClose() }
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>
))}
</div>
)}
</div>}
{/* Actions */}
<div className="flex gap-2 pt-1">
<Button
size="sm"
className="flex-1 bg-[#39d353]/15 text-[#39d353] hover:bg-[#39d353]/25 border border-[#39d353]/30"
onClick={handleApprove}
>
Approve
</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>
</div>
</DialogContent>
</Dialog>
)
}
+11 -11
View File
@@ -3,24 +3,24 @@ import { useReactFlow } from '@xyflow/react'
import { Search } from 'lucide-react'
import { useCanvasStore } from '@/stores/canvasStore'
import { scanApi } from '@/api/client'
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
import type { InventoryEntry } from '@/components/modals/InventoryDeviceModal'
interface SearchModalProps {
open: boolean
onClose: () => void
onOpenPending: (deviceId: string) => void
onOpenInventory: (deviceId: string) => void
}
export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps) {
export function SearchModal({ open, onClose, onOpenInventory }: SearchModalProps) {
const [query, setQuery] = useState('')
const [pendingDevices, setPendingDevices] = useState<PendingDevice[]>([])
const [inventoryDevices, setInventoryEntrys] = useState<InventoryEntry[]>([])
const nodes = useCanvasStore((s) => s.nodes)
const setSelectedNode = useCanvasStore((s) => s.setSelectedNode)
const { fitView } = useReactFlow()
useEffect(() => {
if (!open) return
scanApi.pending().then((res) => setPendingDevices(res.data)).catch(() => {})
scanApi.pending().then((res) => setInventoryEntrys(res.data)).catch(() => {})
}, [open])
const searchable = nodes.filter((n) => n.data.type !== 'groupRect')
@@ -32,7 +32,7 @@ export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps)
n.data.hostname?.toLowerCase().includes(q)
).slice(0, 6)
const pendingResults = q.length === 0 ? [] : pendingDevices.filter((d) =>
const pendingResults = q.length === 0 ? [] : inventoryDevices.filter((d) =>
d.ip?.toLowerCase().includes(q) ||
d.hostname?.toLowerCase().includes(q) ||
d.friendly_name?.toLowerCase().includes(q) ||
@@ -52,11 +52,11 @@ export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps)
setQuery('')
}, [fitView, setSelectedNode, onClose])
const handleSelectPending = useCallback((deviceId: string) => {
onOpenPending(deviceId)
const handleSelectInventoryDevice = useCallback((deviceId: string) => {
onOpenInventory(deviceId)
onClose()
setQuery('')
}, [onOpenPending, onClose])
}, [onOpenInventory, onClose])
if (!open) return null
@@ -77,7 +77,7 @@ export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps)
onKeyDown={(e) => {
if (e.key === 'Escape') { onClose(); setQuery('') }
if (e.key === 'Enter' && nodeResults.length > 0) handleSelectNode(nodeResults[0].id)
if (e.key === 'Enter' && nodeResults.length === 0 && pendingResults.length > 0) handleSelectPending(pendingResults[0].id)
if (e.key === 'Enter' && nodeResults.length === 0 && pendingResults.length > 0) handleSelectInventoryDevice(pendingResults[0].id)
}}
/>
<kbd className="text-[10px] text-muted-foreground border border-border rounded px-1">ESC</kbd>
@@ -109,7 +109,7 @@ export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps)
<li
key={device.id}
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
onClick={() => handleSelectPending(device.id)}
onClick={() => handleSelectInventoryDevice(device.id)}
>
<span className="text-xs font-mono text-[#e3b341] w-16 shrink-0">pending</span>
<span className="text-sm text-foreground font-medium flex-1 truncate font-mono">{device.hostname ?? device.ip}</span>
@@ -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>
@@ -15,7 +15,7 @@ describe('DesignModal', () => {
const { onSubmit } = renderModal()
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Home Network' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(onSubmit).toHaveBeenCalledWith({ name: 'Home Network', icon: DEFAULT_DESIGN_ICON })
expect(onSubmit).toHaveBeenCalledWith({ name: 'Home Network', icon: DEFAULT_DESIGN_ICON, designType: 'network' })
})
it('submits the selected icon', () => {
@@ -23,7 +23,41 @@ describe('DesignModal', () => {
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Rack Power' } })
fireEvent.click(screen.getByRole('button', { name: 'Electrical' })) // zap icon's aria-label
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(onSubmit).toHaveBeenCalledWith({ name: 'Rack Power', icon: 'zap' })
expect(onSubmit).toHaveBeenCalledWith({ name: 'Rack Power', icon: 'zap', designType: 'network' })
})
describe('canvas kind', () => {
it('defaults to a diagram canvas', () => {
const { onSubmit } = renderModal()
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Net' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(onSubmit.mock.calls[0][0].designType).toBe('network')
})
it('submits the rack kind when picked', () => {
const { onSubmit } = renderModal()
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Rack Room' } })
fireEvent.click(screen.getByRole('button', { name: /^Rack/ }))
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(onSubmit.mock.calls[0][0].designType).toBe('rack')
})
it('is not offered in edit mode — a canvas cannot change kind', () => {
renderModal({ initial: { name: 'Existing', icon: 'server' }, submitLabel: 'Save' })
expect(screen.queryByRole('button', { name: /^Rack/ })).toBeNull()
})
it('is hidden once copying — a copy inherits the source kind', () => {
renderModal({
sourceDesigns: [
{ id: 's1', name: 'Home Net', icon: 'network', design_type: 'network' as const,
created_at: '', updated_at: '', node_count: 1, group_count: 0, text_count: 0 },
],
})
expect(screen.getByRole('button', { name: /^Rack/ })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Copy from existing' }))
expect(screen.queryByRole('button', { name: /^Rack/ })).toBeNull()
})
})
it('trims whitespace and blocks empty names', () => {
@@ -34,7 +68,7 @@ describe('DesignModal', () => {
fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Spaced ' } })
fireEvent.click(submit)
expect(onSubmit).toHaveBeenCalledWith({ name: 'Spaced', icon: DEFAULT_DESIGN_ICON })
expect(onSubmit).toHaveBeenCalledWith({ name: 'Spaced', icon: DEFAULT_DESIGN_ICON, designType: 'network' })
})
it('prefills name and icon in edit mode', () => {
@@ -55,7 +89,7 @@ describe('DesignModal', () => {
const input = screen.getByLabelText('Name')
fireEvent.change(input, { target: { value: 'Quick' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onSubmit).toHaveBeenCalledWith({ name: 'Quick', icon: DEFAULT_DESIGN_ICON })
expect(onSubmit).toHaveBeenCalledWith({ name: 'Quick', icon: DEFAULT_DESIGN_ICON, designType: 'network' })
})
it('calls onClose from Cancel', () => {
@@ -93,7 +127,7 @@ describe('DesignModal', () => {
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Copy of Home' } })
fireEvent.click(screen.getByRole('button', { name: 'Copy from existing' }))
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(onSubmit).toHaveBeenCalledWith({ name: 'Copy of Home', icon: DEFAULT_DESIGN_ICON, sourceId: 's1' })
expect(onSubmit).toHaveBeenCalledWith({ name: 'Copy of Home', icon: DEFAULT_DESIGN_ICON, designType: 'network', sourceId: 's1' })
})
it('includes the picked sourceId on submit', () => {
@@ -102,14 +136,14 @@ describe('DesignModal', () => {
fireEvent.click(screen.getByRole('button', { name: 'Copy from existing' }))
fireEvent.click(screen.getByRole('radio', { name: /Lab/ }))
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(onSubmit).toHaveBeenCalledWith({ name: 'Copy of Lab', icon: DEFAULT_DESIGN_ICON, sourceId: 's2' })
expect(onSubmit).toHaveBeenCalledWith({ name: 'Copy of Lab', icon: DEFAULT_DESIGN_ICON, designType: 'network', sourceId: 's2' })
})
it('omits sourceId when the blank option is kept', () => {
const { onSubmit } = renderModal({ sourceDesigns })
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Fresh' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(onSubmit).toHaveBeenCalledWith({ name: 'Fresh', icon: DEFAULT_DESIGN_ICON })
expect(onSubmit).toHaveBeenCalledWith({ name: 'Fresh', icon: DEFAULT_DESIGN_ICON, designType: 'network' })
expect('sourceId' in onSubmit.mock.calls[0][0]).toBe(false)
})
@@ -131,7 +165,7 @@ describe('DesignModal', () => {
expect(screen.queryByText('Floor Plan')).toBeNull()
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'X' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(onSubmit).toHaveBeenCalledWith({ name: 'X', icon: DEFAULT_DESIGN_ICON })
expect(onSubmit).toHaveBeenCalledWith({ name: 'X', icon: DEFAULT_DESIGN_ICON, designType: 'network' })
expect('floorMap' in onSubmit.mock.calls[0][0]).toBe(false)
})
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'
import { PendingDevicesModal } from '../PendingDevicesModal'
import { DeviceInventoryModal } from '../DeviceInventoryModal'
import { useCanvasStore } from '@/stores/canvasStore'
vi.mock('@/stores/canvasStore')
@@ -33,8 +33,8 @@ vi.mock('@/api/client', () => ({
vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
vi.mock('@/components/modals/PendingDeviceModal', () => ({
PendingDeviceModal: ({ device, onApprove }: { device: unknown; onApprove: (d: unknown) => void }) =>
vi.mock('@/components/modals/InventoryDeviceModal', () => ({
InventoryDeviceModal: ({ device, onApprove }: { device: unknown; onApprove: (d: unknown) => void }) =>
device ? (
<div data-testid="approval-modal">
<button data-testid="do-approve" onClick={() => onApprove(device)}>approve</button>
@@ -107,6 +107,33 @@ const DEVICE_PROXMOX = {
discovered_at: '2026-01-04T00:00:00Z',
}
const DEVICE_RACK = {
id: 'dev-rack',
ip: null,
hostname: 'patch-house',
mac: null,
os: null,
services: [],
suggested_type: null,
status: 'pending',
discovery_source: 'rack',
discovery_sources: ['rack'],
discovered_at: '2026-01-05T00:00:00Z',
}
const DEVICE_VM = {
id: 'dev-vm',
ip: '192.168.1.55',
hostname: 'web-vm',
mac: null,
os: null,
services: [],
suggested_type: 'vm',
status: 'pending',
discovery_source: 'proxmox',
discovered_at: '2026-01-06T00:00:00Z',
}
beforeEach(() => {
vi.clearAllMocks()
// Apply the selector when one is passed (setSelectedNode is read via a
@@ -134,29 +161,29 @@ const baseProps = {
onClose: vi.fn(),
}
describe('PendingDevicesModal', () => {
describe('DeviceInventoryModal', () => {
it('loads and renders pending devices on open', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.getByText('living-room-bulb')).toBeInTheDocument()
})
it('closes via the X button (routes through DialogClose, not a raw onClick)', async () => {
const onClose = vi.fn()
render(<PendingDevicesModal open onClose={onClose} />)
render(<DeviceInventoryModal open onClose={onClose} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('shows source chip ZIGBEE for zigbee device', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.getByText('ZIGBEE')).toBeInTheDocument()
})
it('filters by search query', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.change(screen.getByPlaceholderText(/Search/), { target: { value: 'living' } })
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
@@ -164,7 +191,7 @@ describe('PendingDevicesModal', () => {
})
it('filters by source (zigbee only)', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Zigbee' }))
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
@@ -173,14 +200,14 @@ describe('PendingDevicesModal', () => {
it('shows source chip Z-WAVE for zwave device', async () => {
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZWAVE] })
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-c')).toBeInTheDocument())
expect(screen.getByText('Z-WAVE')).toBeInTheDocument()
})
it('colours the role badge with the node-type accent, not flat grey', async () => {
mockPending.mockResolvedValue({ data: [DEVICE_ZWAVE] })
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
const card = await waitFor(() => screen.getByTestId('pending-card-dev-c'))
// zwave_router accent from the default theme = #e3b341 (amber), applied to
// both the text colour and a translucent background.
@@ -192,7 +219,7 @@ describe('PendingDevicesModal', () => {
it('filters by source (zwave only)', async () => {
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZWAVE] })
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Z-Wave' }))
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
@@ -201,7 +228,7 @@ describe('PendingDevicesModal', () => {
it('shows source chip PROXMOX for a proxmox device (not zigbee despite ieee)', async () => {
mockPending.mockResolvedValue({ data: [DEVICE_PROXMOX] })
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-d')).toBeInTheDocument())
expect(screen.getByText('PROXMOX')).toBeInTheDocument()
expect(screen.queryByText('ZIGBEE')).not.toBeInTheDocument()
@@ -209,7 +236,7 @@ describe('PendingDevicesModal', () => {
it('filters by source (proxmox only)', async () => {
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_PROXMOX] })
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Proxmox' }))
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
@@ -217,7 +244,7 @@ describe('PendingDevicesModal', () => {
})
it('filters by suggested type', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.change(screen.getByLabelText('Type filter'), { target: { value: 'server' } })
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
@@ -228,7 +255,7 @@ describe('PendingDevicesModal', () => {
mockHidden.mockResolvedValue({
data: [{ ...DEVICE_IP, id: 'h1', hostname: 'hidden-host', status: 'hidden' }],
})
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Hidden' }))
await waitFor(() => expect(screen.getByTestId('pending-card-h1')).toBeInTheDocument())
@@ -236,7 +263,7 @@ describe('PendingDevicesModal', () => {
})
it('opens approval modal when card is clicked outside select mode', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
expect(screen.getByTestId('approval-modal')).toBeInTheDocument()
@@ -259,7 +286,7 @@ describe('PendingDevicesModal', () => {
it('single approve prompts instead of failing when the host is already on the design', async () => {
mockApprove.mockRejectedValueOnce(DUP_409)
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
fireEvent.click(screen.getByTestId('do-approve'))
@@ -273,7 +300,7 @@ describe('PendingDevicesModal', () => {
it('"Add duplicate anyway" retries the approve with force=true', async () => {
mockApprove.mockRejectedValueOnce(DUP_409)
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
fireEvent.click(screen.getByTestId('do-approve'))
@@ -286,7 +313,7 @@ describe('PendingDevicesModal', () => {
it('"Go to existing node" selects the existing node and closes the modal', async () => {
mockApprove.mockRejectedValueOnce(DUP_409)
const onClose = vi.fn()
render(<PendingDevicesModal {...baseProps} onClose={onClose} />)
render(<DeviceInventoryModal {...baseProps} onClose={onClose} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
fireEvent.click(screen.getByTestId('do-approve'))
@@ -297,7 +324,7 @@ describe('PendingDevicesModal', () => {
})
it('toggles selection in select mode instead of opening approval', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
@@ -306,7 +333,7 @@ describe('PendingDevicesModal', () => {
})
it('select all visible selects only filtered devices', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.change(screen.getByPlaceholderText(/Search/), { target: { value: 'host-a' } })
@@ -315,7 +342,7 @@ describe('PendingDevicesModal', () => {
})
it('bulk approve calls API with selected ids', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
@@ -333,7 +360,7 @@ describe('PendingDevicesModal', () => {
skipped_devices: [{ device_id: 'dev-a', label: 'host-a', match: 'ip', value: '192.168.1.10', existing_node_id: 'n-existing' }],
},
})
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
@@ -355,7 +382,7 @@ describe('PendingDevicesModal', () => {
{ ...DEVICE_IP, canvas_count: 1 },
{ ...DEVICE_ZIGBEE, canvas_count: 1 },
] })
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
@@ -369,7 +396,7 @@ describe('PendingDevicesModal', () => {
})
it('bulk approve carries the scanned MAC onto the canvas node (#168)', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
@@ -397,7 +424,7 @@ describe('PendingDevicesModal', () => {
})
it('bulk hide calls API with selected ids', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
@@ -406,20 +433,20 @@ describe('PendingDevicesModal', () => {
})
it('does not load when closed', () => {
render(<PendingDevicesModal {...baseProps} open={false} />)
render(<DeviceInventoryModal {...baseProps} open={false} />)
expect(mockPending).not.toHaveBeenCalled()
})
it('respects initialStatus=hidden', async () => {
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, hostname: 'hidden-host', status: 'hidden' }] })
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
render(<DeviceInventoryModal {...baseProps} initialStatus="hidden" />)
await waitFor(() => expect(mockHidden).toHaveBeenCalled())
expect(mockPending).not.toHaveBeenCalled()
})
it('clicking a hidden card restores it instead of opening approval', async () => {
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
render(<DeviceInventoryModal {...baseProps} initialStatus="hidden" />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
await waitFor(() => expect(mockRestore).toHaveBeenCalledWith('dev-a'))
@@ -428,7 +455,7 @@ describe('PendingDevicesModal', () => {
it('bulk restore in hidden mode calls API with selected ids', async () => {
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
render(<DeviceInventoryModal {...baseProps} initialStatus="hidden" />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
@@ -437,7 +464,7 @@ describe('PendingDevicesModal', () => {
})
it('Enter confirms approve in pending select mode', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
@@ -448,7 +475,7 @@ describe('PendingDevicesModal', () => {
it('Enter restores (not approves) in hidden select mode', async () => {
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
render(<DeviceInventoryModal {...baseProps} initialStatus="hidden" />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
@@ -460,21 +487,21 @@ describe('PendingDevicesModal', () => {
// --- Device Inventory: rename, canvas badge, on-canvas filter ---
it('titles the pending view "Device Inventory"', async () => {
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.getByText('Device Inventory')).toBeInTheDocument()
})
it('shows "Hidden Devices" title in hidden mode', async () => {
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
render(<DeviceInventoryModal {...baseProps} initialStatus="hidden" />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.getByText('Hidden Devices')).toBeInTheDocument()
})
it('renders a corner canvas-count when canvas_count > 0', async () => {
mockPending.mockResolvedValue({ data: [{ ...DEVICE_IP, canvas_count: 2 }] })
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
const corner = screen.getByLabelText('On 2 canvases')
expect(corner).toHaveTextContent('2')
@@ -482,21 +509,21 @@ describe('PendingDevicesModal', () => {
it('uses singular "canvas" for a single canvas', async () => {
mockPending.mockResolvedValue({ data: [{ ...DEVICE_IP, canvas_count: 1 }] })
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.getByLabelText('On 1 canvas')).toHaveTextContent('1')
})
it('does not render the canvas-count corner when canvas_count is 0', async () => {
mockPending.mockResolvedValue({ data: [{ ...DEVICE_IP, canvas_count: 0 }] })
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.queryByLabelText(/On \d+ canvas/)).not.toBeInTheDocument()
})
it('filters to devices with detected services when "With services" is on', async () => {
// dev-a has an http service; dev-b (zigbee) has none.
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: /With services/ }))
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
@@ -507,7 +534,7 @@ describe('PendingDevicesModal', () => {
mockPending.mockResolvedValue({
data: [DEVICE_IP, { ...DEVICE_ZIGBEE, canvas_count: 1 }],
})
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
// Default: both visible (on-canvas shown).
await waitFor(() => expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument())
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
@@ -529,7 +556,7 @@ describe('PendingDevicesModal', () => {
node_last_seen: '2026-06-25T09:15:00Z',
}],
})
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
const card = await screen.findByTestId('pending-card-dev-a')
const inCard = within(card)
expect(inCard.getByText('Created')).toBeInTheDocument()
@@ -542,11 +569,194 @@ describe('PendingDevicesModal', () => {
it('falls back to Discovered for devices not on any canvas', async () => {
mockPending.mockResolvedValue({ data: [DEVICE_IP] })
render(<PendingDevicesModal {...baseProps} />)
render(<DeviceInventoryModal {...baseProps} />)
const card = await screen.findByTestId('pending-card-dev-a')
const inCard = within(card)
expect(inCard.getByText('Discovered')).toBeInTheDocument()
expect(inCard.queryByText('Created')).not.toBeInTheDocument()
})
})
// Gear created from a rack canvas lives in the same inventory (so it gets the
// search / filter / hide / delete tooling) but describes a mount, not a host.
describe('rack devices', () => {
beforeEach(() => {
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_RACK] })
})
it('filters them into their own section', async () => {
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Rack devices' }))
expect(screen.getByTestId('pending-card-dev-rack')).toBeInTheDocument()
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
})
it('refuses to approve one onto a logical canvas', async () => {
const { toast } = await import('sonner')
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-rack')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('pending-card-dev-rack'))
fireEvent.click(screen.getByTestId('do-approve'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('rack canvas')))
expect(mockApprove).not.toHaveBeenCalled()
})
it('drops them from a bulk approve and says how many', async () => {
const { toast } = await import('sonner')
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
fireEvent.click(screen.getByTestId('pending-card-dev-rack'))
fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ }))
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a'], null))
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('1 rack device'))
})
it('keeps rack gear in the rackable list — it is hardware', async () => {
render(<DeviceInventoryModal {...baseProps} initialRackableOnly />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-rack')).toBeInTheDocument())
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
})
it('approves nothing when the whole selection is rack gear', async () => {
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-rack')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-rack'))
fireEvent.click(screen.getByRole('button', { name: /Approve \(1\)/ }))
await waitFor(() => expect(mockBulkApprove).not.toHaveBeenCalled())
})
})
// The rack canvas opens this modal to pick a mount, so it needs a hardware
// filter and a way to hand one device back instead of approving it.
describe('rackable filter and picker mode', () => {
beforeEach(() => {
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_VM] })
})
it('drops virtual and mesh kinds when the filter is on', async () => {
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-vm')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Rackable' }))
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
expect(screen.queryByTestId('pending-card-dev-vm')).not.toBeInTheDocument()
})
it('starts filtered when the caller asks for it', async () => {
render(<DeviceInventoryModal {...baseProps} initialRackableOnly />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.queryByTestId('pending-card-dev-vm')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Rackable' })).toHaveAttribute(
'aria-pressed',
'true',
)
})
it('re-arms the filter when reopened after the user turned it off', async () => {
const { rerender } = render(<DeviceInventoryModal {...baseProps} initialRackableOnly />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Rackable' }))
expect(screen.getByTestId('pending-card-dev-vm')).toBeInTheDocument()
rerender(<DeviceInventoryModal {...baseProps} open={false} initialRackableOnly />)
rerender(<DeviceInventoryModal {...baseProps} initialRackableOnly />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.queryByTestId('pending-card-dev-vm')).not.toBeInTheDocument()
})
it('hands the card back instead of opening the approval modal', async () => {
const onPick = vi.fn()
render(<DeviceInventoryModal {...baseProps} onPick={onPick} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
expect(onPick).toHaveBeenCalledWith(expect.objectContaining({ id: 'dev-a' }))
expect(screen.queryByTestId('approval-modal')).not.toBeInTheDocument()
})
it('hides the bulk and clear controls a picker has no business offering', async () => {
render(<DeviceInventoryModal {...baseProps} onPick={vi.fn()} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.queryByRole('button', { name: 'Select mode' })).not.toBeInTheDocument()
expect(screen.queryByTitle(/Clear all pending/)).not.toBeInTheDocument()
// …and the keyboard shortcut cannot bring select mode back either.
fireEvent.keyDown(window, { key: 's' })
expect(screen.queryByRole('button', { name: /Approve \(/ })).not.toBeInTheDocument()
})
})
})
/**
* 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' })
})
})
@@ -1,8 +1,8 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { PendingDeviceModal, type PendingDevice } from '../PendingDeviceModal'
import { InventoryDeviceModal, type InventoryEntry } from '../InventoryDeviceModal'
function makeDevice(overrides: Partial<PendingDevice> = {}): PendingDevice {
function makeDevice(overrides: Partial<InventoryEntry> = {}): InventoryEntry {
return {
id: 'dev-1',
ip: '192.168.1.100',
@@ -17,19 +17,19 @@ function makeDevice(overrides: Partial<PendingDevice> = {}): PendingDevice {
}
}
describe('PendingDeviceModal', () => {
describe('InventoryDeviceModal', () => {
// ── Visibility ────────────────────────────────────────────────────────────
it('renders nothing when device is null', () => {
const { container } = render(
<PendingDeviceModal device={null} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={null} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('renders dialog when device is provided', () => {
render(
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByRole('dialog')).toBeDefined()
})
@@ -38,7 +38,7 @@ describe('PendingDeviceModal', () => {
it('shows hostname as title when available', () => {
render(
<PendingDeviceModal device={makeDevice({ hostname: 'myserver.local' })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={makeDevice({ hostname: 'myserver.local' })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
// hostname appears in both title and info row — check at least one match
expect(screen.getAllByText('myserver.local').length).toBeGreaterThan(0)
@@ -46,7 +46,7 @@ describe('PendingDeviceModal', () => {
it('falls back to IP as title when hostname is null', () => {
render(
<PendingDeviceModal device={makeDevice({ hostname: null })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={makeDevice({ hostname: null })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
// IP appears in both title and info row when hostname is absent
expect(screen.getAllByText('192.168.1.100').length).toBeGreaterThan(0)
@@ -54,28 +54,28 @@ describe('PendingDeviceModal', () => {
it('shows IP address', () => {
render(
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('192.168.1.100')).toBeDefined()
})
it('shows MAC address when present', () => {
render(
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('aa:bb:cc:dd:ee:ff')).toBeDefined()
})
it('shows OS when present', () => {
render(
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('Linux')).toBeDefined()
})
it('does not show hostname row when hostname is null', () => {
render(
<PendingDeviceModal device={makeDevice({ hostname: null })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={makeDevice({ hostname: null })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
// "Hostname" label should not appear in the info rows
expect(screen.queryByText('Hostname')).toBeNull()
@@ -83,7 +83,7 @@ describe('PendingDeviceModal', () => {
it('shows suggested type when present', () => {
render(
<PendingDeviceModal device={makeDevice({ suggested_type: 'proxmox' })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={makeDevice({ suggested_type: 'proxmox' })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('proxmox')).toBeDefined()
})
@@ -92,7 +92,7 @@ describe('PendingDeviceModal', () => {
it('shows "No services detected" when services list is empty', () => {
render(
<PendingDeviceModal device={makeDevice({ services: [] })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={makeDevice({ services: [] })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('No services detected')).toBeDefined()
})
@@ -105,7 +105,7 @@ describe('PendingDeviceModal', () => {
],
})
render(
<PendingDeviceModal device={device} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={device} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('Services found (2)')).toBeDefined()
expect(screen.getByText('HTTP')).toBeDefined()
@@ -119,7 +119,7 @@ describe('PendingDeviceModal', () => {
services: [{ port: 8006, protocol: 'tcp', service_name: 'Proxmox Web', category: 'hypervisor' }],
})
render(
<PendingDeviceModal device={device} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={device} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('hypervisor')).toBeDefined()
})
@@ -131,7 +131,7 @@ describe('PendingDeviceModal', () => {
const onApprove = vi.fn()
const onClose = vi.fn()
render(
<PendingDeviceModal device={device} onClose={onClose} onApprove={onApprove} onHide={vi.fn()} onIgnore={vi.fn()} />
<InventoryDeviceModal device={device} onClose={onClose} onApprove={onApprove} onHide={vi.fn()} onIgnore={vi.fn()} />
)
fireEvent.click(screen.getByRole('button', { name: 'Approve' }))
expect(onApprove).toHaveBeenCalledWith(device)
@@ -143,7 +143,7 @@ describe('PendingDeviceModal', () => {
const onHide = vi.fn()
const onClose = vi.fn()
render(
<PendingDeviceModal device={device} onClose={onClose} onApprove={vi.fn()} onHide={onHide} onIgnore={vi.fn()} />
<InventoryDeviceModal device={device} onClose={onClose} onApprove={vi.fn()} onHide={onHide} onIgnore={vi.fn()} />
)
fireEvent.click(screen.getByRole('button', { name: 'Hide' }))
expect(onHide).toHaveBeenCalledWith(device)
@@ -155,10 +155,37 @@ describe('PendingDeviceModal', () => {
const onIgnore = vi.fn()
const onClose = vi.fn()
render(
<PendingDeviceModal device={device} onClose={onClose} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={onIgnore} />
<InventoryDeviceModal device={device} onClose={onClose} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={onIgnore} />
)
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(onIgnore).toHaveBeenCalledWith(device)
expect(onClose).toHaveBeenCalledOnce()
})
})
describe('InventoryDeviceModal — rack gear', () => {
const rackDevice = makeDevice({
id: 'dev-rack',
hostname: 'patch-house',
ip: null,
discovery_source: 'rack',
discovery_sources: ['rack'],
})
it('offers no Approve button — rack gear never lands on a logical canvas', () => {
render(
<InventoryDeviceModal device={rackDevice} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull()
// Hide and Delete still apply: the entry is inventory like any other.
expect(screen.getByRole('button', { name: 'Hide' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument()
})
it('keeps Approve for a scanned device', () => {
render(
<InventoryDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByRole('button', { name: 'Approve' })).toBeInTheDocument()
})
})
@@ -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')
@@ -14,7 +14,7 @@ vi.mock('@/api/client', () => ({
scanApi: { pending: vi.fn().mockResolvedValue({ data: [] }) },
}))
const mockOnOpenPending = vi.fn()
const mockOnOpenInventory = vi.fn()
function makeNode(id: string, overrides: Partial<NodeData> = {}): Node<NodeData> {
return {
@@ -38,23 +38,23 @@ describe('SearchModal', () => {
})
it('renders nothing when closed', () => {
render(<SearchModal open={false} onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open={false} onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
expect(screen.queryByPlaceholderText(/search nodes/i)).toBeNull()
})
it('renders search input when open', () => {
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
expect(screen.getByPlaceholderText(/search nodes/i)).toBeDefined()
})
it('shows "Type to search" hint when query is empty', () => {
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
expect(screen.getByText(/type to search/i)).toBeDefined()
})
it('shows no results message when query has no matches', () => {
useCanvasStore.setState({ nodes: [makeNode('router', { label: 'Router' })] })
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zzz' } })
expect(screen.getByText(/no results match/i)).toBeDefined()
})
@@ -63,7 +63,7 @@ describe('SearchModal', () => {
useCanvasStore.setState({
nodes: [makeNode('n1', { label: 'My Router' }), makeNode('n2', { label: 'NAS Server' })],
})
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'router' } })
expect(screen.getByText('My Router')).toBeDefined()
expect(screen.queryByText('NAS Server')).toBeNull()
@@ -76,7 +76,7 @@ describe('SearchModal', () => {
makeNode('n2', { label: 'Box B', ip: '10.0.0.1' }),
],
})
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: '192.168' } })
expect(screen.getByText('Box A')).toBeDefined()
expect(screen.queryByText('Box B')).toBeNull()
@@ -89,7 +89,7 @@ describe('SearchModal', () => {
makeNode('n2', { label: 'B', hostname: 'nas.local' }),
],
})
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'pve' } })
expect(screen.getByText('A')).toBeDefined()
expect(screen.queryByText('B')).toBeNull()
@@ -102,7 +102,7 @@ describe('SearchModal', () => {
makeNode('g1', { label: 'Zone A', type: 'groupRect' }),
],
})
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zone' } })
expect(screen.getByText(/no results match/i)).toBeDefined()
})
@@ -111,7 +111,7 @@ describe('SearchModal', () => {
useCanvasStore.setState({
nodes: Array.from({ length: 12 }, (_, i) => makeNode(`n${i}`, { label: `Server ${i}` })),
})
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'server' } })
const items = screen.getAllByText(/Server \d/)
expect(items).toHaveLength(6)
@@ -120,7 +120,7 @@ describe('SearchModal', () => {
it('selects node and closes on result click', () => {
const onClose = vi.fn()
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Proxmox' })] })
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={onClose} onOpenInventory={mockOnOpenInventory} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'prox' } })
fireEvent.click(screen.getByText('Proxmox'))
expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
@@ -131,7 +131,7 @@ describe('SearchModal', () => {
it('selects first result and closes on Enter key', () => {
const onClose = vi.fn()
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Switch' })] })
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={onClose} onOpenInventory={mockOnOpenInventory} />)
const input = screen.getByPlaceholderText(/search nodes/i)
fireEvent.change(input, { target: { value: 'switch' } })
fireEvent.keyDown(input, { key: 'Enter' })
@@ -141,14 +141,14 @@ describe('SearchModal', () => {
it('closes on Escape key', () => {
const onClose = vi.fn()
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={onClose} onOpenInventory={mockOnOpenInventory} />)
fireEvent.keyDown(screen.getByPlaceholderText(/search nodes/i), { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
})
it('closes when clicking backdrop', () => {
const onClose = vi.fn()
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={onClose} onOpenInventory={mockOnOpenInventory} />)
// The backdrop is the fixed inset div — clicking it fires onClose
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement
fireEvent.click(backdrop)
@@ -157,14 +157,14 @@ describe('SearchModal', () => {
it('does not close when clicking inside the search box', () => {
const onClose = vi.fn()
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={onClose} onOpenInventory={mockOnOpenInventory} />)
fireEvent.click(screen.getByPlaceholderText(/search nodes/i))
expect(onClose).not.toHaveBeenCalled()
})
it('search is case-insensitive', () => {
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'My NAS' })] })
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
render(<SearchModal open onClose={vi.fn()} onOpenInventory={mockOnOpenInventory} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'MY NAS' } })
expect(screen.getByText('My NAS')).toBeDefined()
})
@@ -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', () => {
+49 -258
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'
@@ -7,7 +7,7 @@ import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore'
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type ServiceStatus, type NodeData, type NodeProperty } from '@/types'
import { getServiceUrl } from '@/utils/serviceUrl'
import { splitIps } from '@/utils/maskIp'
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
import { PropertyList } from '@/components/common/PropertyList'
import { formatTimestamp } from '@/utils/timeFormat'
import { ServiceModal } from '@/components/modals/ServiceModal'
import { serviceToForm, type ServiceFormData, type ServiceSubmitData } from '@/utils/serviceForm'
@@ -16,15 +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 }
type PropForm = { key: string; value: string; icon: string | null; visible: boolean }
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
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)
@@ -32,13 +32,6 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
const [groupName, setGroupName] = useState('')
const [creatingGroup, setCreatingGroup] = useState(false)
// Properties state
const [addingProp, setAddingProp] = useState(false)
const [newProp, setNewProp] = useState<PropForm>(EMPTY_PROP)
const [editingPropIndex, setEditingPropIndex] = useState<number | null>(null)
const [editProp, setEditProp] = useState<PropForm>(EMPTY_PROP)
const [dragPropIndex, setDragPropIndex] = useState<number | null>(null)
const [dragOverPropIndex, setDragOverPropIndex] = useState<number | null>(null)
const [dragSvcIndex, setDragSvcIndex] = useState<number | null>(null)
const [dragOverSvcIndex, setDragOverSvcIndex] = useState<number | null>(null)
@@ -112,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] })
@@ -125,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
@@ -139,6 +134,16 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
if (openSvcModal?.index === index) setSvcModal(null)
}
// Per node, not per device: the same device drawn on two canvases can show a
// service on one and hide it on the other. Only the order and the flag are the
// node's — the service itself still belongs to the inventory row.
const handleToggleService = (index: number) => {
snapshotHistory()
updateNode(node.id, {
services: services.map((svc, i) => (i === index ? { ...svc, visible: svc.visible === false } : svc)),
})
}
const handleStartEdit = (index: number) => {
const svc = services[index]
if (!svc) return
@@ -157,56 +162,9 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
// --- Property handlers ---
const properties: NodeProperty[] = data.properties ?? []
const handleAddProp = () => {
if (!newProp.key.trim() || !newProp.value.trim()) return
const handleChangeProps = (next: NodeProperty[]) => {
snapshotHistory()
const prop: NodeProperty = { key: newProp.key.trim(), value: newProp.value.trim(), icon: newProp.icon, visible: newProp.visible }
updateNode(node.id, { properties: [...properties, prop] })
setNewProp(EMPTY_PROP)
setAddingProp(false)
}
const handleRemoveProp = (index: number) => {
snapshotHistory()
updateNode(node.id, { properties: properties.filter((_, i) => i !== index) })
if (editingPropIndex === index) setEditingPropIndex(null)
}
const handleTogglePropVisible = (index: number) => {
snapshotHistory()
updateNode(node.id, {
properties: properties.map((p, i) => i === index ? { ...p, visible: !p.visible } : p),
})
}
const handleStartEditProp = (index: number) => {
const p = properties[index]
if (!p) return
setEditProp({ key: p.key, value: p.value, icon: p.icon, visible: p.visible })
setEditingPropIndex(index)
setAddingProp(false)
}
const handleSaveEditProp = () => {
if (editingPropIndex === null || !editProp.key.trim() || !editProp.value.trim()) return
snapshotHistory()
updateNode(node.id, {
properties: properties.map((p, i) =>
i === editingPropIndex
? { key: editProp.key.trim(), value: editProp.value.trim(), icon: editProp.icon, visible: editProp.visible }
: p
),
})
setEditingPropIndex(null)
}
const handleReorderProp = (from: number, to: number) => {
if (from === to || from < 0 || to < 0 || from >= properties.length || to >= properties.length) return
snapshotHistory()
const reordered = [...properties]
const [moved] = reordered.splice(from, 1)
reordered.splice(to, 0, moved)
updateNode(node.id, { properties: reordered })
updateNode(node.id, { properties: next })
}
return (
@@ -264,6 +222,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 */}
@@ -274,64 +242,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
/>
{/* Properties section */}
<div className="px-4 py-3 border-t border-border">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground">Properties{properties.length > 0 ? ` (${properties.length})` : ''}</span>
<button
onClick={() => { setAddingProp((v) => !v); setEditingPropIndex(null) }}
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer"
>
<Plus size={10} /> Add
</button>
</div>
{addingProp && (
<PropertyForm
form={newProp}
onChange={setNewProp}
onConfirm={handleAddProp}
onCancel={() => { setAddingProp(false); setNewProp(EMPTY_PROP) }}
confirmLabel="Add"
/>
)}
{properties.length > 0 && (
<div className="flex flex-col gap-1.5">
{properties.map((prop, i) =>
editingPropIndex === i ? (
<PropertyForm
key={`edit-${i}`}
form={editProp}
onChange={setEditProp}
onConfirm={handleSaveEditProp}
onCancel={() => setEditingPropIndex(null)}
confirmLabel="Save"
/>
) : (
<PropertyBadge
key={`${prop.key}-${i}`}
prop={prop}
draggable={properties.length > 1}
isDragging={dragPropIndex === i}
isDragOver={dragOverPropIndex === i && dragPropIndex !== i}
onDragStart={() => setDragPropIndex(i)}
onDragEnter={() => { if (dragPropIndex !== null) setDragOverPropIndex(i) }}
onDragEnd={() => { setDragPropIndex(null); setDragOverPropIndex(null) }}
onDrop={() => {
if (dragPropIndex !== null) handleReorderProp(dragPropIndex, i)
setDragPropIndex(null)
setDragOverPropIndex(null)
}}
onToggleVisible={() => handleTogglePropVisible(i)}
onEdit={() => handleStartEditProp(i)}
onRemove={() => handleRemoveProp(i)}
/>
)
)}
</div>
)}
{properties.length === 0 && !addingProp && (
<p className="text-[10px] text-muted-foreground/50">No properties — click Add to define one.</p>
)}
</div>
<PropertyList properties={properties} onChange={handleChangeProps} />
<div className="px-4 py-3 border-t border-border">
<div className="flex items-center justify-between mb-2">
@@ -347,7 +258,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}
@@ -359,6 +270,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
setDragSvcIndex(null)
setDragOverSvcIndex(null)
}}
onToggleVisible={() => handleToggleService(i)}
onEdit={() => handleStartEdit(i)}
onRemove={() => handleRemoveService(i)}
/>
@@ -689,82 +601,14 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
)
}
// --- Property components ---
function PropertyForm({ form, onChange, onConfirm, onCancel, confirmLabel }: {
form: PropForm
onChange: (f: PropForm) => void
onConfirm: () => void
onCancel: () => void
confirmLabel: string
}) {
return (
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
<Input
value={form.key}
onChange={(e) => onChange({ ...form, key: e.target.value })}
placeholder="Label (e.g. CPU Model)"
className="bg-[#21262d] border-[#30363d] text-xs h-7"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
/>
<Input
value={form.value}
onChange={(e) => onChange({ ...form, value: e.target.value })}
placeholder="Value (e.g. i7-12700K)"
className="bg-[#21262d] border-[#30363d] text-xs h-7"
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
/>
{/* Icon picker */}
<div className="flex flex-wrap gap-1 pt-0.5">
<button
onClick={() => onChange({ ...form, icon: null })}
title="No icon"
className={`w-6 h-6 rounded flex items-center justify-center text-[10px] border transition-colors ${
form.icon === null ? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]' : 'border-[#30363d] text-muted-foreground hover:border-[#8b949e]'
}`}
>
–
</button>
{PROPERTY_ICON_NAMES.map((name) => {
const Icon = PROPERTY_ICONS[name]
const active = form.icon === name
return (
<button
key={name}
onClick={() => onChange({ ...form, icon: name })}
title={name}
className={`w-6 h-6 rounded flex items-center justify-center border transition-colors ${
active ? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]' : 'border-[#30363d] text-muted-foreground hover:border-[#8b949e]'
}`}
>
{createElement(Icon, { size: 11 })}
</button>
)
})}
</div>
{/* Visible toggle */}
<label className="flex items-center gap-2 cursor-pointer pt-0.5">
<input
type="checkbox"
checked={form.visible}
onChange={(e) => onChange({ ...form, visible: e.target.checked })}
className="accent-[#00d4ff] w-3 h-3"
/>
<span className="text-[10px] text-muted-foreground">Show on node</span>
</label>
<div className="flex gap-1.5">
<Button size="sm" className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={onConfirm}>
{confirmLabel}
</Button>
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button>
</div>
</div>
)
const CATEGORY_COLORS: Record<string, string> = {
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
}
function PropertyBadge({ prop, draggable, isDragging, isDragOver, onDragStart, onDragEnter, onDragEnd, onDrop, onToggleVisible, onEdit, onRemove }: {
prop: NodeProperty
function ServiceBadge({ svc, host, status, draggable, isDragging, isDragOver, onDragStart, onDragEnter, onDragEnd, onDrop, onToggleVisible, onEdit, onRemove }: {
svc: ServiceInfo
host?: string
status?: ServiceStatus
draggable: boolean
isDragging: boolean
isDragOver: boolean
@@ -775,69 +619,6 @@ function PropertyBadge({ prop, draggable, isDragging, isDragOver, onDragStart, o
onToggleVisible: () => void
onEdit: () => void
onRemove: () => void
}) {
const Icon = resolvePropertyIcon(prop.icon)
return (
<div
draggable={draggable}
onDragStart={onDragStart}
onDragEnter={onDragEnter}
onDragOver={(e) => e.preventDefault()}
onDragEnd={onDragEnd}
onDrop={(e) => { e.preventDefault(); onDrop() }}
className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors"
style={{
background: '#21262d',
borderColor: isDragOver ? '#00d4ff' : '#30363d',
opacity: isDragging ? 0.4 : 1,
}}
>
<div className="flex items-center gap-1.5 min-w-0">
{draggable && (
<span className="shrink-0 cursor-grab active:cursor-grabbing text-[#8b949e] hover:text-[#00d4ff]" title="Drag to reorder">
{createElement(GripVertical, { size: 11 })}
</span>
)}
{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>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={onToggleVisible}
title={prop.visible ? 'Hide on node' : 'Show on node'}
className="text-[#8b949e] hover:text-[#00d4ff] transition-colors"
>
{prop.visible ? <Eye size={10} /> : <EyeOff size={10} />}
</button>
<button onClick={onEdit} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff]" title="Edit property">
<Pencil size={10} />
</button>
<button onClick={onRemove} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149]" title="Remove property">
<X size={10} />
</button>
</div>
</div>
)
}
const CATEGORY_COLORS: Record<string, string> = {
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
}
function ServiceBadge({ svc, host, status, draggable, isDragging, isDragOver, onDragStart, onDragEnter, onDragEnd, onDrop, onEdit, onRemove }: {
svc: ServiceInfo
host?: string
status?: ServiceStatus
draggable: boolean
isDragging: boolean
isDragOver: boolean
onDragStart: () => void
onDragEnter: () => void
onDragEnd: () => void
onDrop: () => void
onEdit: () => void
onRemove: () => void
}) {
const url = getServiceUrl(svc, host)
// Manually-added services carry no category, so they fell back to grey even
@@ -846,6 +627,8 @@ function ServiceBadge({ svc, host, status, draggable, isDragging, isDragOver, on
// A live offline service overrides the category colour with red.
const color = status === 'offline' ? '#f85149' : categoryColor
const pathLabel = svc.path?.trim() ? svc.path.trim() : ''
// Absent means shown: a service only carries the flag once it has been hidden.
const shown = svc.visible !== false
return (
<div
@@ -924,6 +707,14 @@ function ServiceBadge({ svc, host, status, draggable, isDragging, isDragOver, on
<span className="w-2.5" />
)}
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onToggleVisible() }}
className="text-[#8b949e] hover:text-[#00d4ff] ml-0.5 cursor-pointer transition-colors"
title={shown ? 'Hide on node' : 'Show on node'}
>
{shown ? <Eye size={10} /> : <EyeOff size={10} />}
</button>
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }}
className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5 cursor-pointer"
@@ -0,0 +1,134 @@
/**
* Rack-specific middle group of the header toolbar.
*
* Replaces the logical canvas actions (auto layout, YAML import/export) with the
* ones a rack canvas actually has: adding racks, patching, and controlling how
* much of the cabling is on screen.
*/
import { useState } from 'react'
import { Cable, Eye, EyeOff, Link2, MousePointer2, Plus, Trash2, X } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useRackStore } from '@/rack/store'
import { useRackPalette } from '@/rack/rackTheme'
import { loadNetworkLinks } from '@/rack/networkLinks'
import { useDesignStore } from '@/stores/designStore'
import type { CableVisibility } from '@/types'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const ghost =
'gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]'
/** Reads as one of the ghost buttons around it: no box until you hover it. */
const triggerClass =
'gap-1.5 border-transparent bg-transparent px-2 text-xs font-medium text-muted-foreground hover:bg-[#21262d] hover:text-foreground dark:bg-transparent dark:hover:bg-[#21262d] cursor-pointer'
const VISIBILITY_OPTIONS: { value: CableVisibility; label: string; icon: typeof Eye }[] = [
{ value: 'hover', label: 'Cables on hover', icon: MousePointer2 },
{ value: 'always', label: 'Cables always', icon: Eye },
{ value: 'hidden', label: 'Cables hidden', icon: EyeOff },
]
export function RackToolbarActions() {
const palette = useRackPalette()
const addRack = useRackStore((s) => s.addRack)
const cableMode = useRackStore((s) => s.cableMode)
const toggleCableMode = useRackStore((s) => s.toggleCableMode)
const cableVisibility = useRackStore((s) => s.cableVisibility)
const setCableVisibility = useRackStore((s) => s.setCableVisibility)
const cableDraft = useRackStore((s) => s.cableDraft)
const cancelCableDraft = useRackStore((s) => s.cancelCableDraft)
const selectedCableId = useRackStore((s) => s.selectedCableId)
const removeSelectedCable = useRackStore((s) => s.removeSelectedCable)
const importCables = useRackStore((s) => s.importCablesFromNetwork)
const designs = useDesignStore((s) => s.designs)
const [importing, setImporting] = useState(false)
async function handleImport() {
setImporting(true)
try {
const hints = STANDALONE ? [] : await loadNetworkLinks(designs)
const created = importCables(hints)
toast[created > 0 ? 'success' : 'info'](
created > 0
? `${created} cable${created > 1 ? 's' : ''} imported from the network canvas`
: 'No matching link found — rack the devices first',
)
} finally {
setImporting(false)
}
}
return (
<>
<Button size="sm" variant="ghost" className={ghost} onClick={() => addRack({ style: palette.defaultRackStyle })}>
<Plus size={14} /> Rack
</Button>
<Button
size="sm"
variant="ghost"
className={`${ghost} ${cableMode ? 'text-[#00d4ff]' : ''}`}
onClick={toggleCableMode}
title="Drag from one port to another to patch, or click both in turn. Click a cable to select it, then Delete to unplug."
>
<Cable size={14} /> {cableMode ? 'Exit patching' : 'Patch'}
</Button>
{cableMode && cableDraft && (
<Button
size="sm"
variant="ghost"
className={`${ghost} text-[#e3b341]`}
onClick={cancelCableDraft}
>
<X size={14} /> Cancel cable
</Button>
)}
{cableMode && selectedCableId && (
<Button
size="sm"
variant="ghost"
className={`${ghost} text-[#f85149]`}
onClick={removeSelectedCable}
title="Unplug the selected cable (Delete)"
>
<Trash2 size={14} /> Unplug
</Button>
)}
<Select value={cableVisibility} onValueChange={(v) => setCableVisibility(v as CableVisibility)}>
<SelectTrigger size="sm" className={triggerClass} aria-label="Cable visibility">
<SelectValue>
{(() => {
const { icon: Icon, label } = VISIBILITY_OPTIONS.find((o) => o.value === cableVisibility)!
return <><Icon size={14} /> {label}</>
})()}
</SelectValue>
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{VISIBILITY_OPTIONS.map(({ value, icon: Icon, label }) => (
<SelectItem key={value} value={value} className="text-xs">
<Icon size={14} /> {label}
</SelectItem>
))}
</SelectContent>
</Select>
{!STANDALONE && (
<Button
size="sm"
variant="ghost"
className={ghost}
disabled={importing}
onClick={() => void handleImport()}
title="One-shot: derive patches from the links already drawn on the logical canvases"
>
<Link2 size={14} /> Import links
</Button>
)}
</>
)
}
+88 -28
View File
@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect } from 'react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, RadioTower, Server, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, RadioTower, Server, Type, PlusCircle, Pencil, Trash2, Rows3 } from 'lucide-react'
import { Logo } from '@/components/ui/Logo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore'
@@ -9,6 +9,9 @@ import { authApi, designsApi, mediaApi } from '@/api/client'
import * as standaloneStorage from '@/utils/standaloneStorage'
import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal'
import { AccessoryTray } from '@/rack/components/AccessoryTray'
import { useRackStore } from '@/rack/store'
import { freeUnits } from '@/rack/layout'
import type { Design } from '@/types'
import { toast } from 'sonner'
import { useLatestRelease } from '@/hooks/useLatestRelease'
@@ -31,21 +34,25 @@ interface SidebarProps {
onSave: () => void
onOpenSettings: () => void
onOpenHistory: () => void
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
onOpenInventory: (deviceId?: string, status?: 'pending' | 'hidden') => void
}
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onZwaveImport, onProxmoxImport, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) {
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onZwaveImport, onProxmoxImport, onSave, onOpenSettings, onOpenHistory, onOpenInventory }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false)
const logout = useAuthStore((s) => s.logout)
const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
const { designs, activeDesignId, activeDesignType, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
const isRack = activeDesignType === 'rack'
const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false)
const [designModal, setDesignModal] = useState<{ mode: 'create' | 'edit'; design?: Design } | null>(null)
// Bumped on every open so the modal remounts and re-seeds its local state from
// the current floor plan — otherwise a reopen keeps stale width/height/lock
// and Save would clobber canvas-side resize/move.
const [openSeq, setOpenSeq] = useState(0)
const { nodes, hasUnsavedChanges, floorMap, setFloorMap } = useCanvasStore()
const { nodes, hasUnsavedChanges: canvasDirty, floorMap, setFloorMap } = useCanvasStore()
const floorMapEditNonce = useCanvasStore((s) => s.floorMapEditNonce)
const rackDirty = useRackStore((s) => s.hasUnsavedChanges)
const openDeviceEditor = useRackStore((s) => s.openDeviceEditor)
const hasUnsavedChanges = isRack ? rackDirty : canvasDirty
const handleLogout = useCallback(async () => {
try {
@@ -72,9 +79,10 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
? standaloneStorage.copyDesign(data.sourceId, data.name, data.icon)
: (await designsApi.copy(data.sourceId, { name: data.name, icon: data.icon })).data
} else {
const designType = data.designType ?? 'network'
created = STANDALONE
? standaloneStorage.createDesign(data.name, data.icon)
: (await designsApi.create({ name: data.name, icon: data.icon })).data
? standaloneStorage.createDesign(data.name, data.icon, designType)
: (await designsApi.create({ name: data.name, icon: data.icon, design_type: designType })).data
}
addDesign(created)
} else if (designModal.design) {
@@ -164,6 +172,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
<div className="px-2 pt-2 pb-1 border-b border-border relative">
<button
onClick={() => setDesignSwitcherOpen((o) => !o)}
data-tour="canvas-switcher"
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-xs font-medium bg-[#21262d] border border-border hover:border-[#30363d] transition-colors cursor-pointer"
>
{activeDesignId ? (() => {
@@ -231,8 +240,8 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
{/* Views */}
<nav className="flex flex-col gap-0.5 p-2">
<SidebarItem
icon={LayoutDashboard}
label="Canvas"
icon={isRack ? Rows3 : LayoutDashboard}
label={isRack ? 'Rack view' : 'Canvas'}
collapsed={collapsed}
active
/>
@@ -242,7 +251,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
icon={t.icon}
label={t.label}
collapsed={collapsed}
onClick={() => onOpenPending(undefined, t.kind)}
onClick={() => onOpenInventory(undefined, t.kind)}
/>
))}
{!STANDALONE && (
@@ -255,10 +264,13 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
)}
</nav>
{!collapsed && <div className="flex-1" />}
{/* The rack inventory is the sidebar's body: it scrolls, everything else
stays pinned. On a logical canvas the space is just a spacer. */}
{isRack && !collapsed ? <AccessoryTray /> : !collapsed && <div className="flex-1" />}
{/* Stats footer — hidden in standalone (no scan / live status to count) */}
{!collapsed && !STANDALONE && (
{!collapsed && isRack && <RackStats />}
{!collapsed && !isRack && !STANDALONE && (
<div className="px-3 py-2 border-t border-border text-xs text-muted-foreground space-y-0.5">
<div className="flex justify-between">
<span>Total</span>
@@ -277,16 +289,22 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
{/* Actions */}
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} dataTour="add-node" />
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} dataTour="add-zone" />
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} dataTour="add-text" />
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} dataTour="scan-network" />}
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} dataTour="zigbee-import" />}
{!STANDALONE && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} dataTour="zwave-import" />}
{!STANDALONE && <SidebarItem icon={Server} label="Proxmox Import" collapsed={collapsed} onClick={onProxmoxImport} dataTour="proxmox-import" />}
{isRack ? (
<SidebarItem icon={Plus} label="Add Device" collapsed={collapsed} onClick={() => openDeviceEditor()} />
) : (
<>
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} dataTour="add-node" />
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} dataTour="add-zone" />
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} dataTour="add-text" />
</>
)}
{!STANDALONE && !isRack && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} dataTour="scan-network" />}
{!STANDALONE && !isRack && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} dataTour="zigbee-import" />}
{!STANDALONE && !isRack && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} dataTour="zwave-import" />}
{!STANDALONE && !isRack && <SidebarItem icon={Server} label="Proxmox Import" collapsed={collapsed} onClick={onProxmoxImport} dataTour="proxmox-import" />}
<SidebarItem
icon={Save}
label="Save Canvas"
label={isRack ? 'Save Rack' : 'Save Canvas'}
collapsed={collapsed}
onClick={() => onSave()}
badge={hasUnsavedChanges}
@@ -333,20 +351,62 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
)
}
/** Rack equivalent of the node stats footer: capacity rather than reachability. */
function RackStats() {
const racks = useRackStore((s) => s.racks)
const devices = useRackStore((s) => s.devices)
const cables = useRackStore((s) => s.cables)
const free = racks.reduce((sum, rack) => sum + freeUnits(rack, devices), 0)
const total = racks.reduce((sum, rack) => sum + rack.uHeight, 0)
return (
<div className="px-3 py-2 border-t border-border text-xs text-muted-foreground space-y-0.5">
<div className="flex justify-between">
<span>Racks</span>
<span className="text-foreground font-mono">{racks.length}</span>
</div>
<div className="flex justify-between">
<span>Mounted</span>
<span className="text-foreground font-mono">{devices.length}</span>
</div>
<div className="flex justify-between">
<span>Cables</span>
<span className="text-foreground font-mono">{cables.length}</span>
</div>
<div className="flex justify-between">
<span className="text-[#39d353]">Free</span>
<span className="font-mono text-[#39d353]">{free}U / {total}U</span>
</div>
</div>
)
}
function VersionBadge() {
const current = __APP_VERSION__
const { latest, hasUpdate } = useLatestRelease(current)
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 : '#'}
+44 -24
View File
@@ -3,6 +3,9 @@ import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Tab
import { Button } from '@/components/ui/button'
import { Logo } from '@/components/ui/Logo'
import { useCanvasStore } from '@/stores/canvasStore'
import { useDesignStore } from '@/stores/designStore'
import { useRackStore } from '@/rack/store'
import { RackToolbarActions } from './RackToolbarActions'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
@@ -21,7 +24,10 @@ interface ToolbarProps {
}
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd, onExportYaml, onImportYaml, onViewOnly }: ToolbarProps) {
const { hasUnsavedChanges, past, future } = useCanvasStore()
const { hasUnsavedChanges: canvasDirty, past, future } = useCanvasStore()
const isRack = useDesignStore((s) => s.activeDesignType) === 'rack'
const rackDirty = useRackStore((s) => s.hasUnsavedChanges)
const hasUnsavedChanges = isRack ? rackDirty : canvasDirty
const fileInputRef = useRef<HTMLInputElement>(null)
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
@@ -40,25 +46,33 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
<header className="flex items-center gap-2 px-4 py-2 border-b border-border bg-[#161b22] shrink-0">
<Logo size={28} showText={true} />
<div className="flex-1" />
<Button
size="sm" variant="ghost"
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30 cursor-pointer hover:bg-[#21262d]"
onClick={onUndo}
disabled={past.length === 0}
title="Undo (Ctrl+Z)"
>
<Undo2 size={14} />
</Button>
<Button
size="sm" variant="ghost"
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30 cursor-pointer hover:bg-[#21262d]"
onClick={onRedo}
disabled={future.length === 0}
title="Redo (Ctrl+Y)"
>
<Redo2 size={14} />
</Button>
{/* Undo/redo are canvas history; the rack canvas has none yet. */}
{!isRack && (
<>
<Button
size="sm" variant="ghost"
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30 cursor-pointer hover:bg-[#21262d]"
onClick={onUndo}
disabled={past.length === 0}
title="Undo (Ctrl+Z)"
>
<Undo2 size={14} />
</Button>
<Button
size="sm" variant="ghost"
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30 cursor-pointer hover:bg-[#21262d]"
onClick={onRedo}
disabled={future.length === 0}
title="Redo (Ctrl+Y)"
>
<Redo2 size={14} />
</Button>
</>
)}
<div className="w-px h-4 bg-border mx-1" />
{isRack && <RackToolbarActions />}
{!isRack && (
<>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onAutoLayout}>
<LayoutDashboard size={14} /> Auto Layout
</Button>
@@ -78,15 +92,21 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportYaml} title="Export canvas as YAML">
<Download size={14} /> Export
</Button>
</>
)}
{/* PNG capture is DOM-based, so it works for both canvas kinds. */}
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExport} title="Download canvas as PNG">
<FileDown size={14} /> PNG
</Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportMd} title="Copy inventory as Markdown table">
<Table2 size={14} /> MD
</Button>
{!isRack && (
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportMd} title="Copy inventory as Markdown table">
<Table2 size={14} /> MD
</Button>
)}
{/* Live view reads backend/localStorage canvas; pointless in standalone
where the editor already shows the only (localStorage) copy. */}
{!STANDALONE && (
where the editor already shows the only (localStorage) copy. Rack
canvases have no live view yet. */}
{!STANDALONE && !isRack && (
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onViewOnly} title="Open read-only live view of this canvas">
<Eye size={14} /> View
</Button>
@@ -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' }
@@ -506,6 +546,64 @@ describe('DetailPanel', () => {
expect(payload.services.map((s: { service_name: string }) => s.service_name)).toEqual(['C', 'A', 'B'])
})
it('hides a service on this node without touching the service itself', () => {
// Visibility is the node's answer, not the device's: the rest of the
// service record goes back unchanged so other canvases keep drawing it.
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockImplementation(
((sel?: (s: Record<string, unknown>) => unknown) => {
const state = {
nodes: [makeNode({ services: [{ port: 3001, protocol: 'tcp' as const, service_name: 'Uptime Kuma' }] })],
selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
setNodeSize: vi.fn(),
serviceStatuses: {},
}
return sel ? sel(state) : state
}) as unknown as typeof canvasStore.useCanvasStore,
)
render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByTitle('Hide on node'))
expect(updateNode.mock.calls[0][1].services).toEqual([
{ port: 3001, protocol: 'tcp', service_name: 'Uptime Kuma', visible: false },
])
})
it('shows a hidden service again', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockImplementation(
((sel?: (s: Record<string, unknown>) => unknown) => {
const state = {
nodes: [makeNode({
services: [{ port: 3001, protocol: 'tcp' as const, service_name: 'Uptime Kuma', visible: false }],
})],
selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
setNodeSize: vi.fn(),
serviceStatuses: {},
}
return sel ? sel(state) : state
}) as unknown as typeof canvasStore.useCanvasStore,
)
render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByTitle('Show on node'))
expect(updateNode.mock.calls[0][1].services[0].visible).toBe(true)
})
it('is not draggable with a single service', () => {
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'A' }] })
render(<DetailPanel onEdit={vi.fn()} />)
@@ -744,7 +842,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 +851,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()
})
})
@@ -0,0 +1,68 @@
/**
* Rack header actions. The Unplug button is the visible half of cable
* selection — the keyboard shortcut alone would be undiscoverable.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { RackToolbarActions } from '../RackToolbarActions'
import { useRackStore } from '@/rack/store'
vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
beforeEach(() => {
useRackStore.getState().loadDemo()
})
const unplug = () => screen.queryByRole('button', { name: /unplug/i })
describe('RackToolbarActions cable visibility', () => {
it('shows the active visibility on the trigger', () => {
useRackStore.getState().setCableVisibility('always')
render(<RackToolbarActions />)
expect(screen.getByLabelText('Cable visibility')).toHaveTextContent('Cables always')
})
it('applies the picked visibility to the store', async () => {
// base-ui commits its selection on real pointer sequences, not a bare click.
const user = userEvent.setup({ pointerEventsCheck: 0 })
render(<RackToolbarActions />)
await user.click(screen.getByLabelText('Cable visibility'))
await user.click(await screen.findByRole('option', { name: 'Cables hidden' }))
expect(useRackStore.getState().cableVisibility).toBe('hidden')
})
it('carries no cable type filter — copper and fibre always draw together', () => {
render(<RackToolbarActions />)
expect(screen.queryByLabelText('Cable type filter')).toBeNull()
expect(screen.queryByText('All types')).toBeNull()
expect('cableTypeFilter' in useRackStore.getState()).toBe(false)
})
})
describe('RackToolbarActions cable selection', () => {
it('hides Unplug outside patch mode', () => {
useRackStore.getState().selectCable(useRackStore.getState().cables[0].id)
render(<RackToolbarActions />)
expect(unplug()).toBeNull()
})
it('hides Unplug in patch mode while no cable is selected', () => {
useRackStore.getState().toggleCableMode()
render(<RackToolbarActions />)
expect(unplug()).toBeNull()
})
it('unplugs the selected cable', () => {
useRackStore.getState().toggleCableMode()
const cable = useRackStore.getState().cables[0]
useRackStore.getState().selectCable(cable.id)
render(<RackToolbarActions />)
fireEvent.click(unplug()!)
expect(useRackStore.getState().cables.some((c) => c.id === cable.id)).toBe(false)
expect(unplug()).toBeNull()
})
})
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'
import { Sidebar } from '../Sidebar'
import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
@@ -78,7 +78,7 @@ const defaultProps = {
onSave: vi.fn(),
onOpenSettings: vi.fn(),
onOpenHistory: vi.fn(),
onOpenPending: vi.fn(),
onOpenInventory: vi.fn(),
}
// ── Tests ─────────────────────────────────────────────────────────────────────
@@ -244,16 +244,16 @@ describe('Sidebar', () => {
// ── Pending / Hidden open modal ────────────────────────────────────────────
it('calls onOpenPending with pending status when Device Inventory is clicked', () => {
it('calls onOpenInventory with pending status when Device Inventory is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Device Inventory'))
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'pending')
expect(defaultProps.onOpenInventory).toHaveBeenCalledWith(undefined, 'pending')
})
it('calls onOpenPending with hidden status when Hidden Devices is clicked', () => {
it('calls onOpenInventory with hidden status when Hidden Devices is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Hidden Devices'))
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'hidden')
expect(defaultProps.onOpenInventory).toHaveBeenCalledWith(undefined, 'hidden')
})
it('calls onOpenHistory when Scan History nav item is clicked', () => {
@@ -284,6 +284,122 @@ describe('Sidebar', () => {
expect(mockLogout).toHaveBeenCalledOnce()
})
})
// ── Walkthrough anchor ─────────────────────────────────────────────────────
it('carries the walkthrough anchor the rack tour step spotlights', async () => {
const { useDesignStore } = await import('@/stores/designStore')
const { STEPS } = await import('@/walkthrough/steps')
const before = useDesignStore.getState()
useDesignStore.setState({
designs: [{
id: 'd1', name: 'Main', design_type: 'network', icon: null,
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
}],
activeDesignId: 'd1',
loaded: true,
})
const { unmount } = render(<Sidebar {...defaultProps} />)
const step = STEPS.find((s) => s.id === 'rack')
expect(step?.anchor).toBe('[data-tour="canvas-switcher"]')
// The overlay resolves that selector against the DOM — an anchor the sidebar
// stops carrying leaves the step spotlighting nothing.
expect(document.querySelector(step!.anchor!)).toBeInTheDocument()
// Unmount before restoring, so the store reset never re-renders the sidebar.
unmount()
useDesignStore.setState({
designs: before.designs,
activeDesignId: before.activeDesignId,
loaded: before.loaded,
})
})
})
// ── Rack canvas ────────────────────────────────────────────────────────────────
// Same component, a different half of it: the rack branch swaps the view label,
// the actions, the body and the footer. A mocked canvas store would happily hide
// all of that, so each branch is asserted rather than assumed.
describe('Sidebar (rack canvas)', () => {
let restore: { designs: Design[]; activeDesignId: string | null; activeDesignType: string }
beforeEach(async () => {
mockStore()
mockAuth()
const { useDesignStore } = await import('@/stores/designStore')
const { useRackStore } = await import('@/rack/store')
const s = useDesignStore.getState()
restore = { designs: s.designs, activeDesignId: s.activeDesignId, activeDesignType: s.activeDesignType }
useDesignStore.setState({ activeDesignType: 'rack' })
useRackStore.getState().loadDemo()
})
afterEach(async () => {
cleanup()
const { useDesignStore } = await import('@/stores/designStore')
useDesignStore.setState(restore)
})
it('swaps the view and the actions for their rack equivalents', () => {
render(<Sidebar {...defaultProps} />)
expect(screen.getByText('Rack view')).toBeInTheDocument()
expect(screen.getByText('Add Device')).toBeInTheDocument()
expect(screen.getByText('Save Rack')).toBeInTheDocument()
expect(screen.queryByText('Canvas')).not.toBeInTheDocument()
expect(screen.queryByText('Add Node')).not.toBeInTheDocument()
expect(screen.queryByText('Add Zone')).not.toBeInTheDocument()
expect(screen.queryByText('Add Text')).not.toBeInTheDocument()
})
it('hides the logical-canvas discovery actions', () => {
render(<Sidebar {...defaultProps} />)
// Nothing here is scanned onto a rack — those actions belong to a diagram.
expect(screen.queryByText('Scan Network')).not.toBeInTheDocument()
expect(screen.queryByText('Zigbee Import')).not.toBeInTheDocument()
expect(screen.queryByText('Z-Wave Import')).not.toBeInTheDocument()
expect(screen.queryByText('Proxmox Import')).not.toBeInTheDocument()
// The Device Inventory stays: a rack mounts what the scans found.
expect(screen.getByText('Device Inventory')).toBeInTheDocument()
})
it('opens the device editor from + Device', async () => {
const { useRackStore } = await import('@/rack/store')
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Add Device'))
expect(useRackStore.getState().deviceEditor).toEqual({ deviceId: null })
})
it('counts capacity instead of reachability', async () => {
const { useRackStore } = await import('@/rack/store')
const { freeUnits } = await import('@/rack/layout')
const { racks, devices, cables } = useRackStore.getState()
const free = racks.reduce((sum, r) => sum + freeUnits(r, devices), 0)
const total = racks.reduce((sum, r) => sum + r.uHeight, 0)
render(<Sidebar {...defaultProps} />)
expect(screen.getByText('Racks').nextElementSibling).toHaveTextContent(String(racks.length))
expect(screen.getByText('Mounted').nextElementSibling).toHaveTextContent(String(devices.length))
expect(screen.getByText('Cables').nextElementSibling).toHaveTextContent(String(cables.length))
expect(screen.getByText(`${free}U / ${total}U`)).toBeInTheDocument()
expect(screen.queryByText('Online')).not.toBeInTheDocument()
})
it('tracks the rack store for the unsaved badge, not the canvas store', async () => {
const { useRackStore } = await import('@/rack/store')
useRackStore.setState({ hasUnsavedChanges: true })
// The canvas store — mocked clean — must not be what answers here.
const { container } = render(<Sidebar {...defaultProps} />)
expect(container.querySelector('.bg-\\[\\#e3b341\\]')).toBeInTheDocument()
cleanup()
useRackStore.setState({ hasUnsavedChanges: false })
const { container: clean } = render(<Sidebar {...defaultProps} />)
expect(clean.querySelector('.bg-\\[\\#e3b341\\]')).toBeNull()
})
})
// ── Standalone mode ────────────────────────────────────────────────────────────

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