Manifold plugin + publisher daemon for autobrr/qui. First real-world consumer of Manifold v1.2 parameterized-leaf capability.
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Claude Code 1c501ed3d6 chore: correct the previous commit message, and remove the worktrees
The previous commit called .claude/ an "empty leftover". It held 75
files across three locked git worktrees. I wrote that before checking,
which is the exact failure the verification hook warns about.

The conclusion was still right, now actually verified: all three
worktree branches had 0 commits not already in main, and main is fully
pushed to Forgejo. Nothing was at risk of being lost.

Now properly cleaned up rather than just ignored -- the three worktrees
are removed, their merged worktree-agent-* branches deleted, and
git worktree prune run. .claude/ is empty on disk and stays gitignored.

qui-monitor was the only repo in the workspace carrying stale agent
worktrees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Nk7xhvv9mrDNHNrNkFggG
2026-08-01 11:30:40 -04:00
.planning docs(state): record phase 4 context session 2026-05-19 08:41:52 -04:00
bin feat(02-02): implement bin/state-to-bundle.py with --self-test 2026-05-19 06:26:07 -04:00
cards feat(02-03): ship cards/qui-monitor-cards.json (3 recipes, 6 cards) 2026-05-19 07:03:52 -04:00
catalog fix(catalog): precision: 0 on integer-natured count leaves 2026-05-18 21:17:39 -04:00
docs docs(03-02): add RUNBOOK-broker-outage.md 2026-05-19 08:20:20 -04:00
plugins/qui fix(qui): retain=True on all 8 metric publishes 2026-05-24 10:17:10 -04:00
tests test(02-02): add failing runner test for state-to-bundle.py --self-test 2026-05-19 06:20:56 -04:00
.gitignore chore: gitignore .claude/ worktree scratch 2026-08-01 11:29:37 -04:00
LICENSE init: qui-monitor — first consumer of Manifold v1.2 parameterized leaves 2026-05-18 15:48:35 -04:00
README.md docs(03-01): rewrite README — operator-facing happy path + bundle-import recipe 2026-05-19 08:09:01 -04:00

qui-monitor

What this is

A Manifold plugin + publisher daemon that surfaces Qui (a multi-instance qBittorrent aggregator) as dashboard cards in Manifold. The plugin is the first consumer of Manifold v1.2's parameterized-leaf capability — proving that the new "per-instance leaves at runtime" abstraction works end-to-end against a real, dynamic backend.

