Live Updates
Use a server-rendered snapshot followed by Server-Sent Events (SSE) for one-way live status. Overseer's sidebar and Server Overview are the first examples.
Contract
- The normal GET returns the full page with the installed component catalog and the last completed health snapshot. On a cold start, status shows as pending so a slow probe cannot hold up page rendering. Links work without JavaScript.
- One authenticated SSE connection belongs to the stable page shell, outside the htmx fragment target. Navigation swaps the page content without reconnecting the stream.
- The stream sends a complete baseline on connect or reconnect, then named events only when state changes. A browser that missed events catches up from the next baseline.
- A single sampler per web process collects health for all connected viewers. Subscriber queues hold only the newest complete state. A slow viewer cannot make memory use grow without bound.
- SSE comments keep the connection alive; they do not run a health check. Connections expire periodically so authentication is checked again.
- Every event stream's first frame is an
id:naming its connection record (the connections middleware adds it). The browser's own retry sends it back asLast-Event-ID, which is how Server > Connections tells a reconnect from a new page opening the same stream. - A stream that may go quiet for a while (followed log lines) is wrapped in
overseer_live.heartbeat(frames), which sends an SSE comment every 15 seconds without a frame, ends the stream at the expiry above, and closes its source when the browser leaves.
The Overseer implementation is app/components/web_frontend/overseer_events.py.
Its producer uses the existing cached get_system_status() result. Page
navigation reads last_system_status() and the registered health check names,
so changing sections never triggers a health walk. The SSE sampler performs
the first collection after a cold page load. The stream authenticates before
opening and closes its database session before it starts sending events.
The sidebar listens with htmx's SSE extension and sse-swap on each status
indicator. A component page's sub-menu shows the same status dot and listens
for the same event, so the two never disagree. The Server Overview listens for
server-overview, which replaces its figures and resource panel when they
change. The other Server sections (Performance, Traffic, Load Tests, Routes,
Lifecycle) are ordinary HTMX requests that read their sources when opened. Each event
contains server-rendered HTML. Every SSE target sets hx-target="this" so
it cannot inherit a surrounding navigation link's hx-target. The browser
needs no second status model or JSON-to-DOM mapping.
Deployment boundary
The current fanout is process-local: each web process with viewers runs one sampler. This removes per-viewer polling without requiring Redis in a basic stack. In a deployment with many web processes, a shared publisher and Redis Streams can replace the process-local producer while retaining the same page, SSE route, event names, and snapshot contract. Worker activity already uses Redis Streams and SSE when the worker component is installed.
SSE does not make external health checks event-driven. Database reachability, CPU, and similar values still need a bounded server-side sampling cadence. Jobs and worker lifecycle events can publish immediately because those changes already originate inside the application.
Time series and live charts
Anything the app polls or counts can be kept as a time series and charted
live. A sampler names what it reads; read returns a Sample: numbers by
series name, and the reading as a whole for the views that show it:
from app.core.series import Sample, Sampler
async def queue_depths() -> Sample:
depths = {"system": 12, "media": 0}
return Sample({f"{q}:depth": n for q, n in depths.items()}, latest=depths)
SAMPLERS = [Sampler("containers", ui_runtime.sample), Sampler("queues", queue_depths)]
Samplers are listed in app/services/system/samplers.py and run in the
webserver from startup. Each tick's numbers are kept for an hour under
series:<sampler>:<name> in the series store (app.core.series_store: Redis
when the stack has it, on the shared cache's database, so every process reads
the same history; process memory otherwise), listed in an index set per
sampler so reads never scan the keyspace. One process samples each tick.
A sampler made with history=True (the containers and the host) is kept
longer too: each minute's highest value for a day and each five minutes'
for a week (series.TIERS), folded from the raw hour as each minute ends.
The raw hour is a sorted set a series, read by time; a tier is a list a
series, appended in order and cut to its length, about 20 bytes a point
against a sorted set's 120, so a week of a dozen containers' history takes a
few megabytes of Redis's 64.
The highest rather than the average, so a spike from last night is still
there. series.read serves a window longer than the hour from the tier
that spans it; asked for a long window, a sampler without history gives
its hour. A chart offers series.windows(*samplers): series.HISTORY_WINDOWS
(adding 2h, 6h, 12h, 1d and 7d) where every sampler behind it keeps
history, and a live view re-reads a long window's charts only when its
tier moves (series.Redraw).
Polling costs only while someone looks. A view reads the sampler's last
reading (series.latest) and marks it watched (series.watch); a watched
sampler reads every interval (a second), an unwatched one every
idle_interval (15 seconds), enough to keep the charts' history. Every viewer
is served from the one reading, so ten open pages cost what one does.
Something that happens rather than something polled is a point of its own:
series.record("llm:qwen2.5:7b:latency", 1.4), as every LLM call does when it
finishes.
What Overseer samples today: the containers behind each page, Ollama on the
host (the Inference page, the Flet Ollama modal, and the model loads and
evictions its Activity tab lists), the Redis keyspace map, and the worker
queues (each queue's waiting and finished counts, so its rate and drain read
the same for every viewer and survive a reconnect). A page whose server can
run outside Docker registers its own note and charts beside its sampler
(samplers.HOSTS, read through ui_runtime.host_of), so a stack without
that component carries none of them.
series.read(prefix, window) gives a window back and series.chart(...)
turns it into chart_panel data, one line per series on the times they
share, the axis spanning the window up to now. A window longer than
MAX_POINTS seconds is averaged into buckets on fixed boundaries, so each
bucket keeps its place as the window moves. The windows a chart offers are
series.WINDOWS (15m, 30m, 1h, labelled like every other range row; 2h, 6h,
12h, 1d and 7d too where history is kept), picked
with range_chips on the web and DateRangeChips in Flet; the choice lives
in the query string (?window=1800) and the section's stream follows it.
A chart goes live with chart_panel(..., live="<event>") inside an element
with sse-connect: the stream re-sends chart_data(id, data) as that event
and charts.js updates the drawn chart in place: a time chart ("x": "time")
slides along, its oldest points leaving and new ones joining the same line.
chart_panel(..., empty="...") is said over a chart with nothing in its
window. fragments_events sends several fragments (a table and its charts) on
one stream. The data's "format" (percent, bytes, seconds, money)
reads the same in the Flet charts (LineChartCard.from_chart).
Overseer's Container section is the first user: CPU and memory per container
over the last 15 minutes. A server outside Docker charts what it reports of
itself instead (ui_runtime.host_of): Ollama on the host shows the memory each
loaded model holds (the inference sampler, which also serves the Inference
page's Models table) and each call's tokens per second and latency.