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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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.
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
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
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.
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.
Services can now carry a brand logo (homarr dashboard-icons) or a generic
lucide icon, the same way nodes already could — closes the request in
discussion #299 where services stand in for docker images.
The add/edit form moves out of the right-hand DetailPanel into a proper
ServiceModal, which gives the icon picker room to breathe and matches how
nodes are edited.
- ServiceModal: name, port, protocol, path, icon; validates the name and
clamps the port, and spreads over the original service on save so fields
it doesn't own (category) survive an edit
- IconPickerPanel: Generic/Brand picker extracted from NodeModal so both
surfaces share one implementation
- ServiceIcon: renders a lucide or brand:<slug> icon in the DetailPanel
badge and on the canvas node, and nothing when the key can't be resolved
- Legacy scanner signature keys (bar-chart-2) still don't resolve to an
icon, but the modal shows them verbatim and preserves them on save
ServiceInfo.icon already existed and the backend stores services as
list[Any], so no schema or migration change.
ha-relevant: yes
The login page rendered lucide's generic Network glyph instead of the
brand mark. Use the Logo component there, and realign Logo with the
canonical assets in docs/logo:
- match icon.svg geometry exactly (bg glow gradient, node glow filter)
- fix inverted wordmark colors (Home #e6edf3 / lable #00d4ff)
- scope gradient/filter ids per instance so the Sidebar and Toolbar
logos don't share defs ids
ha-relevant: maybe
The Proxmox/Zigbee auto-sync Save tests clicked Save after only
waiting for an unrelated field, so under CI load getConfig could
resolve after the click and Save fired with the default
sync_enabled=false. Wait for the toggle to reflect the loaded
state first.
Setting --error-bg alone had no effect: sonner only honors per-type
surfaces when richColors is enabled, so error toasts kept using
--normal-bg and looked identical to a normal toast. Enable richColors
and pin success/info/warning back to the neutral popover surface so only
errors turn red.
Error toasts previously used the same neutral popover background as
success toasts, so a failure (e.g. a save while the backend is down)
read like a normal 'Canvas saved'. Set sonner's per-type --error-bg/
--error-text/--error-border so errors stand out in red.
Bespoke spotlight walkthrough (no new deps) that guides new users through the
main features. A version-stamped invite bubble offers the tour to new users and
re-offers it once to existing users after an upgrade; restartable from Settings.
- Multi-hole SVG-mask overlay keeps a ringed control and any open modal lit
- Steps: scan, scan history, inventory, nodes, edit, text/zone, grouping,
style, imports, and a closing recap with a GitHub link
- Scan/history/inventory/import steps inject demo data (no backend calls) and
are filtered out in standalone, which ends on a full-mode recap
- Persisted invite state via localStorage (utils/walkthrough.ts), live tour
state via zustand (stores/walkthroughStore.ts)
ha-relevant: maybe
Add native HTML5 drag-and-drop reorder to the property list in the
detail panel. A grip handle appears when more than one property exists;
dragging updates property order (persisted with canvas save) and is
undoable via history snapshot.
Closes#292
ha-relevant: yes
- add tests for notes, property key/value, hidden-property exclusion, matched-value display
- fix flatMap indentation and stray semicolon
- use stable match key instead of display_value (avoids dup-key collisions)
- keep label searchable but not repeated in matched-values column
The add/edit zone modal grew tall enough to overflow the viewport.
Apply the same layout as NodeModal: widen to sm:max-w-3xl and split
fields into a Content column (label, font, text/label position, text
size) and a Style column (colors, border style/width, z-order).
ha-relevant: yes
Adds an opt-in autosave feature (disabled by default) that silently
saves the canvas after a configurable period of inactivity.
- frontend/src/utils/autosaveSettings.ts (new) — localStorage-backed
setting {enabled, delay} with pub/sub CustomEvent pattern (mirrors
alignmentSettings.ts). Key: homelable.autosave.
- frontend/src/App.tsx:
* handleSave accepts optional { silent?: boolean } — skips the success
toast when called from autosave to avoid noise on every periodic save
* autosave state (useState + subscribeAutosaveSettings)
* debounce useEffect: resets on nodes/edges change; when quiet for
autosave.delay seconds and hasUnsavedChanges is true, calls
handleSaveRef.current silently
- frontend/src/components/modals/SettingsModal.tsx:
* Canvas section: Autosave canvas toggle + Save after delay selector
(3/5/10/30/60 s). Changes persist immediately to localStorage via
writeAutosaveSettings + propagate cross-tab via CustomEvent.
Default: disabled — no behaviour change for existing users.
Manual Ctrl+S and the Save button continue to work normally.
Errors always show a toast regardless of the silent flag.
- SettingsModal: widen to sm:max-w-3xl with max-h-[90vh] scroll and a
two-column grid so it no longer overflows the viewport. Left column =
status/service checks + canvas prefs; right column groups all auto-sync
config (Zigbee, Z-Wave, Proxmox).
- .env.example: drop ZIGBEE_/ZWAVE_ SYNC_ENABLED + SYNC_INTERVAL — the
auto-sync activation is configured in the Settings modal (persisted to
scan_config.json), same as Proxmox; connection config stays env-only.
ha-relevant: no
Mirror the Proxmox auto-sync pattern for the Zigbee2MQTT and Z-Wave JS
UI mesh imports. Connection config + MQTT credentials live in .env only,
are never persisted to scan_config.json, and are never returned by any
API or shown in the UI (single source of truth).
- config: ZIGBEE_* / ZWAVE_* env settings; only sync_enabled+interval
are persisted, connection/credentials stay env-only
- routes: GET/POST /config, POST /sync-now; auto-sync reuses the exact
manual _background_*_import + _persist_pending_import path (fresh
import when empty, update-in-place when nodes exist, ScanRun trace)
- scheduler: zigbee_sync / zwave_sync jobs with live enable + reschedule
- frontend: reusable MeshAutoSync section in Settings (Zigbee, Z-Wave)
- .env.example: documented both blocks
- tests: scheduler jobs, router config/sync-now/auth, credential-never-
persisted, SettingsModal sections
Manual Zigbee/Z-Wave import behaviour is unchanged.
ha-relevant: no
- Replace 10 inline sonner toast mocks with the shared mockSonner() builder.
- Toolbar: add tests for the store-driven undo/redo disabled state and the
unsaved-changes dot — behaviour the full-store mock previously left
unasserted.
ha-relevant: yes
- Split the 1430-line canvasStore.test.ts into a canvasStore/ subpackage by
concern (nodes, containers, sizing, edges, selection, grouping, history,
clipboard, customStyle, floorMap); each file <350 lines. Test bodies are
unchanged; local makeNode/makeEdge replaced by the shared factories.
- Move panels/DetailPanel.test.ts -> utils/serviceUrl.test.ts: it tested
getServiceUrl, not DetailPanel.
Same 1396 tests, all green.
ha-relevant: yes
Single-device approve now guards duplicates per-design the same way bulk
approve does, and asks the user instead of failing or silently merging:
- create_node and approve_device reject a same-design duplicate (ieee, ip
or mac) with 409 + the existing node; a force flag creates it anyway.
- Frontend shows a confirm dialog: go to existing node, add duplicate
anyway, or cancel.
- approve_device no longer rejects a device already on another canvas
(status is global, canvas membership is per-design) — it can be placed
on a new design, matching bulk approve.
- IEEE (Zigbee/Z-Wave) devices now use the same prompt as ip/mac instead
of auto-merging into the existing node.
- bulk approve reports which devices it skipped as duplicates.
Closes#260
ha-relevant: maybe
Bulk/single approve optimistically stripped approved rows from the local list,
but scanApi.pending() still returns them (on-canvas, with an 'In N canvas'
badge). The list went empty until the modal was reopened. Reload after approve
instead of stripping, so approved rows stay visible with a fresh canvas_count.
ha-relevant: yes
Add a 'Re-sync now' button to the Proxmox auto-sync settings section that
triggers an immediate inventory import (POST /proxmox/sync-now) using the
server env config — the manual counterpart to scheduled auto-sync.
Also fix a dual-source-of-truth bug: Proxmox connection config (host, port,
token, verify_tls) is now env-only and never persisted to scan_config.json.
Previously save_overrides() dumped host/port/verify alongside scan settings,
so saving an unrelated setting wrote an empty proxmox_host that load_overrides
then clobbered PROXMOX_HOST with on every boot. Only the auto-sync activation
(sync_enabled + sync_interval) stays user-editable and persisted.
ha-relevant: no
Add a 'Copy from existing' option to the New Canvas modal. It lists every
canvas with node/group/text counts; picking one deep-copies its nodes, edges,
parent/child links and canvas state (viewport, custom style, floor plan) into
a fresh design.
Backend: POST /designs/{source_id}/copy remaps node ids, re-points edges and
parent links, and clones canvas state; GET /designs now returns per-design
counts for the picker. Standalone mode clones the localStorage canvas.
Closes#216
ha-relevant: maybe
Reconcile the same physical device discovered by both the nmap IP scan and
the Proxmox importer into a single inventory row, keyed on MAC. Previously
each path only deduped by IP, and the importer captured no MAC (and no IP for
stopped guests), so most guests double-listed.
Backend:
- mac_utils.normalize_mac: canonical MAC (lowercase, ':'-separated), the
cross-source join key. Normalized on write and on compare.
- proxmox_service: capture the guest NIC MAC agent-free from the net0 config
(qemu virtio=<MAC>, lxc hwaddr=<MAC>); works for stopped guests. Resolver
now returns (ip, mac).
- proxmox persist: match existing Node/PendingDevice by ieee OR ip OR MAC;
fill mac, keep the vm/lxc type, union sources.
- scanner persist: match PendingDevice by ip OR MAC; fill the IP a Proxmox
import lacked, keep a pve row's type, union the scan source. Stamp query
matches raw + normalized MAC (legacy-safe).
- Multi-source tags: new PendingDevice.discovery_sources JSON column so a
merged device shows under both the IP and Proxmox filters. Idempotent
migration backfills from discovery_source (legacy NULL-scalar rows with an
IP become ["arp"]). _sources_after_merge preserves a scanned row's IP origin
through the merge without tagging a pure Proxmox guest.
- Import now broadcasts a scan update on completion so an open inventory
reloads without a manual refresh.
Frontend:
- pendingSources: sourceBuckets/orderedSources map discovery_sources to filter
buckets; a device with ["arp","proxmox"] matches both filters and renders
both badges. PendingDevicesModal filter + badges use them.
Tests: MAC normalization, config MAC capture, cross-source merge both
directions, legacy-row IP-tag preservation, no-false-IP-tag guard, refresh
broadcast, and the frontend bucket mapping.
ha-relevant: maybe
Cluster edges created via the pending -> approve path rendered on the top
handle instead of left/right, because the edge and its endpoints lost their
handle information on the way to the canvas.
- Approve resolver (scan.py) now returns each edge's type + source/target
handle. Handle IDs are the bare slot-0 side names ('right'/'left'), the
canonical stored form React Flow resolves to the correct side; a '-t' target
id fails to resolve and falls back to the top handle.
- Frontend injectAutoEdges no longer hardcodes iot/bottom/top-t. It injects
each edge with its real type + handles and bumps the referenced nodes'
left/right handle counts (which default to 0) so the cluster endpoints exist.
Logic extracted to a pure, tested util (applyAutoEdges).
- clusterEdges direct-import path uses the bare 'left' target to match.
Tests: new autoEdges unit tests; updated backend handle assertions.
ha-relevant: maybe