Commit Graph
496 Commits
Author SHA1 Message Date
Pouzor 0ffece7a0d fix(inventory): widen the merge dialog so an IEEE is readable
The addresses are what the choice is made on, and `pve-proxmox-ai-113` was
clipped at the edge of a max-w-lg dialog. Wider, and the address line wraps
instead of truncating; the list grows with the viewport so a long selection
scrolls less.

ha-relevant: no
2026-09-07 17:05:05 +02:00
Pouzor 46dac1a89f feat(inventory): merge duplicate device rows, by hand or after an import
The inventory matched identity only when a row was created and never looked
again, so two rows describing one host — minted before either carried the
address that would have matched them — stayed separate forever. Documents,
racks and canvases then multiplied them.

Two repairs:

* `POST /scan/pending/merge` and a Merge action in the inventory's select
  mode: the user picks the survivor. The only way to collapse rows sharing
  no address at all, since a name is not an identity.
* `reconcile_duplicates`, run at the end of the Proxmox import and of a
  scan: collapses what a shared MAC proves. Never a shared IP alone, and
  never across two distinct IEEEs.

Merging keeps everything. The survivor holds every fact it had and fills its
gaps from the others; addresses, services, properties and sources union.
Canvas nodes, rack mounts, documents and mesh links are re-pointed before the
extras go, and each node's `display_view` gains the facts it never saw as
visible — a whitelist left alone would have rendered them hidden and made the
merge look like a no-op on the canvas.

Two bugs fell out on the way:

* `dedupe_nodes_by_device` deleted duplicate nodes while `rack_devices.node_id`
  and `documents.node_id` still named them. SQLite runs with foreign keys off,
  so the declared `ON DELETE SET NULL` never fired.
* The scanner's duplicate collapse deleted inventory rows outright, losing
  their facts and orphaning whatever drew them. It merges now; its guard for
  a second approved row is unchanged.

ha-relevant: maybe
2026-09-07 17:05:05 +02:00
Pouzor 40f4a705dc fix(canvas): keep the pan and zoom when coming back from Documentation
Opening Documentation unmounts the canvas, and React Flow remounts at the
default 1:1 viewport at the origin — the graph came back zoomed in and pushed
under the panels. fitView only runs on a fresh load, so nothing corrected it.

The store now remembers the viewport: every pan/zoom the user ends is saved,
so is wherever fitView lands, and the canvas mounts at that value. Switching
design still fits, since loadCanvas clears it.

ha-relevant: yes
2026-09-07 15:52:58 +02:00
Pouzor b40e2cc1db fix(docs): keep a document's URL across a refresh
Reflecting the section into the URL and reading it back on boot were two
effects, and the writer was declared first. It ran with the defaults —
canvas, no document — and deleted `view=docs&doc=` before the reader
looked, so refreshing or opening a shared link always landed on the
canvas.

Both sides now live in `useDocsUrlSync`. The URL's request is captured in
a lazy initializer at the first render, before any effect can touch the
address bar, and the writer stays quiet until that request has been
honoured — or until there was none to honour.

The restore also waits for the session, instead of fetching the document
against the login page, and is awaited so `doc=` is not dropped while it
loads. It settles on failure too: a document that no longer exists must
not freeze the URL, or the section could never be left. Standalone keeps
ignoring the params, since documents need the backend.

ha-relevant: maybe
2026-09-07 15:22:23 +02:00
Pouzor d7924d5ed0 feat(docs): write a wiki-link from a picker instead of from memory
`[[VLAN plan]]` only resolves if you remember the exact title or slug, and
nothing in the editor told you either. Typing `[[` now opens a picker at the
caret: filter, pick, and the finished link is written.

It offers documents only. A device with no document is not a link target — a
link at one renders red — and creating a document from inside an unsaved editor
is a different feature; a device that is documented is in the list through its
document, labelled as such.

A target is addressed by title, or as `[[doc:<id>]]` when another document
shares that title. A bare link resolves by title, so an ambiguous one would
silently point at whichever document came first.

The second bracket is swallowed on purpose. Opening the menu moves focus to its
filter field, and a character typed as focus moves is not reliably delivered to
either box, so the body holds exactly one `[` and that is what the insertion
replaces. Cancelling puts the second bracket back, so what was typed survives
whichever way the menu closes.

The popover itself is extracted as EditorMenu and the `/` insert menu now uses
it too — same filter field over a labelled list, placed at the caret and
measured before it is shown, and two copies of that would have drifted.

ha-relevant: no
2026-09-07 15:22:23 +02:00
Pouzor 5aa6dedce0 feat(docs): find documents from the canvas search
Ctrl/Cmd+K searched nodes and pending devices. Everything written about them
was invisible to it, so the only way to a document was to leave the canvas and
walk the tree — and the body, the part worth searching, was not reachable at all
from there.

Documents now appear under the two existing groups, with the snippet the index
matched on, and picking one switches to Documentation and opens it.

Unlike the other two halves this cannot filter a list in memory: the tree holds
document metadata and never bodies, on purpose. So it queries the server's
index, debounced and skipped under two characters, and an answer is kept with
the query it answered rather than in a bare list — a slow reply can no longer
appear under a query the user has moved on from. A failing index costs the
document rows and nothing else.

Standalone passes no handler, and without one nothing is requested and the
placeholder does not mention documents.

ha-relevant: no
2026-09-07 15:22:23 +02:00
Pouzor cf18521032 fix(docs): stop the template menu opening under the sidebar
The New document popover is 256px wide and right-aligned on a button at the
right edge of the document tree — a column that is about as wide, resizable,
and vertically scrolling. Its left edge therefore landed outside that column,
where two things went wrong at once: setting overflow-y makes the horizontal
axis clip as well, and whatever escaped was painted under the app sidebar. Half
the template names were unreadable.

The menu is portalled to the body and positioned in viewport coordinates taken
from the trigger, with its left edge clamped to the viewport, so no tree width
can push it off screen and no ancestor can clip it. Placement sets top or
bottom from the trigger's own edge.

Two consequences of leaving the component's subtree: the outside-click check
now tests the trigger and the menu, and the menu closes on scroll or resize
rather than floating on at coordinates that no longer mean anything.

ha-relevant: no
2026-09-07 15:22:23 +02:00
Pouzor 761a1a526c feat(docs): reach a document's history, and see what links to it
Two things the documentation section stored and never showed.

