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. Where the two disagree, this documentation calls the disagreement drift.
Every screen in Wheelhouse shows both, in visually distinct forms, and names the disagreement where it finds one.
Reading a page#
- 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
driftbadge means the two do not match, and the badge says how.
The pulse is the only animation in the product. It means the number is arriving rather than
remembered, and it is disabled under prefers-reduced-motion.
Where the comparison is 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 |
| Static routes | protocols static … | the kernel forwarding tables, from show ip route and show ipv6 route | Each route is Installed or Not installed, and the Static routes tile counts the ones not installed |
| QoS | the qos policy attached to an interface | the qdisc the kernel installed, read off the interface | GET /api/qos/stats returns {policy, qdisc, rx, tx} per shaped interface |
| Apps | container name … | the router's own container list and a reachability probe | Declared but not running, running but not declared, and not probed where the agent runs off-router |
| 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 |
| 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 |
Nothing reconciles in the background and reports success. Where Wheelhouse notices a difference it puts it on the page, and where it can act on one it hands the operations to the Commit Bar to be committed by a person.
Two things called drift#
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 exists
only when the agent was started with --reconcile-file, and it answers a different
question: not whether the kernel is doing what the config says, but whether the config says
what your 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. 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".
How live is live#
Different numbers on the same screen have different ages, because every read costs the router work — see What the agent asks the router.
| Reading | Freshness |
|---|---|
| The live stream, shown by the router chip in the top bar | A fresh, uncached read of show interfaces every 2 seconds over a WebSocket |
| The throughput samples behind the Overview's current rates | Sampled every 5 seconds, 240 samples kept in memory — twenty minutes, not persisted |
| Other operational reads | Cached 3 seconds, served stale while a refresh runs |
| Per-interface detail | Cached 20 seconds |
| Configuration reads | Cached until a commit invalidates them |
The stream bypasses the cache, because a stream labelled live cannot replay a three-second cache. An unreadable router arrives as an error frame naming the failure rather than as silence.
What is not compared#
- 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 observable on its own page. Network → IPv6 shows v6 addresses, neighbours, routes and ip6 counters, and Static routes shows the v6 forwarding table beside the v4 one. The neighbour tables still read the IPv4 table, and the uplink model has no v6 concept.
- The router's uplink verdict is the router's, and the agent's numbers are its own. The Health badge reports whether the failover daemon's route is in the forwarding table and what the load balancer says. Beside it the agent probes each uplink itself every five seconds and shows per-target round-trip time, jitter and loss — evidence, not the router's verdict, and the page says per uplink whether the router would act on it.
- 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 dnsandservice dhcp-serverare configured. The agent supervises no external process. Its third panel is UPnP, which is the agent's own service rather than the router's: it reports the agent's UPnP settings and whether its listeners are bound.
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.
- Overview and Interfaces — the screens.
- Telemetry — the live stream itself.
- Metrics reference — every exported series.