Files
homelable/docs/documentation.md
T
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

193 lines
7.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Documentation
A markdown document per device, plus a free-form Library of pages and folders,
behind the **Documentation** entry in the left sidebar.
Before this, the only writing surface in Homelable was `device_inventory.notes`
— one `TEXT` column rendered as plain text. This replaces it with something that
can be structured, linked, searched and kept honest, without ever throwing the
old notes away.
---
## The model
Three tables. See `docs/database-model.md` for the columns.
| Table | What |
|---|---|
| `documents` | One markdown document. Either a Library `page`/`folder`, or a `device` / `node` / `design` document describing exactly one thing. |
| `document_revisions` | Prior bodies, pruned to the most recent 50 per document. |
| `documents_fts` | The FTS5 search index. Optional — see *Search* below. |
**A document attaches to the device, not to the node.** Since 3.3.0 a node draws
a device and the facts live on `device_inventory`, so one device reads the same
on every canvas it appears on.
**Every link is `SET NULL` and the title is denormalized.** Deleting a device or
a canvas never destroys what was written: the document survives as an orphan and
can be re-linked or filed into the Library. Foreign keys are off in this SQLite
setup, so the delete paths clear the columns by hand through
`app/services/doc_links.py::unlink_documents` — `nodes`, `scan` and `designs`
each call it before they delete.
**A folder is a document** (`kind='folder'`). That is what allows an empty
folder, and lets a folder carry an index body.
**One document per target.** Enforced twice: a 409 in the route, and partial
unique indexes created in `init_db` (`DOCUMENT_DDL` in `db/database.py` —
`create_all` cannot express a partial index or a virtual table).
---
## The generated header
`app/services/doc_template.py` scaffolds a device document from the live facts:
addresses, hardware, one section per fingerprinted service, rack and zone
placement, canvas neighbours, custom properties. Old `notes` are appended
verbatim under `## Notes` behind an HTML-comment provenance marker.
**It is generated once and then belongs to the user.** Nothing rewrites a body
that already exists. Two things keep it from going stale silently:
- `facts_snapshot` records the facts as they read at generation time, so the UI
can say *the device has changed* and list what.
- `GET /api/v1/documents/blocks?block=…&device_id=…` renders any single block on
demand — that is what the editor's `/device`, `/services`, `/rack`… commands
insert. Regeneration is always one keystroke away, and never automatic.
A section whose data is empty is still emitted, with an italic prompt: the
document is a checklist of what has not been written yet. Sections that are
structural rather than descriptive — the rack position of an unracked device —
are dropped instead.
Service URLs in a document follow `frontend/src/utils/serviceUrl.ts`, including
its non-HTTP port denylist, so a link in a document is the link the canvas
offers.
---
## The tree
Two roots, with different natures.
**Devices** is a pivot, rebuilt client-side in
`frontend/src/documentation/tree.ts` from data the app already holds. Grouping
by zone, group, type, physical/virtual, subnet, canvas, rack, vendor, discovery
source, status, tag, or flat A–Z is instant and needs no server round trip.
- Zone and group come from walking `parent_id` up to the nearest `groupRect` /
`group` node.
- Subnet prefers a configured scanner range, else the address's own /24. A
device with several IPs appears under each subnet it reaches.
- Physical / Virtual / Host is derived: `vm`/`lxc`/`docker_container` are
virtual, `proxmox`/`docker_host` and anything drawn as a container are hosts.
- Canvas answers "on the loaded canvas or not" — the app only holds one at a
time.
**Library** is the real folder tree, ordered by `sort_order` then title.
Badges: `·` no document, `○` only the generated header, `●` written, `⚠` the
device changed since, `⏰` past its `review_every`.
---
## Editing
Markdown source on the left, live preview on the right. Source rather than a
rich editor on purpose: the body is exported verbatim, so what is typed is what
the file holds.
**Saving is explicit** — Ctrl/Cmd+S or the Save button — matching the canvas
rule that nothing persists on a timer. An unsaved body is mirrored to
`localStorage` under `homelable_docdraft:<id>` on every keystroke and cleared on
save. On reopening, a draft taken against the version being opened is *offered*;
a draft taken against an older version is discarded, because replaying it would
revert a change made elsewhere.
`/` at the start of a line opens the insert menu: the generated blocks above,
plus table, checklist, callout, wiki-link and date.
---
## Links between documents
`[[device:nas-01]]`, `[[doc:vlan-plan]]`, `[[node:<id>]]`, or a bare
`[[VLAN plan]]`; `[[…|label]]` sets the text. A device resolves by id or label,
a document by id, slug, then title, all case-insensitively. An unresolved link
renders red and offers to create the document.
Parsed as a text pass over the rendered children (`wikilinks.ts` +
`markdown/WikiText.tsx`) rather than as a remark plugin, so the file stays
ordinary markdown for anything else that reads it.
**Raw HTML is deliberately not rendered** — `rehype-raw` is absent — so a
document cannot inject markup and no sanitiser is needed.
---
## Search
`GET /api/v1/documents/search?q=` over title, tags and body.
FTS5 is **not** assumed: the SQLite shipped in the LXC and Docker images may be
built without it, the same reason `database.py` avoids JSON1. `doc_search.py`
probes once and falls back to `LIKE`, and the response says which engine
answered (`fts5` or `like`) so the UI can drop snippet highlighting. The index is
maintained from Python on every write — no triggers — and `_reindex_documents_fts`
catches up on boot after an upgrade or a restored backup.
User input is never FTS5 syntax: `build_match_query` quotes every term, so an
IP or a stray quote is data.
---
## Migrating the old notes
Non-destructive and repeatable.
1. `GET /api/v1/documents/coverage` reports `notes_unmigrated`.
2. A banner offers to migrate.
3. `POST /api/v1/documents/scaffold {"only_with_notes": true}` creates one
document per device, old notes appended verbatim, with a `migrate` revision.
4. **`device_inventory.notes` is left exactly as it was.** It is still the
column the HACS integration, the YAML export, the MCP tools and the canvas
search read. Deprecating it is a separate decision.
---
## API
| Method | Path |
|---|---|
| `GET` | `/api/v1/documents` — metadata only, filterable by `kind`, `parent_id`, `device_id`, `tag` |
| `GET` | `/api/v1/documents/{id}` |
| `POST` | `/api/v1/documents` |
| `PATCH` | `/api/v1/documents/{id}` |
| `DELETE` | `/api/v1/documents/{id}` — a folder takes its subtree |
| `GET` | `/api/v1/documents/{id}/revisions`, `/revisions/{rev_id}` |
| `POST` | `/api/v1/documents/{id}/revisions/{rev_id}/restore` |
| `POST` | `/api/v1/documents/{id}/regenerate` — erase the body and scaffold it again |
| `GET` | `/api/v1/documents/search?q=&limit=` |
| `GET` | `/api/v1/documents/blocks?block=&device_id=` |
| `GET` | `/api/v1/documents/coverage` |
| `POST` | `/api/v1/documents/scaffold` |
---
## Standalone mode
Documents are **full-mode only**. Standalone has no backend to store or search
them, and the same reasoning as ADR-001 applies: the section is hidden in the
sidebar and explains itself if reached directly, rather than being polyfilled
into `localStorage` with no search and no history.
---
## Not built yet
Export/import of the tree as `.md` files, a print/handbook view, an aggregated
open-tasks view, image upload inside a document (would reuse
`api/routes/media.py`, full-mode only), and the MCP tools — those are the
planned second lot.