Commit Graph
945 Commits
Author SHA1 Message Date
Pouzor 783ca87acd chore(deps): bump js-yaml to 4.3.2 for GHSA-2883-xcg3-v3hh
The dependency-audit job fails on a high-severity advisory: js-yaml 4.0.0-4.3.1
does not limit CPU use for empty merge sources, so maxTotalMergeKeys can be
bypassed. 4.3.2 is the patched release.

Lockfile only — package.json already allows it at ^4.3.1, and nothing else in
the tree moved. `npm audit --omit=dev --audit-level=high` now reports 0
vulnerabilities.

homelable-hacs pins js-yaml at ^4.1.1 in frontend-src, inside the same
vulnerable range, so it needs the same bump in its own lockfile.

ha-relevant: maybe
2026-09-09 01:19:19 +02:00
Pouzor 71e78b4279 fix(mcp): carry the backend's error detail through to the client
raise_for_status raises httpx's own HTTPStatusError, whose message stops at the
status line and the URL. FastAPI's {"detail": ...} — the half that says what to
do about it — was dropped, so every rejection reached the calling AI as a bare
"Client error '422 Unprocessable Content'" with nothing to act on.

Replaces it with a BackendError naming the method, path, status and detail.
A structured detail (the approve-duplicate prompt answers with an object) is
serialized rather than lost, a non-JSON body falls back to its text, and the
whole thing is capped so an HTML error page can't land in the client's context.

ha-relevant: no
2026-09-09 01:19:19 +02:00
Pouzor ac8aea7467 feat(mcp): expose edge styling on create_edge and update_edge
The backend has always accepted these on EdgeCreate/EdgeUpdate; only the canvas
UI could set them, so an edge created over MCP came out with the edge type's
preset and no way to change it — the animation, colour and markers other edges
in the same canvas already use were unreachable.

Adds animated, custom_color, path_style, line_style, width_mult, marker_start,
marker_end, vlan_id and speed to both tools. The enums match what
normalize_animated / normalize_marker accept, so an advertised value can't
silently coerce to 'none'.

ha-relevant: no
2026-09-09 01:19:19 +02:00
Pouzor 08d85a40c1 fix(edges): reject an edge endpoint the node has no connection point for
React Flow draws nothing for an edge whose handle ID resolves to no element, so
such an edge persists in the DB and is simply invisible on the canvas, with no
error anywhere. Nothing could reach that state while only the UI wrote handles;
exposing source_handle/target_handle over MCP opens it up, which is the same
failure the shrink remap already prevents from the other direction.

- POST /edges and PATCH /edges/{id} answer 422 when a caller-supplied handle
  names a slot its node doesn't have — including a side it opted out of, since
  left/right default to no connection point at all.
- Handles outside the per-side vocabulary ('cluster-right' and friends) belong
  to their own renderer and are left alone.
- The auto-assignment corrects its own pick instead of raising: it always names
  a top/bottom slot 0, which a node that zeroed those sides no longer draws.
  A node with no connection point anywhere keeps the original.
- parse_side_handle in schemas/utils.py reads both the source form and the
  '<side>-t' target twin the auto-assignment stores.

ha-relevant: no
2026-09-09 01:19:19 +02:00
Pouzor e247483d9b feat(mcp): make connection points editable over MCP
Handle counts were readable through get_node but absent from update_node, and
create_edge took no handle arguments — the server always picked. Expose both,
and close the two backend gaps that opening them up would otherwise turn into
silent link loss.

- create_node/update_node take top/bottom/left/right_handles and
  show_port_numbers; create_edge and a new update_edge take
  source_handle/target_handle.
- New list_edges: get_canvas slims edges down to id/source/target/type/label,
  so the handles an edge sits on were unreadable through any tool.
- Clamp handle counts to 0..64 on every write path (create, update, canvas
  save). The renderer clamps at draw time, so an unclamped row drew a
  different node than it stored.
- Port the canvas store's shrink remap to the node PATCH route: an edge left
  on a handle that a count reduction deleted silently stops rendering.

Closes #435

ha-relevant: no
2026-09-09 01:19:19 +02:00
Pouzor acac515d18 feat(walkthrough): walk the tour through the Documentation section
The tour stopped at the canvas, so the section that holds every device page
and the Library was reachable only by finding the sidebar entry.