**History.** Up to 50 revisions per document have been written since the
section shipped — on every save, restore, regenerate, scaffold and the notes
migration — and nothing reached them. RegenerateDocModal went as far as
telling the user the old body "is in its history", which was true and useless:
there was no way there. The header now carries a history button; the rail lists
each version by what caused it, selecting one replaces the body in place, and
Changes turns it into a line diff against the current body. Restore snapshots
the body it replaces first, so a restore is itself undoable.

A revision's body is fetched only when it is opened — the list carries a size,
not the text, which is what RevisionSummary was shaped for.

The diff is a plain LCS over lines after the common head and tail are trimmed,
with unchanged runs collapsed to gaps: no library, and a pathological pair of
long unrelated bodies degrades to "replaced wholesale" rather than building a
quarter-million-cell matrix.

**Backlinks.** A wiki-link only says where it goes. Every document now lists
what points at it, with the line the link was written on and the label it was
written as; repeated links from one document collapse into a count.

The inversion is answered by the server, because the browser holds no body but
the open one — the list endpoint is metadata-only so the tree can badge without
downloading the space. doc_backlinks.py therefore mirrors the resolution rules
in wikilinks.ts, the way the generator's service_url mirrors serviceUrl.ts, and
its tests pin them. The inventory is loaded only when a body actually carries a
[[device:…]].

The client-side backlinkIndex goes with it. It was written, tested and never
rendered; keeping a second copy of the resolution rules on the side that cannot
see every body is only drift.

ha-relevant: no
2026-09-07 15:22:23 +02:00
Pouzor 94f42cf9c5 feat(docs): edit a document's tags, and say they exist
Group by Tag has always been there, and every document was Untagged: tags come
from the frontmatter `tags:` key, nothing but hand-typed YAML ever set one, and
no generated document mentioned the key. Both templates did pass `tags: []` —
`render_frontmatter` dropped every empty value on the way out, so the one place
a user could have learned tags exist never printed. An empty list prints now. An
empty string still does not: `owner:` teaches nothing the template already says.

The chips under the title become the editor. Each carries a cross, and a `+ Tag`
chip opens a field that takes a whole list — "web, prod" is two tags — and
refuses one the document already carries whatever its case. It writes straight
through like starring: a chip clicked off is not a draft of the document.

The body owns tags, the column caches them, so the store rewrites the block and
saves the body rather than patching a field the next body save would overwrite.
`withFrontmatter` would have done it in one line and round-tripped everything
else through the YAML dumper — `created: 2026-09-07` comes back a full timestamp,
because that is what the date parsed to. So `withTags` replaces that one entry,
inline or block list, and leaves the other lines byte for byte as written.

`test_frontmatter_omits_empty_values` asserted the dropping this changes; it is
renamed and now asserts the empty list survives.

ha-relevant: yes
2026-09-07 10:38:24 +02:00
Pouzor b910b03318 feat(inventory): open a device's document from its detail modal
The canvas' detail panel could reach a device's documentation; the inventory
detail — the sheet that owns what a device *is* — could not, though it is where
a user reads and curates the same facts. Its header gains the link beside Edit,
calling the entry point the canvas already uses: fetch the listing if the
section was never opened, open the document the device has, write one from its
facts when it has none.

The link only shows in view mode: while editing, the header belongs to the form,
and leaving a dirty draft for another view is not what that button should mean.

Both dialogs close on the way out. Documentation is a whole view, not something
to read underneath the list it was opened from, and reopening the inventory
later should not restore a detail the user has left.

Backend-only, like the rest of the document surface.

ha-relevant: yes
2026-09-07 10:38:24 +02:00
Pouzor f0b5175155 fix(docs): stop the frontmatter regex going quadratic on an open block
CodeQL, high: a polynomial regular expression on uncontrolled data. Both
frontmatter parsers fenced the block with `\s*`, and `\s` matches the newline
the fence line ends with — so the closing alternative was reachable two ways
and every added line multiplied the backtracking. The pathological input is
a body that opens `---` and never closes it, which is what a document looks
like for as long as the user is typing that block: 20k lines took 2.4s, and
the body is parsed on every save.

Fencing on `[ \t]*` instead leaves one way to reach the newline and the same
input takes no measurable time. A trailing space on the fence still parses;
what is no longer accepted is a fence followed by blank lines before the YAML,
which nothing writes.

Both copies, and a timing regression test each side.

ha-relevant: yes
2026-09-07 10:38:24 +02:00
Pouzor 6ba3d1a38f fix(docs): give the document editor a working undo
Ctrl+Z in the editor undid the canvas. The global handler computed whether the
event came from a field and then used that only to gate `?`, so undo fired
wherever the caret was — the document body, a node label, the filter box — and
took the canvas back a step while swallowing the keystroke the field wanted.
It now yields to whatever is being typed in. Which exposed a branch that had
never run: both undo and redo compared a lowercase `z`, and a shifted `z`
arrives as `Z`, so Ctrl+Shift+Z did nothing at all. It redoes now.

Handing the keystroke back is not enough on its own. The textarea is
controlled and half its edits are programmatic — the toolbar and the slash
menu rewrite the body around the caret — and setting a value that way is
exactly what empties the browser's own undo stack. So the editor keeps its
own: snapshots of body and selection, typing folded into one entry while it
keeps coming inside 500ms, an insertion always a step of its own, the redo
branch dropped once editing carries on past an undo, and the stack bounded.

ha-relevant: yes
2026-09-07 10:38:24 +02:00
Pouzor 56d83668d4 fix(docs): follow the title the body gives, in the row and in the tree
Editing `title:` in the frontmatter left the tree showing the name the
document was created with. `_apply_body` refreshed the frontmatter and tags
caches from the body and stopped there, though that line is the only place a
document is ever named — `rename` exists in the store and nothing in the UI
calls it. So the title column follows the body too, on every save and on a
restore, which brings back the name that body carried. It reslugs with it,
ignores a blank or non-string value, and an explicit title in the same request
still wins.

That alone would not have moved the row the user was looking at:
`buildDeviceTree` labelled a device leaf from the inventory whatever the
document said. Once a device is documented the row is that document, so it
carries the document's title, and falls back to the device's label only while
there is nothing to open.

ha-relevant: yes
2026-09-07 10:38:24 +02:00
Pouzor 7c116571e3 feat(canvas): open a node's document from its detail panel
A selected node linked to a device could reach its inventory row and nothing
else, though the same device now has a document too. The panel gains a second
link beside the first, and the store gains the action behind it: the canvas
knows a device id and nothing about documents, so `openForDevice` fetches the
listing if the section was never opened, opens the document the device has,
and writes one from its facts when it has none — the same thing picking that
device in the tree does, rather than switching to an empty section.

