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
250 lines
11 KiB
Markdown
250 lines
11 KiB
Markdown
# 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.
|
||
|
||
`[[` opens the link picker, anywhere in the line: filter by title, pick, and the
|
||
finished link is written for you. It offers documents only — a device with no
|
||
document is not a link target yet — and it addresses a document by title, or by
|
||
`[[doc:<id>]]` when another document shares that title, since a bare link
|
||
resolves by title and an ambiguous one would silently pick the first. The second
|
||
bracket is held back while the menu is open (opening it moves focus, and a
|
||
keystroke landing mid-move belongs to neither box) and given back if you cancel,
|
||
so what you typed survives either way.
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
### Backlinks
|
||
|
||
Every document lists what points at it, under **Linked from**, with the line the
|
||
link was written on and the label it was written as.
|
||
|
||
The inversion is done **server-side** (`services/doc_backlinks.py`,
|
||
`GET /documents/{id}/backlinks`), because the browser holds no bodies but the
|
||
open one: `GET /documents` is metadata-only so the tree can badge without
|
||
downloading the space. That service mirrors the resolution rules in
|
||
`wikilinks.ts` — same prefixes, same id → slug → title fallback, same
|
||
case-insensitivity — the way the generator's `service_url` mirrors
|
||
`utils/serviceUrl.ts`. Change one and change the other;
|
||
`test_doc_backlinks.py` pins the rules.
|
||
|
||
Repeated links from the same document collapse into one entry with a count, and
|
||
a document never backlinks itself. The inventory is only loaded when some body
|
||
actually carries a `[[device:…]]`.
|
||
|
||
---
|
||
|
||
## History
|
||
|
||
Every explicit save that changes the body snapshots the previous one, capped at
|
||
`REVISION_LIMIT` (50) per document; so do restore, regenerate, scaffold and the
|
||
notes migration, each recording why.
|
||
|
||
The clock-arrow button in the header opens the history rail: what each version
|
||
was (Saved, Regenerated, Restored, Migrated from notes…), when, and how big.
|
||
Selecting one replaces the body with that version — same title, same chips, same
|
||
rail — and **Changes** turns it into a line diff against the current body, with
|
||
long unchanged runs collapsed. **Restore** brings it back, snapshotting 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, so fifty versions cost one small request.
|
||
|
||
The diff is a plain LCS over lines (`documentation/diff.ts`), after the common
|
||
head and tail are trimmed — no diff library, and a pathological pair of long,
|
||
wholly different bodies degrades to "replaced wholesale" instead of building a
|
||
250k-cell matrix.
|
||
|
||
---
|
||
|
||
## Search
|
||
|
||
`GET /api/v1/documents/search?q=` over title, tags and body.
|
||
|
||
The same index answers the canvas' own search modal (Ctrl/Cmd+K), which lists
|
||
matching documents under the nodes and pending devices; picking one switches to
|
||
Documentation and opens it. The request is debounced and skipped below two
|
||
characters, and a failure there costs only the document rows — the node and
|
||
device halves filter lists already in memory.
|
||
|
||
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}` |
|
||
| `GET` | `/api/v1/documents/{id}/backlinks` — the documents linking here |
|
||
| `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.
|