Wheelhouse docs

State, intent and drift#

A router has two truths. Intent is the configuration tree: what somebody declared. State is what the kernel and the daemons are doing right now: which links are up, which routes are in the forwarding table, which qdisc is attached, which containers are running. They are usually the same and occasionally not, and the gap between them is where a bad hour comes from. Wheelhouse's rule is that a page shows both, in visually distinct forms, and names the disagreement where it finds one. That disagreement is what this documentation calls drift.

The rule, and how to read a page by it#

The second of the four rules in docs/ui.md:

State and intent are visually distinct. Configured values render as plain fields; live values carry a pulse dot or a counter column. Where they disagree the page shows a drift badge naming the disagreement.

So on any screen:

  • a plain field is something the configuration says;
  • a value in monospace with a pulse dot beside it, or in a counter column, came off the router just now;
  • a badge means the two do not match, and the badge says how.

The pulse is the only animation in the product and it means exactly one thing — this number is arriving, not remembered — and it is disabled under prefers-reduced-motion.

Where the comparison is actually made#

ScreenIntentStateWhat drift looks like
Interfacesinterfaces … in the config treeshow interfaces <kind> <name>: link state, MTU, addresses, MAC, qdisc, RX/TX countersAn interface the router reports but the configuration does not declare gets a badge reading present on the router but not in the config tree (ui/src/pages/Interfaces.tsx)
Static routesprotocols static …the kernel forwarding table, from show ip routeEach row is in FIB or declared, not installed, and the tiles count both (ui/src/pages/Routes.tsx)
QoSthe qos policy attached to an interfacethe qdisc the kernel actually installed, read off the interfaceGET /api/qos/stats returns {policy, qdisc, rx, tx} per shaped interface, so the page can put the two next to each other (agent/main.go handleQosStats)
Appscontainer name …the router's own container list and a reachability probeDeclared but not running, running but not declared, and an honest not probed where the agent runs off-router (docs/apps.md)
High availabilityhigh-availability …keepalived's per-group state from show vrrprunning: false when the router says the data is not available, rather than an empty table (agent/gaps.go)
IPsec, OpenVPNthe vpn ipsec / interfaces openvpn configurationthe associations and the per-mode status text the router printsThe same running: false treatment
Reconcilethe desired-state filethe whole running configurationGET /api/drift — a different kind of drift; see below

Two things called drift#

They are related and they are not the same, so the documentation keeps them apart.

Per-object drift is state against intent, on one screen, as in the table above. It is computed in the browser or in a handler at the moment you look, from a read of the router. Nothing is stored.

Desired-state drift is the running configuration against a file you wrote. It only exists when the agent was started with --reconcile-file, and it answers a different question: not "is the kernel doing what the config says", but "does the config say what my file says". GET /api/drift reports it:

bash
curl -sk -H "Authorization: Bearer $T" "$R/api/drift"
# {"managed":true,"file":"/config/wheelhouse/agent.yaml","mode":"stage",
#  "drifted":true,"ops":2,"checked_at":"2026-09-02T10:27:07Z"}

mode is off for an agent that is managing no file, which is the default — a check the handler makes deliberately, because it used to answer stage for an agent that was managing nothing (agent/desired.go, handleDrift). The matching metrics — wheelhouse_desired_managed, wheelhouse_desired_drift, wheelhouse_desired_pending_ops — are exported only while a file is being managed, so their absence means "not managing a file" rather than "no drift" (docs/deploy.md).

How live is live#

Different numbers on the same screen have different ages, on purpose, because every read costs the router work — see What the agent asks the router.

ReadingFreshness
The dashboard's throughput streamA fresh, uncached read of show interfaces every 2 seconds over a WebSocket (agent/main.go handleStream)
The throughput series behind the sparklinesSampled every 5 seconds, 240 samples kept in memory — twenty minutes, not persisted (agent/metrics.go)
Other operational readsCached 3 seconds, served stale while a refresh runs (agent/cache.go, opModeTTL)
Per-interface detailCached 20 seconds (interfaceDetailTTL)
Configuration readsCached until a commit invalidates them (configTTL is an hour, and only a backstop)

The stream is the one that says "live", so it bypasses the cache: a telemetry stream replaying a three-second cache is not live, and the panel wearing the label says so. An unreadable router arrives as an error frame naming the failure, not as silence, because silence on a live panel looks like an idle router.

What is not compared#

Being honest about the holes is the point of the rule, not an exception to it.

  • The firewall's rules are not compared with the kernel's ruleset. Hit counters are joined from firewall statistics, which tells you a rule is being matched. A per-object nftables ruleset view is not built.
  • IPv6 is configurable and not observable. There is no v6 route view, and the uplink model has no v6 concept, so v6 drift is not shown anywhere (README.md, Status).
  • Uplink health is the router's verdict, not a probe of Wheelhouse's own. The agent probes no targets; it reports whether the failover daemon's route is in the forwarding table and what the load balancer says. There is no per-target latency or loss history (docs/multi-wan.md).
  • Conntrack byte counters need nf_conntrack_acct enabled on the router; without it they are absent rather than zero.
  • Companion services report configuration state. The Companions page reads whether the router's own service upnp, service dns and service dhcp-server are configured. The agent supervises no external process (agent/main.go handleDaemons).

See also#

Checked against#

docs/ui.md · agent/main.go · agent/cache.go · agent/metrics.go · agent/desired.go · agent/gaps.go · ui/src/pages/Interfaces.tsx · ui/src/pages/Routes.tsx · docs/apps.md · docs/multi-wan.md · docs/deploy.md · README.md · PLAN.md §8

Updated 2026-09-02 concepts drift monitoring