Which exposed what the inventory link had always done: open the modal, scroll
the card into view, and stop. "Open in inventory" means show me that device,
so the highlight now opens its detail modal as well. The id it honoured is
remembered, so a later filter change cannot reopen a detail the user closed,
and picker mode is left alone — it wants a device back, not a dialog stacked
on the one it was opened from. The scroll moved after the open and is called
optionally: a browser without `scrollIntoView` should not cost the caller the
modal it asked for.

Both links are backend-only, like the rest of the scan and document surface.

ha-relevant: yes
2026-09-07 10:38:24 +02:00
Pouzor b9ffa996c6 refactor(sidebar): drop the Hidden Devices shortcut
Hidden is a filter of the Device Inventory, not a place of its own, and the
modal already carries the Pending / Hidden toggle that switches between them.
A second sidebar entry for it spent a permanent nav slot on a view most users
open rarely, and made the two look like separate lists.

The one remaining trigger no longer needs a table to loop over, so
`PENDING_TRIGGERS` goes with it. `onOpenInventory` keeps its `status`
argument — the modal still opens straight into either filter.

ha-relevant: yes
2026-09-07 10:38:24 +02:00
Pouzor ed49c53969 feat(docs): put the Library first and let a drag file a document
The Library was the section below a device list hundreds of rows long, and
its two create buttons sat in a footer far from it. It is also the only part
of the tree the user owns — Devices is a pivot recomputed from the inventory,
so nothing there is filed by hand. So the Library moves to the top of the
pane, its heading becomes the collapse toggle, and the New document and New
folder buttons move up beside it. The footer keeps the coverage counter.

Filing was then the missing half: `parent_id` has always been settable and
`move` has always been in the store, with no UI reaching either. A Library row
now drags — by its icon, not the row, because a row-wide drag fights the click
that opens a document and the double-click that opens a folder. Folders take
the drop at any depth; the space around the list is a drop zone of its own,
which is the only way back out of a folder.

Every drop asks first, naming both ends, and the target folder is expanded
afterwards so the document does not vanish into a closed one.

`canMoveInto` decides the four rules the server already enforces — tree kinds
only, folders only as parents, no move to the parent it already has, no folder
into its own subtree — so an invalid drag refuses rather than asking, moving,
and failing on the response. `isDescendant` moves to `tree.ts` with it and is
re-exported from the store.

ha-relevant: yes
2026-09-07 10:38:24 +02:00
Pouzor 185d9bcacb feat(docs): regenerate a document, and only warn about real drift
A document is generated once and then owned by the user, which leaves no way
back when the generated header is what you actually wanted. The viewer gains a
Regenerate button behind a confirmation that says plainly the written body is
erased, names what it will be rebuilt from — the device's current facts, or the
page's template — and points at the one consolation: the replaced body is
snapshotted into the history first, so a regenerate is undoable from there.

`POST /documents/{id}/regenerate` re-runs the scaffolder, clears `edited_at`
so the document counts as "only the generated header" again, and refreshes
`facts_snapshot`. A folder has no generated body and is refused. The store
drops the localStorage draft with the body, so a stale edit cannot be saved
back over the new one.

Which surfaced the drift banner being wrong: it read "The device has changed"
on every device document, including one regenerated a second earlier. The
comparison ran in the UI, field by field, against the inventory wire shape —
and `facts_snapshot` is the server's shape, not that one. `properties` is a
flat `{key: value}` map in the snapshot and a `NodeProperty[]` on the wire, so
`(current ?? null) !== (value ?? null)` compared two objects by reference and
was true even when both were empty; `label` and `type` are stored through the
`device_name` / `device_type` fallbacks, so a device with no curated label
never matched its own snapshot either.

So the rule moves to the server, which is the only side that knows the shape:
`drifted` on the document summary, `doc.facts_snapshot != facts_snapshot(row)`,
the same comparison the coverage count already made. The listing resolves it
for every document in one query, which finally lights the tree's `drifted`
badge — `buildDeviceTree` has always taken the set and nothing ever passed it.

ha-relevant: yes
2026-09-07 10:38:24 +02:00
Pouzor d0f0fe0322 fix(docs): stop the tree clipping the last letter of every name
A device with no document yet is drawn in italic, and italic glyphs lean past
the advance width — the width a shrink-wrapped box gets. `truncate` puts
`overflow: hidden` on that box, so the overhang was cut off and every
undocumented name lost the tail of its last letter: "bazarr" and "glpi" ended
mid-stroke.

Padding on the label gives the overhang somewhere to live. Names that really
are too long still ellipsise, and now carry a `title` so they can be read on
hover instead of just being cut. Group headers get the same treatment.

The padding itself is invisible to a test; the title is not, and it is what
makes an over-long name readable at all, so that is what the new DocTree tests
pin — along with the italic rule that caused the clipping in the first place.

ha-relevant: yes
2026-09-07 10:38:24 +02:00
Pouzor bf0d8077bb fix(docs): open the insert menu at the caret, and insert where the slash is
The menu was `absolute bottom-4 left-4` — pinned to the bottom-left of the
source pane, not to the caret. Typing `/` halfway up a long document opened it
at the bottom of the screen, and as the query narrowed nothing the list still
grew downward off the viewport.

It is measured against the caret now. A textarea exposes no caret geometry, so
`caret.ts` uses the mirror technique: a hidden div carrying the same text
metrics and box, filled to the caret, with a marker span read back for its
position. `placeMenu` then puts the popup under that line, flips it above when
it would run past the bottom, and clamps it into the pane when it fits neither
way — so it can no longer end up off-screen whatever the list length or the
pane height. That maths is a pure function with its own tests; the measuring
itself is not testable, because jsdom lays nothing out and reports every offset
as 0.

The same trace turned up a worse bug behind it. `insertAtCursor` derived its
anchor from `el.selectionEnd` at the moment the command was clicked, then
walked back `slashQuery.length + 1` to swallow the "/query". But the query is
typed into the menu's own field and never enters the document, and opening the
menu moves focus out of the textarea — freezing its selection wherever it sat,
and widening the gap with every keystroke. Inserting `/task` on a line after
"Intro" produced "Intr- [ ] " with no query, and ate the line outright with
one: the user's text was deleted, silently.

The index of the `/` is recorded when the menu opens and passed to the insert,
which consumes exactly that one character. `runCommand` reads it before its
await, so the reset effect cannot clear it out from under the insert.

