The shape of it
There is no live connection of any kind. No websockets, no LiveView, no
polling. The dashboard is a static Svelte SPA that makes ordinary
GET requests to a JSON API, and every one of those requests is
caused by a URL change.
SQLite (session_usage_hourly + devices)
│ Ecto, one grouped query per request
TokenTracker.Dashboard.report/2 ← all aggregation lives here
│ plain map
ReportController → Phoenix JSON
│ GET /api/report?period=…&view=…&tz=…&device=[…]
SvelteKit load (+page.ts) ← runs in the browser only
│ { report: Promise }
+page.svelte → <svelte:boundary> → ReportView → BarChart / LineChart
The single most important design decision: the URL is the source of
truth. Controls do not hold state. Clicking "1W" writes
?period=week, which is a navigation, which re-runs
load, which issues a new request. The report follows the URL, and
a pasted link reproduces exactly what the sender saw.
The comments in +page.svelte record the previous design: the
page kept its own copy of the query and wrote to the URL with
replaceState. That does not re-run load, so the
address bar changed while the report did not.
Data at rest
The dashboard never touches raw JSONL transcripts or the
usage_events table. It reads one pre-aggregated table, which is
what makes a request cheap.
| Column group | Columns | Role |
|---|---|---|
| Composite key | device_id, session_key, hour_utc,
project, agent, provider,
model, pricing_tier |
Every dimension the UI can group or filter by is part of the primary key |
| Measures | input_tokens, output_tokens,
reasoning_tokens, cache_read_tokens,
cache_write_tokens, session_starts |
Summed in SQL |
Rows arrive by two routes, both outside the request path:
- Local collection.
Schedulerticks on an interval, runsCollector.collect/0over the agent history files, thenSessions.reconcile_local/2. For each dirty session,replace_snapshot/1deletes that session's hourly rows and re-inserts them. Sessions are replaced wholesale, never patched. - Remote devices. Clients push revisioned session
snapshots to the host over Erlang distribution
(
Sync.Client→Sync.Server), which land in the same table under their owndevice_id.
So the dashboard is always reading a settled table. A collection happening mid-request cannot produce a half-written session — it swaps one session's rows inside a transaction.
The devices table is joined on every query, because the
device dimension is devices.name while the other three
are columns on the usage table itself.
Serving the app
The HTTP endpoint is not started by the OTP application. It is started by
Runtime.activate/1, and only under two conditions:
def child_specs(%{role: "host", web_enabled: true} = config) do
[{TokenTracker.Sync.Server, config}, {Scheduler, config}, TokenTrackerWeb.Endpoint]
end
A client node or a host with web.enabled = false runs the
collector and sync loop with no web server at all. When it does start, Bandit
binds to 127.0.0.1:4000 by default, and
Runtime.parse_ip/1 rejects any bind address that isn't loopback —
this dashboard is not addressable off the machine.
| Route | Handler | Returns |
|---|---|---|
/_app/* | Plug.Static | Hashed JS/CSS/fonts, immutable, one year |
/theme.js, /robots.txt | Plug.Static | no-cache |
GET /api/report | ReportController | The entire dashboard payload |
GET /api/system | SystemController | Device roster and sync health |
GET /api/healthz | HealthController | {"ok": true} |
/api/* (other) | NotFoundController | JSON 404, so a typo never returns HTML |
GET /*path | SpaController | priv/static/index.html |
The catch-all is what makes deep links work: /?period=week and
any other path return the same HTML shell, and the client router takes over.
SecurityHeaders sets a strict CSP, adds
cache-control: no-store to every /api/ response, and
for the SPA shell computes SHA-256 hashes of its inline scripts so the CSP can
allow exactly those and nothing else.
Boot and the first fetch
+layout.ts is one line — export const ssr = false —
and the build uses adapter-static with
fallback: 'index.html', output to ../priv/static.
Nothing is rendered on the server. The sequence is:
- Browser gets the static shell, which paints a
#initial-loadingbanner from inline CSS. - The SPA hydrates;
+layout.svelte'sonMountremoves that banner and applies the saved theme. +page.tsloadruns. It callsdepends(reportDependency)— the string'tracker:report'— registering a hand-rollable invalidation key.- It builds the query with
reportSearch(url, browserTimeZone)and firesfetch('/api/report' + search).
The time zone comes from
Intl.DateTimeFormat().resolvedOptions().timeZone unless the URL
carries a tz. This is one of the reasons SSR is off — the server
has no idea what zone the viewer is in, and the whole report is bucketed by
local time.
load returns { report: Promise<ReportResponse> }
without awaiting it. Navigation therefore completes immediately and the
page's <svelte:boundary> owns the pending and failed
states. Awaiting inside load would make a failure replace the
whole dashboard with the error page; instead a failure is an inline notice
with a retry button, and the controls stay usable.
Inside a /api/report request
ReportController.show/2 is four lines. Everything real is in
TokenTracker.Dashboard.report/2, which runs this pipeline:
1. Validate
validate_params/1 rejects anything outside
day|week|month, device|project|agent|model, a real
IANA zone, and filter arrays of at most 10 values of at most 160
characters. Failures return 400 with a message. Filter
params arrive as a JSON array in a single query parameter; the server
Jason.decodes it and falls back to treating the raw string as one
value.
The QueryParams plug wraps fetch_query_params so a
malformed query string returns {"error":"malformed query"} as JSON
rather than crashing into an HTML error page.
2. Build the time window
| Period | Buckets | Bucket key | Label |
|---|---|---|---|
| day | 24 hours, floored to the hour | UTC ISO-8601 | %-I %p in the viewer's zone |
| week | 7 local days | Local ISO date | %b %-d |
| month | 30 local days | Local ISO date | %b %-d |
Day and month boundaries are computed as local midnight and shifted to UTC,
with explicit handling for DST: an ambiguous local midnight takes the first
instant, a gap takes the instant after it. Rows land in buckets via
bucket_key/2, which converts each row's hour_utc to
the report zone once, at the point the row is read.
3. Query
Four queries, each doing its aggregation in SQLite rather than in Elixir:
query_rows/2— one row per (hour, provider, model, pricing_tier, dimension), with the token columns summed. Filters are applied asWHERE … IN, so rows that will be discarded are never materialized.query_sessions/3— sessions per dimension, counted asCOUNT(DISTINCT device_id || ':' || session_key). A session spans many hourly rows and often several models, so summing a per-row column would multiply-count it.query_options/1— the filter menus. One grouped scan yields every distinct (device, project, agent, model) combination in the window, and the four menus are derived from that in memory.any_usage?/1— whether the window has any usage at all, ignoring filters, which is what separates "no data yet" from "your filters match nothing".
4. Price
The distinct (provider, model) pairs go to
Pricing.load/1, which resolves rates from a models.dev catalog
cached on disk for 24 hours with ETag revalidation. Decoding that multi-megabyte
catalog dominated response time, so the decoded term is memoized in
:persistent_term, keyed on a SHA-256 of the file's bytes — mtime is
only second-resolution and would serve a superseded catalog after a same-second
replacement. Cost is per row, using the row's stored
pricing_tier so long-context pricing is applied as it was at
collection time.
The loader is injectable via
:dashboard_pricing_loader, which is how the tests avoid the
network.
5. Assemble
build_report/6 walks the priced rows once, accumulating
totals keyed by dimension and by bucket. (A comment notes the previous version
rescanned the row set per bucket and again per series, growing as buckets ×
series × rows.) It then takes the top 10 dimensions by tokens; everything else
collapses into a single Other series whose session count is a
fresh distinct count across the collapsed values — not the sum of theirs, since
one session can touch several.
The response:
{
"report": {
"period", "view", "timeZone",
"series": [{ key: "series-0", label, tokens, isOther, count }],
"bars": [{ key, label, datetime, tokens, cost, segments: [{key, tokens}] }],
"totals": [{ key, label, counters: {input, output, reasoning,
cacheRead, cacheWrite, sessions},
tokens, cost, isOther, count }],
"combined": { …same shape… },
"options": { devices, projects, agents, models },
"hasUsage", "hasMatches", "truncated"
},
"stale": bool, "staleDevices": [names],
"pricing": { source, fetchedAt, missingModels, warning }
}
Series get opaque keys (series-0…) and bars reference them, so
the chart never re-derives which segment belongs to which series.
What happens when you change a filter
This is the loop worth internalizing. Every control does the same thing.
onclick → params.period = 'week' // runed/kit useSearchParams
→ URL becomes ?period=week // a real navigation, noScroll
→ SvelteKit re-runs +page.ts load // because a read searchParam changed
→ reportSearch(url, tz) // rebuilds the query
→ fetch('/api/report?…') // new promise on data.report
→ navigating.to is truthy // ReportView dims, aria-busy
→ boundary re-awaits, charts animate to the new figures
Checkboxes go through toggleFilter, which reads the current
array, adds or removes one value, enforces the 10-value cap client-side, and
assigns back to params[key] — the same navigation.
Dependency granularity
reportSearch reads every parameter by name via
searchParams.get(...). That is deliberate and load-bearing:
SvelteKit records a load dependency per parameter accessed through
get. Iterating searchParams or stringifying it would
register a dependency on the whole URL and re-run load on any
change at all.
Validation on both ends
The client applies the same rules the server enforces —
effectivePeriod, effectiveView,
filterValues — and the buttons render from those resolved values
rather than the raw parameters. A hand-edited link with
?period=forever renders a day report with "1D" highlighted, instead
of a day report with "1W" highlighted or a 400. Out-of-range values are
replaced, not rejected.
query_options/1 applies the window but not
apply_filters. If the menus only listed values surviving the
current selection, filtering to one device would remove every other device
from the menu and you could never widen the selection again. The client also
unions in the currently-selected values before sorting, so a selection stays
visible even if it has no rows in the new window.
The one control that doesn't fetch
Bars vs. lines is a rendering choice with no bearing on the data.
reportSearch never reads chart, so it registers no
dependency on it — toggling writes ?chart=lines to the URL,
load does not re-run, no request is made, and
+page.svelte simply swaps BarChart for
LineChart against the report already in hand.
There is a test asserting exactly this: two URLs differing only by
chart must produce an identical request string.
Pending, stale, and failed
| State | Driven by | What you see |
|---|---|---|
| First load | boundary's pending snippet | Skeleton rows |
| Subsequent load | navigating.to → pending prop | Previous report dims to 0.62 and animates to the new figures |
| Request failed | boundary's failed snippet | Inline notice + "Try again"; controls stay live |
| Data is late | response.stale | Named devices that haven't reported in 15 min |
| No rows anywhere | hasUsage: false | "No usage yet" |
| No rows after filtering | hasMatches: false | "No matching usage" |
| >10 series | truncated: true | Note explaining the Other bucket |
Two subtleties in the failure path:
- Retry needs both halves. Resetting the boundary alone
would re-await the promise that already rejected. So
retry()callsinvalidate(reportDependency)first — that re-runsloadand produces a fresh promise — and only then callsreset(). It resets in afinally, so a retry that also fails reports the new failure rather than leaving a dead button. - Multiple boundaries, one reset. The header total, the
time zone label, and each filter panel all await the same report in their own
boundaries, but only the report boundary has a
resetto hand. AretryTokencounter is bumped on retry and the others are wrapped in{#key retryToken}, so they rebuild together instead of staying stuck on their failure text.
stale is computed server-side by
stale_devices/2: for a local device the freshest of
last_collection_at and its newest snapshot; for a remote device the
freshest of last_sync_at and its newest snapshot. Anything older
than 15 minutes, or never seen, is named.
The system page, for contrast
/system uses the conventional shape and is worth comparing:
export const load: PageLoad = async ({ fetch }) => {
const response = await fetch('/api/system');
if (!response.ok) error(response.status, 'failed to load system status');
return (await response.json()) as SystemResponse;
};
It awaits, and it throws error(). So navigation blocks
until the data arrives and a failure renders +error.svelte for the
whole route. That is fine here — there are no controls to keep alive and nothing
to preserve on screen. It also takes no parameters, so nothing re-runs it.
Dashboard.system/0 joins the device roster against per-device
usage and snapshot recency, and reports the outbox depth,
last_collection_at, last_sync_at, and — deliberately
vague — "Synchronization needs attention" rather than the raw error
text.
Dev vs. installed
| Development | Installed | |
|---|---|---|
| HTML + assets | Vite dev server on 127.0.0.1 | SpaController + Plug.Static from priv/static |
/api | Vite proxy → http://127.0.0.1:4000 | Same origin, same port |
| Build step | — | mix assets.build → vite build → ../priv/static |
Because the proxy makes /api same-origin in dev too, no code
anywhere knows an API base URL. Every fetch is a root-relative path. The release
ships the compiled dashboard inside the Mix release, which is why the target
machine needs no Node or pnpm.
Notes and one loose end
- Everything is per-request. No server-side report cache,
and
/apiresponses carrycache-control: no-store. The speed comes from the pre-aggregated table, SQL-side grouping, the single pass inbuild_report, and the memoized pricing catalog. - Series colors are keyed on identity, not rank
(
ReportView.svelte), so a series keeps its color when the ordering changes and the transition reads as movement. Hash collisions across 10 series and 11 colors are likely rather than rare, so a taken slot walks to the next free one. - Charts have a table equivalent. The visual bars are
aria-hidden;SeriesTablecarries the same figures for assistive technology.
web/src/lib/api.ts declares qualities: string[]
on both Bar and Total, but
Dashboard.build_report/6 never emits that field and nothing in
the UI reads it. It's a leftover in the type definitions — harmless at
runtime, but the types currently promise something the API doesn't send.