Commit Graph
343 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 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 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 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 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 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 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
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 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 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 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 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 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 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 c4017c7634 feat(services): brand icon picker and dedicated add/edit modal
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
2026-07-28 22:00:45 +02:00
Pouzor 34758c9850 fix(ui): use official Homelable logo on login page
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
2026-07-25 00:02:39 +02:00
ypso fd96b7d6ae feat(auth): add OIDC browser session flow 2026-07-21 00:36:02 +02:00
Marianne JOSEPH 084a8e23e7 feat(detail-panel): make services list draggable to reorder
Mirrors the existing properties drag-and-drop (native HTML5 drag)
so services can be reordered the same way.
2026-07-19 21:59:16 +02:00
Pouzor 121873f41c test(settings): wait for async config before asserting Save payload
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.
2026-07-19 15:34:19 +02:00
Pouzor 5726289b18 fix(toaster): enable richColors so error surface actually applies
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.
2026-07-19 11:44:47 +02:00
Pouzor e85dab8cfc fix(toaster): render error toasts on a red surface
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.
2026-07-19 11:33:54 +02:00
Pouzor 8c56921375 feat(walkthrough): add Getting Started tour with first-run invite
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
2026-07-19 00:40:08 +02:00
Pouzor af3dc018c7 feat(properties): drag to reorder node properties
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
2026-07-18 01:51:57 +02:00
Pouzor - Rémy JardientandGitHub 79bd0d4efb Merge pull request #294 from Floyddotnet/main
feat: search for nodes and properties
2026-07-18 01:26:17 +02:00
Pouzor a364a95cb5 fix(search): clean up node/property search matches
- 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
2026-07-18 01:17:36 +02:00
Peter Becker 9000f82213 add search for nodes and properties 2026-07-17 23:00:20 +02:00
Pouzor 2f91ca3dfd feat(canvas): widen zone modal into two columns
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
2026-07-17 21:57:07 +02:00
Nicola Bottini a7b7327ce5 feat: autosave canvas after configurable inactivity delay
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.
2026-07-16 12:22:18 -04:00
Pouzor d3728f4108 fix: widen Settings modal to two columns; env cleanup
- SettingsModal: widen to sm:max-w-3xl with max-h-[90vh] scroll and a
  two-column grid so it no longer overflows the viewport. Left column =
  status/service checks + canvas prefs; right column groups all auto-sync
  config (Zigbee, Z-Wave, Proxmox).
- .env.example: drop ZIGBEE_/ZWAVE_ SYNC_ENABLED + SYNC_INTERVAL — the
  auto-sync activation is configured in the Settings modal (persisted to
  scan_config.json), same as Proxmox; connection config stays env-only.

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

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

Manual Zigbee/Z-Wave import behaviour is unchanged.

ha-relevant: no
2026-07-10 14:51:01 +02:00
Pouzor 075f7f6d78 test: dedup sonner mocks and close Toolbar seam-mock gaps
- Replace 10 inline sonner toast mocks with the shared mockSonner() builder.
- Toolbar: add tests for the store-driven undo/redo disabled state and the
  unsaved-changes dot — behaviour the full-store mock previously left
  unasserted.

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

Same 1396 tests, all green.

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

Migrate ScanConfigModal sonner mock as canary. No behavior change.

ha-relevant: yes
2026-07-10 02:15:49 +02:00
Pouzor 0792105b96 feat: prompt on duplicate device instead of silently blocking/merging approve
Single-device approve now guards duplicates per-design the same way bulk
approve does, and asks the user instead of failing or silently merging:

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

Closes #260

ha-relevant: maybe
2026-07-09 13:05:19 +02:00
Pouzor b09ebb5cd2 fix: keep approved devices in inventory after bulk approve
Bulk/single approve optimistically stripped approved rows from the local list,
but scanApi.pending() still returns them (on-canvas, with an 'In N canvas'
badge). The list went empty until the modal was reopened. Reload after approve
instead of stripping, so approved rows stay visible with a fresh canvas_count.

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

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

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

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

Closes #216

ha-relevant: maybe
2026-07-07 15:53:42 +02:00
Pouzor adf82f8f01 feat: merge IP-scanned and Proxmox-imported devices by MAC
Reconcile the same physical device discovered by both the nmap IP scan and
the Proxmox importer into a single inventory row, keyed on MAC. Previously
each path only deduped by IP, and the importer captured no MAC (and no IP for
stopped guests), so most guests double-listed.

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

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

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

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

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

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

ha-relevant: maybe
2026-07-06 11:13:32 +02:00