ha-relevant: yes
2026-09-07 10:38:24 +02:00
Pouzor 4ef0cdc73f fix(canvas): keep zone and group descriptions on save
A group's Description textarea wrote to `data.notes`, and everything typed in
it was lost on the next save, with no error.

3.3.0 moved the device facts off `nodes` onto `device_inventory`, `notes`
included, so the save routes `notes` to the inventory row. But a zone, a group
and a text box are canvas furniture: they draw nothing physical and never get a
row, and `link_facts` returns early for them. The text had nowhere to land, and
on reload `data.notes` came back undefined.

Zones were worse off — `GroupRectModal` never had a description field, and the
serializer hardcoded `notes: null` for them, so there was no way to describe a
zone at all.

Only full mode was affected: standalone keeps furniture nodes whole, so the two
modes disagreed about whether a description survived.

Furniture gets its own `nodes.description` column. It is canvas content, not a
device fact — a zone describes no device — so it saves with the canvas and
costs nothing in standalone. `link_facts` clears it on any node that does draw
a device, so `description` and the row's `notes` never compete for the same
text.

The column is added by `_try_migrate` and, importantly, is also listed in
`_NODE_COLUMNS_SQL` and `_NODE_KEPT`: `_drop_legacy_node_columns` recreates the
table from those two strings, so a column missing from them is silently lost on
the boot that finally drops the pre-3.3.0 columns — which is exactly the boot a
user who wrote a description in the meantime would not expect to lose it.
A migration test covers that path.

Zones get the Description field they never had, in `GroupRectModal`.
`NodeModal`'s free-text field reads "Description" and binds to the new column
for furniture, and "Notes" on the device row for everything else. Reads fall
back to `notes` so a canvas loaded before this change still shows its text, and
standalone hoists the old value once on load.

Also folds the two duplicate `FURNITURE_TYPES` sets in `standaloneStorage.ts`
and `subnet.ts` into one export, rather than adding a third copy.

ha-relevant: yes
2026-09-07 10:38:24 +02:00
Pouzor e6d338be13 feat(docs): a real document space for the homelab
The only writing surface was `device_inventory.notes`, a single TEXT column
rendered as plain preformatted text. No structure, no links, no search, and
nowhere to write anything that is not about one device.

This adds markdown documents, a navigable tree and a Documentation section in
the left rail — the first app-level view that is not a canvas.

Model — three tables. `documents` (kind: device / node / design / page /
folder), `document_revisions` capped at 50 per document, and a `documents_fts`
FTS5 index. A document attaches to the *inventory row*, not to a node, so one
device reads the same on every canvas. Every link is SET NULL and the title is
denormalized: deleting a device or a canvas must never destroy what the user
wrote. Foreign keys are off in this SQLite setup, so `doc_links.unlink_documents`
clears those columns by hand in the nodes, scan and designs delete paths.

FTS5 is optional. The SQLite shipped in the LXC and Docker images may not carry
it — `doc_search` probes once at boot and falls back to LIKE, and the search
response names the engine it used so the UI can drop snippet highlighting.

A device document is scaffolded once, from the scan facts: identity table,
hardware, services (one section per service), network, operations,
troubleshooting, dependencies, changelog. Then it belongs to the user and
nothing rewrites it. The old `notes` are appended verbatim under `## Notes`
behind a provenance comment, and `device_inventory.notes` is left untouched —
HACS, the YAML export, MCP and canvas search still read it.

Drift is surfaced, never corrected: `facts_snapshot` powers a banner, and
`GET /documents/blocks` re-renders one generated block from current facts for
the editor's slash commands. `review_every` plus `reviewed_at` produce a
"needs attention" bucket, because homelab docs die of staleness.

The Devices tree is pivoted client-side from the already-loaded inventory —
zone, group, type, physical/virtual, subnet, rack, vendor, discovery source,
status, tag, A-Z — so re-pivoting costs no request. The Library beside it is
real folders the user makes. The Canvas pivot is scoped to the loaded canvas:
`NodeData` carries no `design_id`, so naming every canvas a device appears on
would be a guess.

Saving is explicit, per the product rule. The dirty body is mirrored to
localStorage and offered back on reopen; a draft whose base no longer matches
the document is discarded rather than replayed.

react-markdown is loaded behind React.lazy, so the canvas path does not pay for
it: the main bundle is unchanged and the section brings its own 199 kB chunk.
Raw HTML is not rendered, which removes the XSS surface and means no sanitizer.

Export/import, a print view, an aggregated open-tasks view and images inside a
document are deliberately not in this lot. MCP tools are lot 2.

ha-relevant: yes
2026-09-07 10:38:24 +02:00
Pouzor 4e8d841807 feat(deploy): serve Homelable under a configurable base path
Homelable could only own the root of an origin. Behind an existing proxy
at `https://home.example/homelab/` the built `index.html` still asked for
`/assets/*`, which fell through to whatever owned the root — and when
that answered `text/html` for a `<script>` under `nosniff`, the browser
failed the load on an HTTP 200. The only workaround was patching the
checkout and rebuilding on every upgrade.

One build-time knob, `VITE_BASE_PATH`, becomes Vite's `base`. Vite
rewrites the asset URLs it emits; everything the app builds by hand goes
through the new `utils/basePath.ts` — the axios instances, the WebSocket
URL, the live-view route, the local brand icons, and the OIDC login href.
`resolveServerPath` covers what the *backend* hands back, which is always
root-absolute because it cannot know where the SPA is mounted: uploaded
floor-plan URLs already stored in a canvas are resolved at render time,
so plans predating the move keep loading. The OIDC callback used to
redirect to `/`, dropping subpath users at the origin root after login;
it now reads the prefix back out of `OIDC_REDIRECT_URI`.

The default is `/`, and stays a no-op there by construction: every helper
returns the string it returned before, the root build output is unchanged
and both nginx sites are byte-identical to what shipped — the Docker
image copies `docker/nginx.conf` verbatim and the installer keeps its
original heredoc. Only a non-default prefix takes the generated config.

Those generated configs use `root`, never `alias`, since `alias` plus
`try_files` mis-resolves `$uri` — the Docker build lands the bundle in
the matching subdirectory, and the installer symlinks it under
`/var/www/homelable`. Both accept either reverse-proxy style, prefix
forwarded intact or already stripped, with no redirect loop between them,
and `absolute_redirect off` stops the no-slash 301 from eating the port.

