Commit Graph
838 Commits
Author SHA1 Message Date
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
v3.2.0
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