Wheelhouse docs

What the agent asks the router#

The agent has one way to reach the router: the router's own HTTP API, over HTTPS. It runs no commands, links against no router code and opens no shell — the binary imports no netlink library and grep -rn "os/exec" agent/ finds nothing. Everything the web UI shows came back from that API. Two properties of the API shape the whole design: a call forks a process on the router, and the API serialises, so parallel calls are no faster than sequential ones. Every cache, every interval and every refusal to poll in this product is a consequence of those two sentences.

The cost of a call#

Each call into the router's HTTP API forks cli-shell-api on the router, and a read takes between 150 and 800 milliseconds. Concurrency does not help: seven parallel reads were measured taking as long as seven sequential ones (agent/cache.go, and docs/deploy.md "What the agent costs the router"). So the agent budgets calls, not intervals. The only way to stop every page from blocking on the router is to not ask the router every time.

The read-through cache#

Two freshness classes, because the two kinds of read are different in kind rather than in degree (agent/cache.go):

ClassWindowWhy
Configuration reads (showConfig, the command rendering, the raw form)Until a commit invalidates them; configTTL is one hour and is only a backstop against a missed invalidationThe configuration changes only when somebody commits, and the agent is the thing that commits. This is correct, not merely fast.
Operational reads (op-mode show)opModeTTL, 3 secondsThey drift constantly. Long enough to serve a page's fan-out and a burst of navigation from one fetch, short enough that counters stay live.
Per-interface detail (show interfaces <kind> <name>)interfaceDetailTTL, 20 secondsOne router call each, for facts that change on the scale of minutes.

Both are served stale-while-revalidate: a stale hit returns immediately and starts a single background refresh, and a key already being refreshed is not refreshed again. Only the very first read of a key ever blocks.

Four details in there are load-bearing:

  • A transport failure is never cached. A router that could not be reached is retried on the next request rather than remembered as broken.
  • A "path is empty" answer is cached. It is a stable negative and it is invalidated by a commit like any other configuration read.
  • An invalidation generation counter guards the race. A fetch that began before a commit may finish after it, carrying the pre-commit answer; storing that with a fresh timestamp would serve stale configuration for the whole window, so the entry is left stale instead.
  • A background refresh is detached from the request that noticed it, with a 30-second ceiling of its own. A browser that navigated away must not cancel the read that would have kept the next page fast.

A commit invalidates every configuration-derived entry — cfg, cmds and raw — and triggers a re-warm (agent/vyos.go, invalidateConfig).

The primer#

A background goroutine warms the cache so the browser almost never waits (agent/primer.go). At start-up it fetches the configuration subtrees every page reads and the op-mode reads behind the dashboard and the hot pages. After that:

  • Configuration entries stay warm on their own, because they are invalidated by commits rather than by time. A 10-second ticker re-checks configuration only, so a commit's invalidation is repaired quickly and every other pass is a map lookup.
  • Op-mode reads are not refreshed on a timer. Each one forks a process on the router, and a page that hits a stale entry is served the stale value at once and refreshed in the background — so a periodic refresh bought nothing and cost an idle router a steady load.
  • The calls are deliberately not staggered. The router serialises, so spacing them does not lower the peak; it only makes the warm-up take longer.

What an idle router is actually asked#

SourceRate
Throughput samplerOne show interfaces counters every 5 seconds — one call for every interface, not one per interface
An open DashboardOne show interfaces every 2 seconds, uncached, for the live stream
Per-interface detailAt most one call per interface per 20 seconds, and only while something is looking
The primerConfig subtrees at start and after a commit; op-mode reads at start

Idle, that is about a dozen calls a minute. The number is not decorative: on a two-vCPU bench router, the earlier design that sampled each interface separately held the load average at 15, which is what motivated the single-call sampler and the budget-not-interval rule (docs/deploy.md).

Steady-state, every page endpoint answers from the cache in well under a millisecond. The UI's loading indicator self-delays 400 milliseconds for that reason — a warm response paints the page before the indicator would appear, so the indicator only shows up when something is genuinely slow (ui/src/components/ui.tsx, Loading).

Timeouts, and who gets cancelled#

The agent keeps three HTTP clients for the router, because one timeout cannot be right for all of it (agent/vyos.go):

ClientTimeoutUsed for
reads30 secondsshow, retrieve
writes3 minutes/configure, /config-file — a container commit or a rollback on slow hardware runs well past 15 seconds, and timing out mid-commit leaves the caller unable to tell whether it landed
slow15 minutescontainer image pulls, system images, traceroute, reboot, power off — bounded by the uplink, not the CPU

On top of that, one browser request may spend at most 20 seconds talking to the router in total (routerBudget), and the reads it makes are cancelled when the browser goes away. A page that fans out to six sequential reads used to be allowed six times the read timeout — three minutes of a held connection with nothing on the other end.

A commit is deliberately exempt. It uses the daemon's own client and is not cancelled by a closed tab, because a commit that was abandoned halfway is worse than a commit nobody is watching.

Knowing whether the router is there#

Every round trip records its outcome, so "is the router reachable right now" has an answer outside the page that happened to fail (agent/vyos.go, routerHealth). Reachable means the most recent answer was a good one — not that a call was tried recently, which is why the timestamps and the last error come with it. A router that answered with an error of its own is reachable; it is the transport failures that mean gone.

The same counters feed /metrics: wheelhouse_vyos_calls_total and wheelhouse_vyos_failures_total, plus the cache's hits, stale hits and misses. A cache that has quietly stopped hitting is the difference between a fast appliance and a slow one, and there was no way to see it.

Two consequences worth planning around#

Do not write a script that polls a per-object endpoint on a timer. Each call is work on the router. Read the aggregate endpoints, or scrape /metrics, which the agent serves from what it already knows.

A page can be a few seconds behind, on purpose. If you need a number that is certainly current, the telemetry stream bypasses the cache and a commit invalidates the configuration reads immediately. Everything else is within its window — see State, intent and drift for the freshness table.

See also#

Checked against#

agent/cache.go · agent/primer.go · agent/vyos.go · agent/metrics.go · agent/main.go · agent/go.mod · ui/src/components/ui.tsx · docs/deploy.md

Updated 2026-09-02 concepts performance agent