Closes #334.

ha-relevant: maybe
2026-09-04 11:23:16 +02:00
Pouzor 3cbda60277 feat(icons): add the Homelable logo to the brand icon picker
The brand catalog is the generated homarr-labs dashboard-icons manifest,
which carries no Homelable mark, so a user cannot label the app itself on
a canvas. Serve a small set of logos from `public/brand/` instead: the
picker prepends `LOCAL_BRAND_ICONS` to the manifest and `brandIconUrl`
routes those slugs at the local asset rather than the jsDelivr CDN.

`dashboardIcons.json` stays untouched — `scripts/fetch-dashboard-icons.mjs`
overwrites it wholesale.

Closes part of #397 (the Docker host polling request is untouched).

ha-relevant: yes
2026-09-04 10:21:32 +02:00
Pouzor 37f893abe6 fix(rack): keep the saved pan/zoom when switching back to a rack canvas
Leaving a rack design for another canvas and coming back put the racks
at the wrong pan/zoom until a hard reload. Two causes, both fixed here.

The rack flow shared the App-level `ReactFlowProvider` with the logical
canvas, so that canvas' transform and pane size carried over into it.
`RackCanvas` now wraps its own provider.

The restore effect was also spent before it could work: a switch back
remounts the component before `App` calls `loadDesign`, so the first
pass applied the pre-load viewport and then locked itself out for that
design. The marker now clears whenever a load starts, and the apply
waits a frame so React Flow has measured its pane.

Closes #408

ha-relevant: no
2026-09-04 10:01:35 +02:00
Pouzor f51dd131c1 refactor(rack): tidy the device modal's last row and its actions
Status and the colour override shared a column each, one under the other,
while the pair fits on one row like the placement fields above them. The
footer sat flush against the last section, so Save read as part of it, and
a section rule carrying the faceplate button sat lower than the plain rule
beside it.

- Status and Colour override share a two-column grid.
- A rule separates the form from Unmount / Cancel / Save.
- SectionHeader is a fixed-height row, so an `aside` button no longer
  pushes its border down.

ha-relevant: yes
2026-09-04 01:40:34 +02:00
Pouzor 4b90c60cc6 refactor(rack): put the plate picker on the faceplate rule
The Faceplate field sat in the Information column, a two-line block whose only
job was to open the catalog — a long way from the plate it changes, and the
last thing left between the label and the placement fields.

It is now a small button on the right end of the Faceplate rule, above the
drawing it affects: the current plate names itself, and `Change` says the name
is a button rather than a read-out. The name truncates first, so a long one
("Switch 24 ports + 2 SFP") never pushes the verb off. The warning about a plate
swap dropping its ports moved with it, between the rule and the plate.

ha-relevant: yes
2026-09-04 01:40:34 +02:00
Pouzor b26e9d3137 refactor(rack): rebuild the device modal around a headed strip
The plate used to sit alone above the form at a fixed 660px, and what the
mount actually stands for was buried under the port list at the bottom of the
right column — the two things the user checks first, at opposite ends of the
dialog.

They now share a strip across the top: the linked device on the left, the plate
on the right. The plate fills the half it is given instead of a constant, which
is what made port placement so fiddly — a full-width switch drew at two thirds
of the space with sockets to match. `PortPositionEditor` measures its container
and derives the scale from that; `fullWidth` stays as an explicit override, and
both fall back to the old default where nothing can be measured.

The form is split by the same section rules the logical canvas' `NodeModal`
draws — Linked device, Faceplate, Information, Placement, Ports — so the two
editors read the same way. `SectionHeader` moves to its own component and takes
an `aside`, which is where the mount status now rides. An accessory has no
ports and no inventory row, so it gets neither heading.

Port rows go from two columns to three; a 24-port switch no longer runs past
the fold.

ha-relevant: yes
2026-09-04 01:40:34 +02:00
Pouzor 4ccad61e73 feat(rack): let a mount say when it shows its ports, and never end a cable on hidden metal
Two things about when a faceplate draws its sockets.

A mount now carries `portVisibility`: `auto` leaves the faceplate in charge —
switches and patch panels permanently, everything else on focus — while
`always` and `hover` override that either way. The selector sits in the port
section of the device modal. It is a canvas display choice, so it lives on the
rack mount and never reaches the inventory row the mount points at; `auto` is
the default, so nothing about existing racks changes.

The fix underneath: with the cabling overlay on `hover`, hovering a switch drew
its runs, but the far end landed on a plate that was not itself focused and so
drew no socket — a cable stopping on blank metal. A port a *drawn* cable ends
on is now always rendered, on both plates, whatever the plate rule or the mode
says. `cableVisibility.ts` owns the question of which cables are on screen and
`CableLayer` draws from the same answer, so the two can no longer disagree.

Fixes #403

ha-relevant: yes
2026-09-04 01:40:34 +02:00
Pouzor caaa408618 fix(rack): stop a clamped size from shrinking the device everywhere
The write-through was asymmetric. A load applies the inventory row's
height and width to a mount only where they still fit that rack, so a
mount can legitimately be drawn smaller than the row says. A save then
copied the mount's size back onto the row unconditionally — so opening a
rack where a 4U device sits too high, changing anything at all and
saving turned that device into a 1U everywhere, silently. The rack store
did the same in the live session.

Size now travels only when the save actually changed it; an unchanged
size is an echo of the clamp and is dropped. The plate, its colour and
its ports keep going through unconditionally: none of them can overflow
a rail.

The other half of the overlay had no collision check, so a model bigger
than the mount could be applied on top of the neighbour above it —
drawing two plates on the same U, a state the canvas itself refuses and
`RackSaveRequest` happily stored. The overlay now obeys the placement
rule too, and the save endpoint rejects overlapping mounts in one rack
(devices in different racks, and half-width plates side by side on one
U, still pass).

ha-relevant: no
2026-09-03 20:58:20 +02:00
Pouzor bffe1927dd refactor(rack): lead with the faceplate, and make the port list a chip grid
Three things the port editor got wrong on a real switch:

The plate sat under the form, so the thing being edited was the thing you
had to scroll to. It leads now, in both the rack device modal and the
inventory one.

The arrow keys only moved a port while its handle held focus. Selecting a
port in the list and reaching for them — the obvious gesture — did
nothing. Placement mode now listens for as long as it is on and moves the
selected port, stepping aside for a text field being typed in.