Three steps, between the rack and the styling ones: the section itself, the
template picker with wiki-links and history, and the device tree with its
generated headers and pivots. All backend-only — a standalone build has
nowhere to store a document.

Documentation replaces the whole main pane rather than opening a modal, so
`closeAll` puts the view back to the canvas before every step; without it the
following steps anchor on a toolbar that is no longer mounted.

ha-relevant: no
2026-09-08 00:20:18 +02:00
Pouzor - Rémy JardientandGitHub 5273189315 Update README.md 2026-09-07 18:57:24 +02:00
Pouzor - Rémy JardientandGitHub fa192beac9 Update README.md 2026-09-07 18:55:43 +02:00
Pouzor 7aa322da35 test(migrations): stop the pre-split backup test tracking the app version
`_backup_db` names its copy `homelab.db.back-<APP_VERSION>`, so the 3.4.1 bump
renamed the backup `init_db` takes from `back-3.3.2` to `back-3.4.1`. The test
hardcoded its post-split copies as `back-3.3.1` / `back-3.3.2`, which happened
to overwrite that backup while the version was 3.3.2; at 3.4.1 it survives,
still carrying the columns this very boot is about to drop, and wins the
newest-first scan in `_pre_split_backup`.

The fixture was also unlike a real install, where the current version's backup
is taken after the columns are already gone — they went in the 3.3.0 boot, not
this one. Copy a post-split database over it too, under whatever the version is,
and the test stops breaking on every bump.

`_pre_split_backup` is unchanged: preferring the newest backup that still holds
the columns is right, the drop boot's own backup included.

ha-relevant: no
2026-09-07 17:58:45 +02:00
Pouzor 574b84d843 chore: bump version to 3.4.1
ha-relevant: no
v3.4.1
2026-09-07 17:08:19 +02:00
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 0adb2619dd chore: bump version to 3.4.0
Version bump to 3.4.0 plus the CHANGELOG entry for the release:
the Documentation space, the rack faceplate owned by the Device
Inventory, Proxmox guest nesting, subpath serving, the MCP rack and
inventory tools, and the fixes since 3.3.5.

ha-relevant: no
v3.4.0
2026-09-07 16:11:49 +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 da058001b8 docs: describe the link picker and documents in the canvas search
Both shipped in the two commits before this one. The Editing section gains the
`[[` picker and why it holds a bracket back; the Search section gains the canvas
modal that now queries the same index; and "Not built yet" drops the two entries
that are no longer true.

ha-relevant: no
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 4bcba084db docs: say the documentation space exists
The README and FEATURES.md described nineteen features and not the one that
writes the homelab down. A user reading either had no way to learn that every
device gets a document, that there is a Library, or that any of it is there at
all.

README gets a Documentation section next to the Rack Canvas one and mentions it
where it lists what Homelable does. FEATURES.md gets section 13, after Device
Inventory, which is where it belongs — the rest shift up one and the table of
contents follows. The link docs/rack-canvas.md holds into that file points at
Device Inventory, which does not move.

