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
driftbadge 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#
| Screen | Intent | State | What drift looks like |
|---|---|---|---|
| Interfaces | interfaces … in the config tree | show interfaces <kind> <name>: link state, MTU, addresses, MAC, qdisc, RX/TX counters | An 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 routes | protocols static … | the kernel forwarding table, from show ip route | Each row is in FIB or declared, not installed, and the tiles count both (ui/src/pages/Routes.tsx) |
| QoS | the qos policy attached to an interface | the qdisc the kernel actually installed, read off the interface | GET /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) |
| Apps | container name … | the router's own container list and a reachability probe | Declared but not running, running but not declared, and an honest not probed where the agent runs off-router (docs/apps.md) |
| High availability | high-availability … | keepalived's per-group state from show vrrp | running: false when the router says the data is not available, rather than an empty table (agent/gaps.go) |
| IPsec, OpenVPN | the vpn ipsec / interfaces openvpn configuration | the associations and the per-mode status text the router prints | The same running: false treatment |
| Reconcile | the desired-state file | the whole running configuration | GET /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:
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.
| Reading | Freshness |
|---|---|
| The dashboard's throughput stream | A fresh, uncached read of show interfaces every 2 seconds over a WebSocket (agent/main.go handleStream) |
| The throughput series behind the sparklines | Sampled every 5 seconds, 240 samples kept in memory — twenty minutes, not persisted (agent/metrics.go) |
| Other operational reads | Cached 3 seconds, served stale while a refresh runs (agent/cache.go, opModeTTL) |
| Per-interface detail | Cached 20 seconds (interfaceDetailTTL) |
| Configuration reads | Cached 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_acctenabled 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 dnsandservice dhcp-serverare configured. The agent supervises no external process (agent/main.gohandleDaemons).
See also#
- The configuration tree — where intent lives.
- Desired state — the other kind of drift, and the loop that acts on it.
- What the agent asks the router — why the freshness table looks like that.
- Dashboard and Interfaces — the screens.
- Telemetry — the live stream itself.
- Metrics reference — every exported series.
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