And the list drew one full-height row per port: a 24-port switch ran a
metre down the modal and buried the panels under it. Each port is now a
28px chip (borderless name, bare type select, remove) in a two-column
grid, so the same switch is a list rather than a page.

ha-relevant: no
2026-09-03 20:58:20 +02:00
Pouzor f933f774fc feat(inventory): draw and edit a device's rack faceplate from its inventory row
The row already owns the front panel; until now only a rack canvas could
show it. The device detail modal draws the plate full size for any device
some rack has modelled, and its edit mode carries the whole model —
faceplate, height, width, colour override and the port list, placement
mode included — writing back through `PATCH /scan/pending/{id}`. A device
therefore no longer has to be racked, or racked again, to have its ports
named and placed.

The port list is now one component (`PortListEditor`) instead of a copy
in each modal, since both edit the same list on the same row.

A device with no modelisation gets no section and no `rack_*` fields in
the PATCH: editing an ordinary host must not silently give it a plate.
Creating a model from the inventory is deliberately left out — a plate is
still born on a rack.

ha-relevant: no
2026-09-03 20:58:20 +02:00
Pouzor b658a68a09 feat(rack): let the inventory own the front panel, and place ports by hand
The Device Inventory row now owns the rack modelisation — faceplate, U
height, column span, colour override and the port list, positions
included. A device therefore wears the same front panel in every rack it
is mounted in; only its placement, pinned status and label stay per
canvas. `POST /racks/save` writes it through, `GET /racks` overlays it on
load, and size is applied only where it still fits the mount's own rack:
geometry is global, a rack is not.

Ports were also unplaceable. Adding one dropped it on the middle of the
plate, so three added ports drew as one socket, and the only preview was
a 160×18px thumbnail that lost its label and stacked its ports. The
modal is wider, the plate is drawn full size below the form, and a
Position mode turns it into the port editor: drag to place, arrow keys
to nudge, per-axis snap to the nearest peer so banks stay in line.

Accessories stand for no inventory row, so they carry no ports and get no
port UI. Standalone has nothing to hang a global panel on, so ports stay
per mount there.

ha-relevant: no
2026-09-03 20:58:20 +02:00
Pouzor 6e2ccf7b7f fix(canvas): make the basic edge animation direction independent of geometry
The basic dash flipped to `animation-direction: reverse` whenever the source
sat below the target on screen, so the same edge marched one way or the other
depending only on where its nodes had been dragged.

The flip was compensating for the keyframes: homelable-basic-dash decreased
stroke-dashoffset while homelable-snake and homelable-flow increase it, so the
basic dash ran against the other two modes. Flip the keyframes to match and
drop the geometry test.

Does not address the underlying handle bug: the invisible oversized target
handle in SideHandles wins the pointerdown, so a drag from A to B is stored as
source=B, target=A. That is why all three modes now march target to source.

Closes #395

ha-relevant: yes
2026-09-03 02:46:58 +02:00
Pouzor fb39663293 refactor(inventory): rename the Approve action to "Put in current Canvas"
"Approve" described a moderation step the inventory no longer really has —
a row is not judged, it is placed on the canvas the user is looking at. The
new label says what the button does and which canvas it targets.

Renames the button in the device detail modal and the bulk button in the
inventory grid, plus the success toasts and the walkthrough step that spoke
of "approved devices". The API paths, the `status` value and every handler
name keep the word: it is a published contract the MCP server also calls.

ha-relevant: no
2026-09-02 21:14:44 +02:00
Pouzor 54176983b6 fix(proxmox): no host link for a guest nested in container mode
A nested guest was still getting the host -> guest 'virtual' edge, which
loops out of the container box and back into it — the screenshot on #399.
Being inside the box already says the host runs it.

Both entry points drop the link only for guests they actually nest: the
import modal path skips the edge in App's handler, and the inventory path
filters the host <-> guest pairs out of the edges bulk-approve returns.
Linked mode, loose guests and cluster edges are untouched.

ha-relevant: maybe
2026-09-02 21:14:44 +02:00
Pouzor deff7a34d0 feat(proxmox): optionally nest guests inside their host on the canvas
Two entry points now offer container mode, where a Proxmox host is drawn as
a box holding its VMs and LXCs instead of sitting beside them.

Import modal: an opt-in "Nest guests inside their host" checkbox, shown only
in "Inventory + canvas" mode. `onAddToCanvas` carries the choice as a
`ProxmoxCanvasMode` ('linked' | 'container').

Device Inventory: approving a Proxmox host opens a new ProxmoxApproveModal
asking whether its guests come along, and nested or linked. The guests come
from a new `GET /scan/pending/{id}/proxmox-children`, which resolves the
host -> guest `device_inventory_links` the Proxmox import records (cluster
links excluded, hidden rows skipped). A host with no guests, or a failed
lookup, falls through to the plain single approve.

Placement lives in the new `utils/proxmoxContainerLayout`: `groupProxmoxGuests`
splits a selection into hosts, guests and loose nodes (one parent per guest),
and `layoutProxmoxContainers` grids the guests inside their host box, hosts
side by side, anything left over below. Positions are absolute — `addNode`
turns them relative when it nests. A host skipped as a same-canvas duplicate
leaves its guests loose rather than orphaning a parent_id.

Also extracts `inventoryNodeData()` so the single, bulk and Proxmox approve
paths build node data from one place.

ha-relevant: maybe
2026-09-02 21:14:44 +02:00
Pouzor 39cad9efc7 fix(canvas): keep a nested container ahead of its children
A container dropped into a zone left its own children draggable anywhere
on the canvas: they lost the box they were pinned inside (#366
follow-up).

The store reordered nodes for React Flow with a two-bucket split —
parentless nodes first, everything else after. That is only correct while
nesting is one level deep. A zone can hold a container, so the container
and its own children now sit in the same bucket, and a child whose row
predates its parent's lands ahead of it. React Flow then drops the
child's parent binding entirely: no parent-relative position, no
`extent: 'parent'` clamp, so it renders detached and drags free.

`orderParentsFirst` — a topological sort that already backs the zone drop
— now covers the other five reorder sites: loadCanvas, applyLayout,
updateNode's re-parent path, setProxmoxContainerMode and pasteNodes. The
child's stored state was never wrong; only the array order was.

ha-relevant: yes
2026-09-02 01:24:12 +02:00
Pouzor 6c55cbfe58 fix(canvas): detach a selection from a zone in one undo step
Dragging a multi-selection out of a zone called removeFromGroup once per
node, and each call pushed its own history entry — so an add of three
nodes undid in one step but the matching detach took three, with the
intermediate states leaving part of the selection out.