docs/documentation.md picks up the two capabilities that just landed, a
backlinks row in the API table, and a "Not built yet" list that is honest about
what is still missing: export/import, a print view, an open-tasks view, images,
documents in the canvas search modal, [[ autocompletion, and the MCP tools.

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 080de69543 fix(proxmox): keep a migrated guest on its existing inventory row
Excluding claimed rows from both fallbacks was too broad. The synthetic ieee
embeds the host name, so a live migration or a node rename rewrites it for what
is the same guest (`pve-pve1-802` → `pve-pve2-802`). Its exact match then
missed, MAC and IP both skipped the existing row as claimed, and the import
filed a second row — leaving the first orphaned on a host that no longer runs
the guest.

A MAC is an identity; an IP is not, and only the IP caused #419. So the MAC
fallback now matches a claimed row too, and `_adopted_ieee` re-points it to the
new host. Re-pointing is the half that matters beyond the duplicate: links are
rebuilt keyed on the ieee, so a row left on the stale one stops resolving as a
host→guest endpoint. A mesh ieee is a real hardware address and is never
overwritten.

Accepted trade-off, pinned by a test: two guests sharing a NIC MAC now collapse
into one row. That is a misconfiguration which already breaks their networking,
where the alternative orphans a row on every migration.

ha-relevant: no
2026-09-07 11:32:18 +02:00
Pouzor 29525bca87 fix(proxmox): match a guest by its own identity, not a shared IP
`_find_pending` matched ieee OR ip OR mac in one flat `or_()` with `.first()`
and no ordering, so an import merged into whichever row the database happened
to yield first. Two guests on one address — a duplicated static lease, a
re-used DHCP address, two guests behind one NAT — let one guest's import
overwrite the other's hostname, VMID and specs, non-deterministically. Canvas
nodes hold their own label, so the map kept showing the old name over the wrong
machine's facts and the failure was invisible where you would look for it.

Match the synthetic `pve-{host}-{vmid}` ieee first (unique per guest), then
MAC, then IP, and exclude fallback rows already claimed by a different Proxmox
guest. Remaining ties go to the oldest row, so a re-import is stable. The
docstring promised IP as tier 1; the code never implemented any tier.

Upstream, `_extract_qemu_ip` returned the first non-loopback IPv4 the agent
listed, which on a guest running Docker or k8s is the CNI bridge — an address
several guests report identically. Prefer the interface whose hardware-address
is the NIC MAC already parsed from /config one call earlier.

Latent for anyone whose API token cannot reach the QEMU guest agent: guest IPs
are None, so no row ever matched on one. It surfaces the first time the token
gets VM.GuestAgent.Audit.

Already-corrupted rows cannot be repaired — the overwritten facts are gone.
Affected users delete the bad rows and re-import.

Fixes #419

ha-relevant: yes
2026-09-07 11:32:18 +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 a4d333b3d7 test(auth): assert the CORS warning against the derived origin
CodeQL's only open alert, `py/incomplete-url-substring-sanitization`: a URL
literal on the left of an `in` reads as a host check, and a substring match is
not one that can be made safely. The check here is a log assertion rather than
sanitization, so the alert is a false positive — but the assertion was weaker
than it needed to be either way, pinning the warning to a URL retyped beside
it. It now compares against the origin the settings themselves derive, which
is what the warning is supposed to name.

ha-relevant: no
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 079098cdda fix(docs): read the hardware table out of the device properties
The Hardware table printed three em dashes directly above a Properties table
listing CPU Cores, RAM and Disk for the same device.

It read `cpu_count` / `cpu_model` / `ram_gb` / `disk_gb`, and those columns are
barely written any more: the scanner, the Proxmox import and the inventory
modal all put hardware in `properties` instead. Four rows out of 176 in a real
inventory still carry the columns; every Proxmox guest carries the properties.

Properties win now, with the columns kept as the fallback for a row that still
has them. Keys are matched case-insensitively, and the common synonyms are
accepted alongside what the importers write — Processor/CPU, vCPU/Cores,
Memory/RAM, Storage/Disk.

A property value is printed verbatim. It arrives already formatted ("6.1 GB"),
so appending a unit would say "6.1 GB GB" — and guessing one onto a bare number
would be inventing a fact.

`facts_snapshot` gains the properties to match. The hardware table now depends
on them and the properties table already printed them, so an edit to one is a
real change to what the document says and should raise the drift banner. It is
stored as a plain dict: the snapshot is compared against its own JSON round
trip, and a tuple would come back a list and read as drifted forever.

A document generated before this keeps the empty table it was born with —
generation happens once. Re-inserting the block with `/hardware` fills 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 d52f698de3 fix(docs): bring an existing documents table up to the current shape
Opening the Documentation section against a database that already had a
`documents` table failed with `no such column: documents.edited_at`.

`Base.metadata.create_all` creates a table that is missing; it never adds a
column to one that is already there. `edited_at` landed after the table had
been created on a machine running the branch, so that database kept the older
shape and every query naming the column failed — including the one behind the
section's first request.

The repo convention is that column additions go through `_try_migrate`, and the
documents tables were the one place leaning on `create_all` for more than the
initial CREATE. Every nullable or defaulted column is now restated in
DOCUMENT_DDL as an idempotent ALTER, the way the `device_inventory` migrations
are, so an existing table is brought up to the current shape at boot whatever
shape it starts from.

The test fixture applied the same DDL with a raw `exec_driver_sql`, which was
fine for pure CREATE statements but turns each of those ALTERs into a fatal
"duplicate column" on the fresh database create_all has just built. It goes
through `_try_migrate` too now — which is what its own comment already claimed
it did.

ha-relevant: no
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 8612f453d4 chore(deps): clear every Dependabot alert in the frontend lockfile
All eight advisories were transitive and dev-only, reached through
`eslint` and through `shadcn` -> `@modelcontextprotocol/sdk`. Nothing
here ships in the runtime bundle.

- fast-uri   3.1.5  -> 3.1.7   (#71 #70 #67 #66, high)
- @humanfs/node 0.16.7 -> 0.16.8 (#69, medium)
- qs         6.15.2 -> 6.16.0  (#72 #68, auto-dismissed)
- nanoid     3.3.16 -> 3.3.18  (#62, auto-dismissed)

The qs and nanoid alerts were auto-dismissed as dev dependencies, but
the bumps are free and keep `npm audit` at zero.

Lockfile only -- no `package.json` change, no direct dependency moved.
`@humanfs/types` 0.15.0 and `@eslint/config-array` 0.19.2 come along as
resolution side effects.

ha-relevant: no
2026-09-05 01:29:19 +02:00
Pouzor e1e7b5089b fix(scan): never collapse a second approved row into another
An approved inventory row is what canvas nodes and rack mounts point at,
under `ON DELETE SET NULL`. Both collapse paths kept the first approved
row of a duplicate group and deleted the rest, so a group holding two of
them lost one device's links silently — and grouping by shared address
rather than by the exact `ip` string made that group easy to form: an
approved `10.0.0.1` beside an approved `10.0.0.1, 10.0.0.2` is one.

`_collapse_targets` now picks the merge target and the rows that may go.
With one approved row nothing changes. With two, every approved row
stands and only the pending ones collapse: a background scan is not the
place to decide which canvas-linked device loses its links, and the pair
stays visible in the inventory for the user to resolve.

ha-relevant: yes
2026-09-05 01:14:47 +02:00
Pouzor df60664506 fix(scan): match a scanned host against every declared IP of a device
An inventory row may hold several comma-separated addresses — a router's
LAN and VLAN legs, a host behind a VIP. The user declares them by hand
precisely so a scan folds into the existing row, and every other matcher
already honours that: `find_device_for`, `node_dedupe`, the status
checker and the inventory modal's own placeholder all read the field as
a list.

The scanner did not. `process_host` matched on `InventoryDevice.ip == ip`,
an exact string compare a multi-address row can never satisfy, so it fell
back to the MAC — which differs across VLAN interfaces, bridges and
keepalived VIPs. Result: a fresh pending row for the same device on every
run, no matter how the user had annotated it.

- Match per token: narrow with `contains`, then confirm the token in
  Python. The confirmation is load-bearing — "10.0.0.4" is a substring of
  "10.0.0.40", and the rows a match does not keep are deleted, not skipped.
- Build `hidden_ips` from tokens, so hiding a multi-address device hides
  it at every one of its addresses.
- Group `_dedupe_pending_by_ip` by shared token, transitively, so
  declaring an extra address also collapses the duplicate row it already
  spawned.

`_ip_tokens` becomes public `ip_tokens` rather than growing a fourth copy.
A scan still never appends an address on its own: `keep.ip = keep.ip or ip`
is unchanged, and declaring the extras stays a user action.

Closes #373

ha-relevant: yes
2026-09-05 01:14:47 +02:00
Pouzor 87081ac926 fix(mcp): key node properties on key, not name
create_node and update_node advertised a property as {name, value}. The
backend keys properties on `key` — merge_properties unions on it and
apply_view orders the node's view by it — so every property an MCP client
sent arrived keyless, collapsed onto the empty key, and a node given
three of them drew one. The schema also *required* `name`, so a correct
{key, value} payload was rejected outright.

Only MCP clients were affected: the frontend has always sent `key`.

`icon` and `visible` are exposed too, the other two fields of a
NodeProperty, so a property can be created the way the UI creates one.

While here, document that `status` on an update is a live observation
rather than a setting: link_facts keeps the freshest one instead of the
last writer, so it only lands while the device's status is still unknown.
The tool silently no-opped and its schema said nothing about it.

ha-relevant: no
2026-09-04 15:10:05 +02:00
Pouzor 4c8831d5df feat(mcp): expose the rack canvas and the inventory write routes
The MCP server covered the logical canvas only. A client could create a
design whose design_type is `rack` and then had no way to put a rack, a
mount or a cable in it, and it could triage the Device Inventory but
never create, edit or delete an entry — though every route already
existed. 24 tools become 47.

Racks (mcp/app/racks.py), 13 tools: list_racks, get_rack,
list_rack_inventory, list_faceplates, create_rack, update_rack,
delete_rack, mount_device, mount_accessory, unmount_device, move_device,
set_device_faceplate, patch_cable, unpatch_cable.

`POST /api/v1/racks/save` is full-state upsert-and-prune, so every write
is load, mutate, save it all back. Two traps are encoded once, in the
helpers: the response rows carry `design_id` and the save schemas do not
(echoing one back verbatim is a 422), and the viewport has to be echoed
or the save resets the user's pan and zoom. The plate, its colour and its
ports need no special handling — /racks/save writes those through to the
inventory row itself.

Two frontend files are copied rather than imported, since the MCP server
is a separate service:

  - mcp/app/faceplates.py — the 21-plate catalog from
    frontend/src/rack/faceplates.ts, data only. `bank()` reproduces the
    port arithmetic exactly, so a plate seeded here lands its ports where
    the renderer draws them.
  - mcp/app/rack_layout.py — canPlace / findSlot / freeUnits from
    frontend/src/rack/layout.ts, to *choose* a slot. The backend still
    polices the one it is sent.

Both are guarded against drift: test_faceplates_sync.py parses
faceplates.ts and compares every plate down to each port's coordinates,
the way test_node_types_sync.py guards NODE_TYPES, and
test_layout_parity.py runs the frontend's own layout cases against the
Python port.

Devices (mcp/app/devices.py), 10 tools: create_device, update_device,
delete_device, bulk_approve_devices, bulk_hide_devices,
bulk_restore_devices, rescan_device, list_proxmox_children,
get_scan_config, update_scan_config.

Verified against a running backend: the rack flow end to end (create,
mount, patch, move, swap a plate, shrink, unmount) plus every refusal
path, and the device tools including a byte-identical scan-config
write-back.

ha-relevant: no
2026-09-04 15:10:05 +02:00
Pouzor 6b11e7ed34 fix(config): unwrap quoted secrets that podman-compose passes through
bcrypt hashes and most OIDC client secrets contain `$`, so `.env.example`
tells operators to single-quote them or Docker Compose eats the `$`. Docker
Compose then strips those quotes itself when reading `env_file`, and so does
python-dotenv on the `env_file=` path — but podman-compose (1.0.6) forwards
them verbatim. The container receives `'$2b$12$...'`, quotes included, every
bcrypt verify fails, and the logged error blames the operator's hash.

Unwrap one matched pair of surrounding quotes from SECRET_KEY,
AUTH_PASSWORD_HASH and OIDC_CLIENT_SECRET before validating them, so the same
.env works under either container runtime. Only a matched pair is removed: a
value with a single stray quote still reaches the existing validation instead
of being silently patched up, and SECRET_KEY is unwrapped before the 32-byte
OIDC floor is checked so quotes cannot pad a short key past it.

Fixes #410

ha-relevant: no
2026-09-04 13:40:25 +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 03e5ae939e ci(security): give pip-audit the same registry-outage retry as npm
pip-audit queries PyPI's advisory API and exits non-zero when it cannot
reach it, exactly as `npm audit` does — the same class of outage that
reddened #409 would have failed the Python half too.

Both now go through .github/scripts/audit-with-retry.sh, which retries
only when the output names a transport or availability failure and lets
a real finding fail on the first attempt, unretried. The npm loop added
in the previous commit is folded into it.

ha-relevant: no
2026-09-04 10:01:35 +02:00
Pouzor 37a3aee9cc ci(security): retry the npm audit when the registry endpoint is down
`npm audit` exits 1 both for a real advisory and for a registry that
refuses to answer, so a 503 from the audit endpoint reddened PRs that
changed no dependency at all — as it did on #409.

Retry up to three times, but only when the output names a transport or
availability failure. An advisory at or above high severity still fails
on the first attempt, unretried.

ha-relevant: no
2026-09-04 10:01:35 +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