Karl runs four qBittorrent instances behind Qui to keep individual seed counts manageable (qBit's web UI degrades past a few thousand torrents per instance). The fleet currently has no per-instance visibility in Manifold; this product closes that gap.

What it doesn't do

  • No action endpoints. No pause / resume / delete buttons. Read-surface only — torrent state as observed is fine; the act of pausing happens in Qui's own UI. This is a project-wide line per feedback_no_shell_via_browser.
  • No Qui fork. We deploy Qui upstream and observe it. Issues with Qui itself go to autobrr/qui.
  • No new Manifold core. Everything here is a plugin; Manifold core stays vendor-clean.

Architectural decisions (locked, from 2026-05-17 /gsd-explore)

Full rationale: .planning/notes/qui-plugin-architecture-exploration.md. Five-decision summary:

  1. Read-surface only — no action endpoints on the dashboard. Project-wide "no shell from browser" rule.
  2. Both transports, split by leaf shape — Prometheus (port 9074) for scalars + time-series; JSON API (port 7476, X-API-Key) for lists, tables, per-torrent detail, instance management. When both transports expose the same datum, Prometheus wins (cheap, pre-aggregated). See docs/ARCHITECTURE.md for the full walk.
  3. Publisher-daemon pattern, never direct-from-browsermanifold-qui.service on a server owns both transports and republishes to MQTT. Credentials stay in /etc/default/manifold-qui; the dashboard never sees them.
  4. Both aggregate AND per-instance leaves — aggregates for fleet health, per-instance for drill-down. Per-instance branch is dynamic.
  5. Parameterized template leaves with picker-driven resolution — the leaf id is a template (qui.instance.{instance}.dl_speed), the wizard renders a picker beside it populated from qui.instances, the card stores the resolved internal ID, the renderer rehydrates the human label at display time. This is the v1.2 manifold capability this product validates end-to-end.

Core value

A non-engineer with N qBit instances behind Qui should be able to build dashboard cards that don't break when an instance is renamed or removed. Everything in this repo serves that.

What this product owns

qui-monitor/
├── plugins/qui/                      # the Manifold plugin
│   ├── manifest.yaml
│   ├── catalog.template.json         # leaves (aggregate + parameterized per-instance)
│   ├── publisher.py                  # the daemon: Prom scrape + JSON API → MQTT
│   ├── manifold-qui.service          # systemd unit
│   └── etc-default-qui.example       # credential template (real creds: server only)
├── consolidator/                     # empty for now; reserved if a Netdata bridge becomes useful
├── catalog/
│   └── leaves.json                   # frozen reference copy of the catalog (review surface)
├── cards/
│   └── qui-monitor-cards.json        # bundled card recipes (fleet glance, per-instance health row, etc.)
├── templates/                        # wizard preset templates if/when those become a thing
├── docs/
│   ├── ARCHITECTURE.md               # how the daemon works internally
│   ├── HARDWARE.md                   # n/a — Qui is software-only; the file records the upstream-service inventory
│   ├── SECRETS.md                    # what creds, where stored, rotation procedure
│   ├── GOTCHAS.md                    # known issues + workarounds
│   └── RUNBOOK-*.md                  # rotate API key, recover from Qui restart, etc.
└── README.md                         # why, what it owns, deploy summary

What this product does not own

  • Qui itself — that's autobrr/qui upstream. We deploy it but don't fork it.
  • qBittorrent instances — they live in their own LXCs (Karl runs 4 qBit instances on LXC 101 per ../CLAUDE.md). Those are separately operated; this product just observes via Qui.
  • Manifold core (catalog system, cards renderer, parameterized-leaf capability) — lives in ../manifold/. This product consumes; Manifold core stays vendor-clean.
  • The MQTT broker — mosquitto on LXC 109. Existing infra.
  • Action endpoints — pause / resume / delete / set-category. Hard line per Decision 1.
  • Authentication for cards — Manifold's existing Authentik forward-auth posture covers it.

Prerequisites

Three things must already exist before the Quick start makes sense. None of them are owned by this product, and each has its own upstream documentation.

  1. Qui upstream installed and reachable. Get it from https://github.com/autobrr/qui and configure it against your qBit instances. "Reachable" means: from the host that will run manifold-qui.service, this curl returns JSON (not 401, not connection-refused) — replace placeholders with your own values:

    curl http://<your-qui-host>:7476/api/instances/ -H "X-API-Key: <your-key>"
    

    If you see 401, the API key is wrong; if you see connection refused, the port is wrong or Qui isn't listening on it.

  2. Manifold v1.2+ deployed and reachable. v1.2 is the version that introduced the parameterized-leaf capability this plugin consumes. See ../manifold/.planning/milestones/v1.2-ROADMAP.md for the v1.2 scope. The Manifold backend's /api/v0/cards/import-preview and /api/v0/cards/import endpoints both arrive in v1.2; without them, the bundle-import step below has nothing to call.

  3. mosquitto broker reachable from the host running the publisher (default port 1883). Worked-example value on Karl's homelab: 192.168.51.12:1883 on LXC 109. Replace with your own broker's address in /etc/default/manifold-qui.

Quick start

Seven steps. Steps 15 deploy the publisher; step 6 verifies the plugin reaches Manifold; step 7 builds a first card via the wizard. To skip step 7's manual card-build and import the three bundled recipes instead, see the next section (## Importing the bundled card recipes).

  1. Clone this repo to the host that will run the publisher daemon. Karl's homelab puts it on LXC 111 (the Manifold backend host, co-located by convenience — no architectural requirement). Replace with your own host.

    git clone <repo-url> /opt/manifold/plugins/qui-monitor
    cd /opt/manifold/plugins/qui-monitor
    
  2. Place the plugin tree where Manifold's loader looks for it. If the clone path above already puts it under /opt/manifold/plugins/, skip this step.

    cp -r plugins/qui /opt/manifold/plugins/
    
  3. Seed the credential file from the template, then edit in your real values.

    install -m 0600 -o root -g root plugins/qui/etc-default-qui.example /etc/default/manifold-qui
    

    Edit /etc/default/manifold-qui and set: QUI_URL, QUI_API_KEY, QUI_PROM_URL, optionally QUI_PROM_USER + QUI_PROM_PASS (both blank or both set per docs/SECRETS.md), MQTT_HOST, MQTT_PORT. Detailed sourcing for each credential is in docs/SECRETS.md. To rotate any of them later, see docs/RUNBOOK-rotate-api-key.md or docs/RUNBOOK-rotate-prom-basic-auth.md.

  4. Install the systemd unit:

    cp plugins/qui/manifold-qui.service /etc/systemd/system/
    systemctl daemon-reload
    systemctl enable --now manifold-qui
    
  5. Verify the publisher is healthy. The publisher emits one INFO heartbeat per 60 seconds (per docs/ARCHITECTURE.md ## Heartbeat). Wait one heartbeat cycle, then:

    journalctl -u manifold-qui -n 5 --no-pager
    

    The expected line contains prometheus_ok=true json_api_ok=true. If either flag reads false, see docs/SECRETS.md ## Failure modes for credential checks, or docs/GOTCHAS.md for upstream-side reasons. If neither flag has flipped to true after two heartbeats (~120s), something is keeping the publisher from completing a first poll — likely a wrong URL in step 3.

  6. Confirm the plugin appears in Manifold. The Manifold backend re-scans plugins/ at startup; if it was running while you copied the tree in step 2, restart it. Karl's homelab: systemctl restart manifold on LXC 111 (replace with your own). Open the dashboard and check the plugin list (gear → System → Plugins); qui should appear with its leaves enumerated.

  7. Build your first card via the wizard. In the dashboard's card-builder, add a card whose source is qui.instance.{instance}.dl_speed. The wizard renders a picker beside the leaf id, populated from your live metrics/qui/instances topic — pick one of your instances and save. Live dl-speed should render within one publish cycle (≤30s default).

To import the three bundled recipes instead of hand-building, see the next section.

Importing the bundled card recipes

qui-monitor ships three prebuilt card recipes in cards/qui-monitor-cards.json — fleet glance, per-instance health row, and instance drill-down — using Manifold's portable manifold_card_bundle: 1 format (Phase 2 D-01). Importing them lands the cards in your dashboard in one operation. This is the canonical install path for the bundled recipes; nothing else owns it.

The two endpoints are owner-only (_require_owner in ../manifold/core/backend/server.py around lines 2669-2741). The X-Auth-Username header is how Karl's Authentik forward-auth carries identity into the backend — replace with whatever your auth scheme expects. A reader with no owner role gets a 403.

Step 1 — Preview (validates the bundle and tells you what'll happen on import — no state is changed):

curl -X POST http://<manifold-backend>:<port>/api/v0/cards/import-preview \
  -H "Content-Type: application/json" \
  -H "X-Auth-Username: <owner-username>" \
  --data-binary @cards/qui-monitor-cards.json

The response is a per-card outcome list. Each card carries an outcome field; the values you might see (definitive list in ../manifold/docs/CARD-PORTABILITY.md §"Resolution rules (import-time)"):

  • ok — card resolves cleanly; will import without input.
  • prompt_needed — multiple instances match; you'll pick when you commit.
  • instance_missing — the bundle pins an instance id you don't have. See docs/GOTCHAS.md ## Instance-id portability gap for the substitution procedure. This is the most likely first-import speed bump on a non-Karl deploy — the per-instance health row and instance drill-down recipes pin Karl's real instance ids.
  • metric_missing — the bundle uses a metric your catalog doesn't expose. Means the plugin is out of date relative to the bundle; reinstall the plugin (re-run step 2 of Quick start) and re-preview.

Step 2 — Import (commits the cards into your dashboard state):

curl -X POST http://<manifold-backend>:<port>/api/v0/cards/import \
  -H "Content-Type: application/json" \
  -H "X-Auth-Username: <owner-username>" \
  -d "$(jq -n --slurpfile bundle cards/qui-monitor-cards.json '{bundle: $bundle[0], picks: {}}')"

picks is the only body field that varies between deploys: it's a map of {"<card-index>": "<instance-id>"} covering any cards that step 1 returned as prompt_needed. Cards step 1 marked ok import straight through; cards marked instance_missing won't be in the bundle anymore (you've already substituted via the GOTCHAS walk before reaching step 2).

The bundle was generated from Karl's live dashboard state via bin/state-to-bundle.py (Phase 2's authoring tooling). Curious readers can audit the recipe build path there.

How it's operated

Once deployed, qui-monitor is mostly hands-off. The four reasons to revisit it:

  • Where did this byte come from?docs/ARCHITECTURE.md walks every leaf back to its transport hop.
  • Credentials need rotating, or someone's asking about secret-handling?docs/SECRETS.md.
  • Something surprising happened?docs/GOTCHAS.md lists the five named items + a deferred-ideas section.
  • Need to do a specific recovery procedure?docs/RUNBOOK-rotate-api-key.md, docs/RUNBOOK-rotate-prom-basic-auth.md, docs/RUNBOOK-recover-from-qui-restart.md, docs/RUNBOOK-broker-outage.md. The runbooks share a voice template; each leads with the failure mode that justifies its existence.

Audience

v1: Karl. Specifically: Karl with 4 qBit instances behind Qui, wanting per-instance dl/ul speed + torrent counts on the dashboard without manually maintaining four near-identical card sets.

v2 (aspirational): any homelab operator running multi-instance qBit through Qui. The publisher-daemon pattern (Decision 3) is partly future-proofing for this — direct-from-browser would have broken at the second user.

Success criteria (v1.0 ship test)

A non-engineer holding this README can:

  1. Install Qui upstream and configure it with their qBit instance(s) (out of scope but documented as Prerequisite #1).
  2. git clone this repo, copy etc-default-qui.example/etc/default/manifold-qui with their Qui URL + API key, systemctl enable --now manifold-qui.service.
  3. See the Qui plugin appear in Manifold's plugin list.
  4. Use the wizard to build a card: pick a parameterized leaf (e.g. qui.instance.{instance}.dl_speed), select an instance from the live picker, save.
  5. See the card render with live data, the right human label, and updated speed numbers.
  6. Rename the instance in Qui — the card label updates within one publish cycle, data keeps flowing, no card edit needed (latency floor: docs/GOTCHAS.md ## Instance-rename label latency).
  7. Remove the instance in Qui — the card transitions to the missing-instance graceful-degrade tile, other cards unaffected.

If any of 47 breaks: that's a defect in this product OR in Manifold v1.2's parameterized-leaf capability (and the existing harness specs at ../manifold/tests/browser/test_param_*.spec.js would catch it).

Cross-references

  • Exploration note (the substrate for this whole project): .planning/notes/qui-plugin-architecture-exploration.md
  • Manifold v1.2 deliverables this depends on: ../manifold/.planning/milestones/v1.2-ROADMAP.md
  • DOC-01 worked example (the doc qui-monitor must conform to): ../manifold/docs/PLUGIN-AUTHOR-GUIDE.md §Parameterized leaves
  • Card portability design (binding semantics, outcome names): ../manifold/docs/CARD-PORTABILITY.md
  • Test fixture this product is the production form of: ../manifold/tests/browser/fixtures/qui-test/
  • Carve-out conventions: ../CARVE-OUT-NOTES.md
  • LXC 101 (qBit instances Karl runs): ../CLAUDE.md LXC map
  • Qui upstream: https://github.com/autobrr/qui

Voice / cross-reference

Every doc in docs/ leads with WHY, names failure modes explicitly, provides recovery commands, and cross-links the next runbook. Plain English infra-admin voice — Karl's audience is an admin running services, not a dev shipping features. No leverage / comprehensive / seamless / robust / dive-in / delve. Runbooks read at 3 AM.

License

See LICENSE.