removeNodesFromGroup detaches the batch in a single set, mirroring
addNodesToZone; removeFromGroup delegates to it.

ha-relevant: yes
2026-09-01 08:52:31 +02:00
Pouzor 3301f00e57 feat(canvas): add a whole selection to a zone in one drop
Dropping a multi-selection on a zone, a group or a container only moved
the node under the cursor: handleNodeDragStop read `dragNode` and ignored
`dragNodes`. Dragging a parent and its children in was especially
tedious (#365).

The drag handler now uses the whole dragged selection, with `dragNode`
only picking the destination, and the store grows batch counterparts
(addNodesToZone / addNodesToGroup / addNodesToContainer) so the move
lands in a single history entry and one undo takes it all back. The
singular actions delegate to them.

Ineligible children are skipped rather than failing the batch: the
destination itself, a node already parented to it, a node whose own
parent is in the same selection (it rides along with that parent), and
any node the destination descends from. Leaving a zone detaches every
dragged child too, not just one.

The confirmation modal takes labels instead of a label, and counts and
lists them ("Add 3 nodes (Router, Switch, NAS) to the zone DMZ?"),
summarizing the tail past five.

ha-relevant: yes
2026-09-01 08:52:31 +02:00
Pouzor 895d00a499 fix(canvas): stop a self-parented node from freezing the canvas
A node recorded as its own parent (`parent_id = id`) made the child walk in
`translateWaypointsForMovedNodes` recurse forever. The resulting stack overflow
was thrown inside the `onNodesChange` reducer, so the whole state update was
discarded: the node still selected but would never move. It also renders
unparented, sitting wherever its stored coordinates put it rather than inside
the container it appears to belong to.

Guard the walk, then close every path that could write the row:

- `propagate` keeps a `walked` set, matching the cycle guard `orderParentsFirst`
  already carries. Covers longer cycles (a -> b -> a) too, not just self-parent.
- `updateNode` / `addNode` drop a `parent_id` equal to the node's own id. The
  key is removed rather than nulled, so the rest of the edit still lands and an
  existing real parent is left alone.
- `importYaml` resolves parents by label, so a node naming its own label — or a
  duplicate label mapping back to it — imported as its own parent. Warn and skip.
- `node_dedupe` re-pointed every child of a duplicate at the canonical node,
  including the canonical node itself when it had been nested under one of its
  own duplicates. Detach instead, mirroring the self-loop edge deletion below it.
- `NodeSave` and the node PATCH route normalize it away. Dropped rather than
  rejected: a canvas that already carries the bad row must still be able to save,
  and a 422 would cost the user the whole save.

`_repair_self_parent_nodes` clears what is already persisted at startup. The
real parent is not recoverable from the row, so NULL returns the node to the top
level where the user can re-nest it. Idempotent, and never fatal to boot.

A longer cycle is deliberately left alone by the repair — there is no single
right link to cut, and the runtime guard keeps the canvas usable either way.

ha-relevant: yes
2026-09-01 01:53:03 +02:00
Pouzor 28f3ac3878 fix(services): stop appending the scanned port to a custom service host
A service `host` override usually names a reverse proxy, where the scanned
port is an internal detail: `https://proxy.example.com:8083/` is not a URL
that resolves to anything. The override now suppresses `svc.port` in both the
link the canvas opens and the URL the status checker probes — only a port
typed into the override itself (`host:port`) is printed.

The scanned port still gates the non-HTTP/SSH skip and the http/https guess,
so a MySQL or SSH service behind an override stays unlinked and unchecked.

Closes #382

ha-relevant: yes
2026-09-01 00:50:37 +02:00
Pouzor 2413ac3950 feat(imports): link a canvas-direct import to its Device Inventory row
A canvas-direct Zigbee / Z-Wave / Proxmox import placed nodes and left the
inventory alone. The first canvas save then minted a row for each of them
(`link_facts`), but the save wire shape carries no `ieee_address` — so the row
landed IEEE-less and tagged `canvas`, and the next mesh import, matching by
IEEE, created a second row for the same device.

The canvas import now upserts the inventory first, through the same
`_persist_pending_import` the pending path uses, and stamps each returned node
with the id of the row it draws. The canvas already round-trips `device_id`, so
the save links to that row instead of minting a rival. A row a node draws also
leaves the pending queue — the mirror of the existing "approved but no longer
drawn -> pending" revival. A hidden row stays hidden.

The mode selector is relabelled to match what it now does: both modes reach the
Device Inventory, so the choice is "Device inventory only" vs "Inventory +
canvas". The three modals also gain the width and spacing the two-line labels
needed.

Pre-existing split rows (a `canvas` row and an import row for one device) are
left alone: with no shared ieee/ip/mac they could only be matched on hostname,
and welding inventory rows together on a name match is worse than the drift.

ha-relevant: maybe
2026-08-31 13:26:37 +02:00
Pouzor 0acfe3acc3 fix(zigbee): stop canvas imports dying on a proxy read timeout
A Zigbee2MQTT networkmap on a 200+ device mesh takes minutes to build.
Two separate failures fell out of that:

- POST /zigbee/import held the HTTP request open for the whole MQTT
  round-trip, so any reverse proxy in front of the API cut it first
  (Cloudflare returns a 524 at 120 s) and the browser never saw the map.
  It now registers a job, fetches in the background and answers 202; the
  client polls GET /zigbee/import/{job_id} until the payload is ready.
  Job results are transient and live in memory with a 15 min TTL — the
  same single-worker assumption the scheduler already makes. A failed
  fetch replays the status the synchronous route used to raise, so a bad
  broker is still a 502 and a slow mesh still a 504.

- The networkmap wait was hard-coded at 300 s with no way to raise it.
  It now reads ZIGBEE_NETWORKMAP_TIMEOUT, and the shared MQTT round-trip
  used by the Z-Wave import reads MQTT_RESPONSE_TIMEOUT. Both default to
  300 s, fall back to that if misconfigured to a non-positive value, and
  name themselves in the timeout message.

Also corrects the route and doc claims that the wait was 60 s.

The /import tests changed with the contract they cover, not to pass.

Fixes #380

ha-relevant: yes
2026-08-31 11:46:36 +02:00
Pouzor 9c2a1ba0eb docs(canvas): correct a stale comment on where a zone's height is stored
The zone size moved to the nodes.width/height columns; the comment still
described the custom_colors stash it replaced.

ha-relevant: yes
2026-08-27 01:45:23 +02:00
Pouzor 851141951d fix(canvas): store a zone's size in the width/height columns
Every node type persisted its size in `nodes.width` / `nodes.height` except
`groupRect`, which stashed it inside the `custom_colors` JSON next to its
colours. The columns already existed and were simply unused for zones, so
this was an inconsistency rather than a missing-column workaround, and it
put geometry in a blob that otherwise holds style.

The serializer now writes the columns for a zone too, and strips the legacy
`width`/`height` keys out of the blob so the two cannot drift apart and
leave an older canvas reading a stale size.

No data is lost on upgrade:

- `_backfill_zone_size` copies the blob geometry into the columns at
  startup. It only fills a column that is still NULL, so it cannot overwrite
  a size set since; it parses the JSON in Python rather than with
  `json_extract`, so it does not depend on the SQLite build carrying JSON1;
  and an unreadable row is skipped without costing the others their size.
  Re-running it is a no-op.
- the reader still falls back to the blob, covering a payload the backfill
  has not reached — an older server, or an import.

Standalone mode is unaffected: it stores React Flow nodes verbatim, so the
size was always on `node.width` / `node.height` there.

The four serializer tests that pinned the size to the blob now assert the
columns, since that is the behaviour being changed.

ha-relevant: yes
2026-08-27 01:45:23 +02:00
Pouzor f9f88c8fb0 refactor(canvas): drop a dead custom_colors.height write on zone growth
Growing a zone during a subnet import wrote the new height twice: to
`node.height`, the live field, and to `data.custom_colors.height`, which
nothing reads. The blob copy is produced by the serializer at save time
(rebuilt from `node.height`, so the value written here was overwritten
before it reached the API) and consumed at load time off the API payload.
Writing it from the store was invisible, and misleading in a blob that
otherwise holds colours and style.

The test asserting the dead field is replaced by a save/load round-trip
through the real serializer, which is what actually protects the height.

ha-relevant: yes
2026-08-27 01:45:23 +02:00
Pouzor 2bf9f1bca8 fix(canvas): keep a parent ahead of its children after a subnet import
The subnet import only guaranteed the zone preceded the nodes it pulled in.
An arrival that is itself a parent — a Proxmox host with nested VMs, whose
children stay put because they already have a parent — could end up listed
after those children, which React Flow renders detached with a
parent-not-found error. Nothing re-sorts on load, so the broken order
survived a save.

Reorder the whole array instead: every node now follows its parent, which
covers both directions at once. An already-valid list comes back untouched
and a parent cycle terminates rather than recursing.

ha-relevant: yes
2026-08-27 01:45:23 +02:00
Pouzor d9451f35dc feat(canvas): import devices into a zone by subnet
Closes #325.

A zone gains an "Import devices by subnet" action: type a CIDR, and every
free device whose IP falls in that range moves into the zone, laid out on a
grid in its free space.

The CIDR is an argument to a one-shot action, never a property of the zone —
it is not submitted with the form, not persisted, and cleared after each run.
So there is no schema change, no migration, and it works in standalone mode.
The trade-off is that a device scanned later does not join the zone by
itself; the user re-runs the import.

Deliberate rules:
- additive — running it twice with two subnets leaves both sets inside, and
  a device whose IP stops matching is never ejected
- only unparented devices move. A node nested in a group, a container host
  or another zone keeps the parent the user gave it
- canvas furniture (groupRect / group / text) is skipped, and a zone never
  swallows another zone
- one history entry per import, so a single Undo reverses the whole thing

Edit mode gets an Import button; add mode has none, since the zone does not
exist yet and a button would look broken — there the CIDR is applied right
after the zone is created.

IPv4 only: the modal rejects an IPv6 CIDR with a message rather than
silently matching nothing.

ha-relevant: yes
2026-08-27 01:45:23 +02:00
Pouzor 9daea4c4d0 fix(scan): keep an edit, a discovery source and one name for a failed run
Three findings from the PR review.

A finishing deep rescan threw away an edit in progress. The reset effect in
InventoryDeviceModal was keyed on the `device` object, and the parent hands
down a fresh one whenever the row is refreshed — including from the poll's
own onSaved. Same device, new object, so the effect reset the form and left
edit mode, minutes into a scan the user was waiting on. It is keyed on the
id now: a refreshed row is not a different device, and only a different
device is a reason to throw the form away. The fresh row still reaches the
canvas and the grid — gating that behind edit mode would discard the scan
result instead, and a save unions the services server-side anyway.

A rescan tagged every device it touched as "arp"-discovered, so a Proxmox
guest, a rack mount or a hand-added host started answering the network
source filter. It carries its own source through instead.

The two background wrappers wrote status "failed" where the scanners write
"error" for the same condition. Harmonized on "error" — the one Scan
History filters and colours; "failed" showed up unlabelled. The frontend
union keeps 'failed' for rows already recorded.

That last one meant updating an existing assertion in test_scan_run.py: it
encoded the old spelling.

ha-relevant: yes
2026-08-21 12:49:48 +02:00
Pouzor c6d6b525ea feat(scan): choose the port range before a deep scan
The Deep scan link on a device now opens a small dialog instead of firing
straight away. It is prefilled with the full 1-65535 range — that is still
the point of the feature — but a user who knows where a service lives can
narrow it and get an answer in seconds instead of minutes.

The dialog takes an nmap-style spec: a port, a range, or a comma list
(80,443,8000-9000). It shows the port count live, refuses to start on
something nmap could not use, and carries three presets (all / 1-1024 /
1-10000).

Backend:
- _parse_port_spec / _port_chunks generalize the slicing that used to be
  full-range only. Ranges are merged before slicing, so an overlapping
  spec is never scanned twice, and small ranges are packed into one nmap
  call instead of one call each. _deep_port_chunks still yields the same
  eight slices as before.
- run_device_scan takes ports=; it wins over full_ports.
- The retry-free flags (--max-retries 0 --min-rate 2000) now key on the
  total port count rather than on full_ports. They pay for themselves over
  thousands of ports on a lossy host; over a handful they only cost
  accuracy.
- RescanDeviceRequest.ports validates the spec — 422 rather than handing
  nmap a bad -p. Blank means the full sweep.

Frontend utils/portSpec.ts mirrors the backend parser so a typo is caught
before the request; the backend validates again because it is the one
calling nmap.

The existing rescan tests now go through the dialog: the click path
changed, so the start is two steps.

ha-relevant: yes
2026-08-21 12:49:48 +02:00