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
Scan History:
- Proxmox is now a first-class scan kind (badge, filter chip, Server icon,
completion toast) instead of being mislabeled as an IP scan.
- A done run carrying a non-fatal advisory renders amber (info) with a
warning toast, distinct from red failures.
Import diagnostics:
- test-connection probes /access/permissions and warns when the API token
has no ACL (VMs/LXC would be invisible) — points at the PVEAuditor grant.
- import surfaces an advisory when hosts import but no guests are visible
(privilege-separated token whose rights are the intersection with the user),
rather than a silent "done".
Node style:
- Proxmox container mode is now opt-in (container_mode === true), matching the
rest of the codebase (App.tsx nesting logic). Imported proxmox nodes leave
the flag unset and render like a manually-created node instead of an empty
group container.
Cluster edges:
- Hosts from one import are chained with 'cluster' edges via left/right handles,
distinct from the vertical host->guest 'virtual' edges. Wired for both the
direct "Add to Canvas" path and the pending -> approve path (host<->host
proxmox_cluster links, resolved to cluster edges on approve; cluster hosts
get left/right handles).
Tests added on both sides.
ha-relevant: maybe
Add a Proxmox VE importer that reads the /api2/json REST API with a read-only
API token and drops hosts (proxmox), VMs (vm) and LXC containers (lxc) onto the
canvas as typed nodes with run state and hardware specs (vCPU/RAM/disk).
- Backend: proxmox_service (httpx) + proxmox routes (test-connection, import,
import-pending, config). Two-tier dedupe — merge onto an existing scanned node
by IP, else synthetic pve-{host}-{vmid} identity. Update-in-place, never
deletes. Host->guest rendered as a 'virtual' edge via the pending-link flow.
- Security: token is env-only (PROXMOX_TOKEN_*), never written to disk by the
app, never returned by any endpoint; errors are credential-sanitized.
- Auto-sync: optional scheduled re-import into pending (APScheduler job).
- PendingDevice.properties carries specs through approve (+ migration).
- Frontend: ProxmoxImportModal, sidebar entry, pending inventory source filter,
Settings auto-sync section, proxmoxApi client.
- Docs: docs/proxmox-import.md, README + FEATURES sections, .env.example keys.
- Tests: backend service/router/scheduler, frontend modal/client/pending.
ha-relevant: maybe
Edge render (solid/dashed/dotted) and stroke width were hardcoded per
edge type. Expose both as user settings.
- Custom Style modal (Edges): line-style buttons, 1-4x width slider,
live preview; left-list swatch renders the actual line.
- Per-edge EdgeModal: same controls; line style follows the type preset
live until overridden.
- Renderer applies line_style/width_mult over BASE_STYLES (width scales
markers + animation overlays); unset keeps the type default look.
- Persist line_style/width_mult through serializer, canvas save, and the
edges API (new nullable columns, idempotent migration).
ha-relevant: yes
Add optional filled-triangle arrowheads at either end of an edge,
independently toggleable per edge (EdgeModal) and as per-edge-type
defaults (CustomStyleModal). Arrowheads are custom inline <marker> defs
filled with the live stroke colour so they recolour reactively with
custom_color / vlan / selected state. Persisted frontend (serializer)
and backend (edge columns + schemas + runtime migration).
Also fix two dedupe layers that silently dropped legitimate parallel
links between the same two devices:
- store: React Flow addEdge() connectionExists dropped a second edge
with matching source+target when handles were null/equal. Build the
edge with a unique id and append directly.
- render: rewireEdgesForCollapse deduped ALL edges by src->tgt key even
when nothing was collapsed, filtering real parallel edges out of the
visible set. Restrict the anti-mesh dedupe to rewired collapse stubs.
Tests: marker render, per-edge/per-type UI, store apply, serializer
round-trip, backend edge/canvas persistence, parallel-edge regressions.
ha-relevant: yes
Adds a link under the colour swatches in the add/edit node modal that opens the
Custom Style editor with the node's type preselected, so a user can edit the
style for every node of that type without navigating Style -> Customize -> pick
type manually.
- CustomStyleModal: new optional `initialNodeType` prop; when set, the modal
opens straight into that node type's editor instead of the empty placeholder.
- NodeModal: new optional `onEditTypeStyle(type)` prop renders the shortcut link
(hidden for group/groupRect and when no handler is wired).
- App: `styleEditorType` state; both Add and Edit NodeModals forward the handler,
and a standalone CustomStyleModal instance opens preselected (stacked over the
node modal so in-progress node edits are preserved).
ha-relevant: yes
The overflow-hidden added to the BaseNode root (to tame the oversized 16px
NodeResizer handles) also clipped the connection handles, which sit centred on
the node edge with half their box outside it. CSS overflow:hidden disables
pointer events in the clipped region, so the outer half of every handle — the
side an edge approaches from — became non-interactive, halving the magnet area.
- Remove overflow-hidden from the BaseNode root so handles stay fully grabbable.
- Shrink the NodeResizer handles 16px -> 8px (rounded): the reason the clip was
added in the first place, so the resizer no longer looks oversized when zoomed
out without relying on clipping.
- Enlarge the invisible target (magnet) hit area 12px -> 20px.
- Set connectionRadius={30} on ReactFlow for stronger distance-based snapping.
ha-relevant: yes
Removing overflow-hidden from the BaseNode root un-clipped the NodeResizer
handles, making them huge (especially when zoomed out). Clipping was never what
made left/right connection points fail — that was backend persistence (fixed
separately) — and top/bottom handles have always worked while clipped. Restore
overflow-hidden so the resizer looks correct; side handles still work.
ha-relevant: yes
The Proxmox node had two always-on cluster-left/cluster-right handles that
rendered independently of the new per-side connection-point config — so a
configured side stacked on top of the forced handle (e.g. 0 config still showed
1; 2 config left the forced one in the middle).
- ProxmoxGroupNode: remove the always-on cluster handles in both container and
non-container modes; cluster links now use the normal per-side points
- add migrateClusterHandles(nodes, edges): on load, remap any edge still bound
to cluster-left/right onto the left/right slot-0 point and set that node's
side count to at least 1, so existing links survive (edge 'cluster' type/style
kept). Applied on API load, standalone load, LiveView, and YAML import
- App: new cluster links are made by choosing the Cluster edge type in the edge
modal (drop the cluster-handle auto-default)
ha-relevant: yes
- NodeModal: widen to max-w-3xl; split into Information (left) and Design
(right) columns with headers; Notes moved to left as a resizable textarea
- Connection Points: spatial cross of -/N/+ steppers around a node preview
(replaces the four stacked sliders)
- BaseNode: drop overflow-hidden from the node root so left/right handles are
no longer clipped to an ungrabbable sliver (inner sections keep their own
clipping; rounded corners preserved)
ha-relevant: yes
Generalize the bottom-only handle machinery to all four sides. Top/Bottom
keep their single-handle default; Left/Right are opt-in (default 0) so
existing diagrams render unchanged. Handle IDs stay keyed off the bare side
name at slot 0, keeping saved edges valid.
- handleUtils: side-generic API (handleId, handlePositions, clampHandles,
sideDefault, removedHandleIds, sideHandleCount); legacy bottom fns kept as
delegating aliases
- BaseNode + ProxmoxGroupNode render all sides via new shared SideHandles
- updateNode remaps edges on any side's shrink (fallback to slot-0 / bottom)
- serializer round-trips top/left/right_handles
- NodeModal: spatial cross of -/N/+ steppers around a node preview, replacing
the four stacked sliders; seeds per-type defaults
- CustomStyleModal: per-type default connection points per side
ha-relevant: yes
The role/type badge on each inventory tile was always flat grey
(bg-[#21262d] text-muted-foreground), which read as disabled and gave no
visual cue about the device kind. Colour it with the same per-type accent
the node uses on the canvas — resolved through the active theme / style
section via resolveNodeColors — so a zigbee_coordinator, zwave_router, etc.
gets its style-section colour (translucent background + solid text).
Applies to every source (IP, zigbee, zwave) since all node types carry an
accent in the theme.
Test: role badge renders with the node-type accent colour, not the grey
muted class.
ha-relevant: yes