Skip to content

feat: replicate the omp-stats dashboard - #53

Merged
KurutoDenzeru merged 61 commits into
mainfrom
feat/omp-stats-dashboard
Oct 12, 2026
Merged

KurutoDenzeru merged 61 commits into
mainfrom
feat/omp-stats-dashboard

Conversation

@KurutoDenzeru

@KurutoDenzeru KurutoDenzeru commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Description

The dashboard replicates the upstream @oh-my-pi/omp-stats dashboard page for page, and adds the parts Tersio needs on top of it. A later stretch of this branch rebuilds usage pricing on top of that work, described under Usage pricing.

Each view is one hash-routed page with the reference's own header, stat tiles, charts, and tables, read from omp's databases without ever writing to them. The routes match the reference exactly, so #/providers?range=7d and #/traces?range=7d&s=<sessionFile> work in either dashboard.

On top of the replication: the Traces session page is a flamegraph with the reference's axis choice, span search, legend, and brush; the share card builds a GitHub-style contribution graph and copies itself as PNG; Settings gains a Data pane that snapshots every file Tersio keeps and restores one behind a confirm; the rail footer holds the credits and the social marks.

Changes Made

  • extensions/shared/agent-stats.ts: one host-neutral aggregate over ~/.omp/stats.db, ~/.omp/agent/agent.db, and ~/.omp/snapcompact-savings.jsonl, matching the reference metric for metric: stop reasons, cache rate, throughput, error signatures, subscription window insights, snapcompact gain, request percentiles, per-provider hourly burn, and session transcripts. A view argument selects the query groups a page needs, so a page payload stays in the tens of kilobytes. It also folds every subagent transcript into its root session, and derives the span model a flamegraph needs from the transcript itself.
  • cli/backups.ts and cli/dashboard.ts: a snapshot is a directory with a manifest, so one snapshot holds the usage ledger, the reset watermark, the settings defaults, both price caches, the agent stats and quota databases, and the command history. /api/data creates, lists, restores, and deletes one, and both destructive moves ask first: a restore names the files it copies back, a delete names the files going with it. The four WAL failure modes (-readonly without side files, immutable=1 over a live log, a plain open creating side files) are handled in order instead of failing or silently reading stale rows.
  • extensions/shared/usage-ledger.ts and extensions/shared/usage-store.ts: the ledger records the agent kind beside the host. A transcript with parentSession on its own session row is a subagent, and that kind lands in the new a column, so a subagent's work reads apart from its root's. A store written before this branch gains the column with a bare ALTER, so no rows move and none are lost. tersio usage prints a BY AGENT block. An OpenCode row carries no subagent marker, so it records as the main agent rather than as a NULL.
  • dashboard/app: a page kit (page header, card, column-definition tables with sorting and paging, stat tiles with inline sparklines, meters) and the pages built on it, plus the session flamegraph behind &s= and the Settings panes. Settings and Share move to the rail footer; the theme button sits at the top right of the topbar.
  • dashboard/app/src/components/common.tsx: one toolIcon map names the glyph a tool name carries, so a transcript row reads as its verb rather than borrowing a vendor mark. dashboard/app/src/lib/format.ts: hostOfSession now returns omp, pi, or opencode, the same ids AgentLogo switches on, so the request drawer carries the host's own brand art instead of a terminal glyph.
  • dashboard/app/src/lib/carbon.ts: the What that equals card runs ten comparisons, each with the published rate it used, seven visible and the rest scrolling. dashboard/app/src/components/charts/stats.tsx: each breakpoint tier names its own cells, so a stat band draws a rule between every pair at a phone width, a tablet width, and a desktop width.
  • cli/install.ts: omp merges the plugin manifest with the extension directory it discovers by default, so Tersio extensions lived in two trees at once. Every OMP mode extension now writes into the plugin tree and the agent-tree copies an older build wrote are dropped, with rtk.ts kept where config.yml registers it.
  • cli/manifest.ts and cli/doctor-fix.ts: the two doctor repair paths each walked TREE_FILES reading the bundled source and writing it out, so syncTreeFiles holds that loop for both. The rule-body skip rides along as a predicate, so an upstream fetch newer than the bundled copy is never overwritten by a repair.
  • Bloat pass over the whole branch: three dead exports dropped (withResolvers, missingTreeFiles, EXTENSIONS_KEY_RE); AgentWindowPoint, which was declared twice thirty lines apart in agent-stats.ts, is now declared once; doctor.ts takes the installed plugin path from ompPackageDir() instead of rebuilding it; seven comment blocks were trimmed with every distinct fact kept; and nine tests were removed where they asserted a constant, a pure function equalling itself, a static literal map, or a case another file already covers.
  • Brand marks are vendored, so no page fetches a logo at runtime. Each mark tile carries the brand's own color, with the glyph taking the ink that WCAG contrast allows, and a test fails if any fill leaves its glyph under 2.5:1.
  • Docs: the README host table and Settings section, the INSTALL and ARCHITECTURE sections, the changelog entries, and the docs/ERRORS.md entries for the mistakes the build caught.

Usage pricing

tersio usage repriced every model from the LiteLLM feed and discarded the per-message cost the host itself reports. On this machine the report read $373.49 where the true figure is $71.23, an overstatement of $302.27.

  • kilo/stepfun/step-5-preview, 1.46B tokens, was billed at the $2/M Sonnet default for $368.94 against the $69.08 the host reported for those tokens.
  • Twenty genuinely free gateway models (space-bunny, muse-spark-1.3-contributor, glm-5.3-flash, qwen38-flash-next, stealth/ox-alpha, MiniMax-M3, DeepSeek-V4-Flash, ling-3.0-flash-sante, Qwen3.8-27B and more) each paid $2/M for tokens that cost nothing, because the feed carries no row for any of them.

The information was already being read. costOf() parsed usage.cost, accum.costMeasured summed it, and usage.db stored it. summarizeUsage then threw it away and repriced instead.

Each price hit now carries a tier, and PRICE_TIERS_FIRM names the firm ones: measured (the host's own cost for those tokens), override (the operator's file), exact, and alias all read as prices; tail and default read as estimates. usdCost returns priced from the tier alone, never from the fact of a match, so an estimate is summed apart from measured cost.

That a recorded zero is a real zero was verified rather than assumed: cost_unpriced is 0 across all 29,749 rows in ~/.omp/stats.db, and no row is both unpriced-flagged and zero-cost.

A model is host-priced only when its measured coverage matches its message count. Where the counts diverge, usdCost falls back to a rate card rather than reading the unpriced messages as zero, which is the one error worse than a guess.

One model key answers for every host. A model reads as the same key whichever agent ran it, so one operator entry prices it everywhere. Verified: omp, pi and opencode spell the same model three ways, all three fold to one key, the per-host split survives the fold, and one key yields one resolution rather than three.

~/.tersio/prices.local.json holds the operator's own rates, keyed either as the transcript spells the model or with a * prefix that covers every gateway prefix. It merges over the feed cache, and the model memo keys on that file's mtime, so an edit applies on the next lookup. When models still fall to a guess, tersio usage names the five heaviest rather than counting them.

Cache savings is removed. It read (noCacheCost - cachedCost) / noCacheCost, sourced from cost_no_cache_input, which omp leaves at zero in 26,176 of 29,749 rows and populates only for deepseek. The denominator was near-always zero, so the dashboard rendered a confident 0.0% built from no data — worse than an absent panel, because a reader sees a number. It went as a tile, a stat hint and a table column, and noCacheCost and cachedCost went with them. Cache rate stays.

Screenshots/Recordings

Before After
One long scroll page: every omp view stacked as anchor sections behind a jump rail One page per view at the reference's routes, each with its own header, stats, charts and tables: #/overview, #/models, #/providers, #/costs, #/requests, #/errors, #/traces, #/tools, #/frustration, #/projects, #/gain
No snapshot of anything Settings -> Data: a schedule card with a frequency picker, a snapshots card with selectable rows, a footer that restores or deletes the picked one, and a confirm that names the files before either happens
A tool span carrying a provider's logo A tool span carrying its own glyph: a terminal for the shell tools, a pen for the edits, a book for the reads
Four carbon comparisons Ten, seven in view with the rest scrolling, each with the rate it used
tersio usage reporting $373.49 of estimated cost $71.23, matching the figure the hosts report, with unpriced models named

Run tersio dashboard --open and compare with the reference at 127.0.0.1:3847 on the same route and window.

Testing

  • bun run verify at this head: frozen lockfile, build green, 59 test files and 420 tests passed.
  • The usage report was run against a real store before and after: $373.49 to $71.23, and the weak count from four models to two after adding operator entries.
  • Each price tier is asserted: a measured zero reports as firm, tail and default report as estimates, an override covers a gateway the feed keys under a different upstream, and partially covered measured cost falls back to a rate card instead of reading zero.
  • test/cli/update-command.test.ts is hermetic: a finished tersio update refreshes the price cache under a fifteen-second window, which outran the child's spawn timeout on a loaded machine and failed the test for a reason unrelated to update. The fetch now points at a closed port and the cache at a throwaway file.
  • The dashboard panels were loaded in a real browser on a local server and inspected, confirming no gap in the detail tiles.
  • Every aggregate metric was diffed against the live reference API at a matched row count on 1h, 24h, 7d, 30d, 90d, and all time, including the subscription window insights.
  • Every page's card structure was compared against the running reference at the same route and window.
  • The snapshot create, restore, and delete paths were driven in a real browser against a throwaway home, in both themes. A delete asks first: the confirm blocks the first click, "Keep it" closes it with the row intact, and the confirmed click removes one row and returns the footer to its empty state.
  • The ledger's agent kind was checked against omp's own ground truth: ~/.tersio/usage.db holds omp|main 6,361 and omp|subagent 1,692 with no NULL kinds, against omp's 25,694 main and 2,559 subagent.
  • The shared tree-sync writer was proven against the repair it replaced: a planted 13-byte rule.md survived a doctor --fix extensions whose bundled copy is 5990 bytes, rule-ultra.md survived with it, all nineteen tree files landed, and the stubbed index.ts was restored from the bundle.
  • The stat band's rules were read from computed styles at a phone width, a tablet width, and a desktop width, and the animation guard was proven by flipping one chart to isAnimationActive={true} and watching it fail, then reverting.
  • oxlint exits 0 on both packages, and the new files add no warnings.

Additional Notes

  • Read-only. Nothing opens an omp database for writing.
  • Host coverage is uneven by design, and worth knowing before merge. Session transcripts are host-neutral: sessionsDirs() reads omp's and pi's directories and hostOfSession names omp, pi, and OpenCode. The aggregate is omp-only, because statsDbPath and agentDbPath default to ~/.omp and omp's schema is the only one carrying agent_type, the cost columns, tool_calls, and the quota windows. So Overview, Models, Providers, Costs, Requests, Errors, Tools, and Carbon read omp's database only; pi and OpenCode work reaches the dashboard through the session list and its flamegraph.
  • Renamed for host neutrality. extensions/shared/omp-stats.ts is agent-stats.ts, the Omp* types are Agent*, /api/omp is /api/agent, and the seams are TERSIO_AGENT_STATS_DB and TERSIO_AGENT_SESSIONS_DB. The reader still opens omp's own databases; the naming no longer claims other hosts.
  • Reduced on purpose where the reference writes. The frustration page reads stored verdicts and never runs the judge, so the "Judge classification" card reports coverage instead of offering a run. The trace timeline is a flamegraph with a transcript panel rather than the reference's canvas viewer.
  • Unreplicated fields are shown as a hyphen, never recomputed: the provider premium column, per-component cost splits, and per-series legend totals.
  • The Usage page and its ledger-only surface are gone. Tersio's own ledger stays a product: tersio usage, tersio reset, and tersio dashboard --export still work, and the CLI still owns ~/.tersio/usage.db.
  • Refresh is already bounded and needs no knob. The dashboard client polls api/agent every 30 seconds while the tab is visible and on focus; the server memoizes the aggregate for 5 seconds keyed on both database file stamps, so a new row reaches the page within 5 seconds of the write and the sqlite spawn cost is not repeated by the poll. usage.db syncs on demand, on no timer.
  • Known duplication left in place, deliberately. Three routines edit plugins/package.json across install.ts, doctor-fix.ts, and uninstall.ts. Two of them then shell out to npm and they differ in whether the write is conditional, so one shared helper would take four options and still branch. The RTK download-and-verify path is likewise duplicated between install.ts and doctor-fix.ts; extracting it touches the checksum gate, which is the one place where a mistake runs an unverified binary. Both are named here rather than abstracted for its own sake.
  • omp's own stats.db needs no shrinking from this side. It is 42 MB with freelist_count = 0, so VACUUM reclaims nothing; 58% of the file is 18 indexes, of which 10.4 MB are UNIQUE autoindexes that cannot be dropped without a schema change. Retention and unused-index removal are omp's decisions, and Tersio's read-only contract keeps them there.

Reads ~/.omp/stats.db, ~/.omp/agent/agent.db, and snapcompact savings read-only, matching the upstream omp-stats metrics: stop reasons, cache rate, cache savings, throughput, error signatures, subscription window insights, gain, and per-page view slicing.
Adds /api/omp?range=&view= with a five second memo per window and view, and /api/omp/session?file= for one transcript. The export snapshot carries one window. repo-integrity now resolves Vite asset imports, so a vendored .svg target counts as tracked.
One hash route per view, matching the upstream routes and layout page for page: a page kit (header, numbered cards, column tables, stat tiles with sparklines), the eleven omp pages, a session timeline behind &s=, and Tersio's own ledger on #/usage. Settings and Share move to the rail footer and the theme button to the topbar. Brand marks are vendored, so no page fetches a logo.
README gains the route table and the rail and topbar notes, ARCHITECTURE gains the omp-stats section and its invariants, the changelog carries the entry, and ERRORS.md records the five mistakes the build caught.
The stat strip is now one solid card with a rule between cells and inline percentage hints, replacing the translucent tiles. The segmented control is shadcn tabs, matching the Activity card.
@KurutoDenzeru KurutoDenzeru self-assigned this Oct 9, 2026
Neither source exists on this host: snapcompact-savings.jsonl is never written, and user_messages plus frustration_verdicts are absent from stats.db. Both views, their query groups, their fixture helpers, and their tests go with them.
PagedTable replaces the row cap on the request log, failures, models, projects, tools, sessions, provider totals, windows, and accounts: one page at a time with the row count, a rows-per-page picker, and page buttons. The one look for every page now pages instead of clipping.
A null made recharts reset the stack, so every stacked area broke into disjoint shapes; the Models share chart showed a hard vertical cut. A stacked series now reads zero in a bucket with no data, and only a lone series keeps its nulls.

The tooltip title came from the tick text, which read Invalid Date, and its values printed raw. The title now comes from the hovered row's own category and the values use the page formatter, so a share reads 98%.

The Costs view never asked for modelSeries, so its first chart painted an empty frame.
A scripted edit rewrote the file from a stale buffer and appended a second copy of every release section. This drops the copy, so the file holds one section per release.
The drawer opens on the reference's sections: Timing, Tokens, API-equivalent cost, Agent, Identity, then the three payloads. It fetches the journal entry behind the row on demand from /api/omp/entry, so the Output message, the Session entry, and the Stats row all carry real bytes instead of an empty block.

The Agent section is the twist: the agent type, the model, the thinking level, the mode, and whether the model resolved as a fallback, all read from the change entries that precede the request in its own journal.

The drawer also shows the cache hit rate, each token bucket's share of the row, the API-equivalent cost per output token, and its Trace button deep-links the session.
…mance panel

The Models table opens a detail row that spans every column, the shape the reference uses: Efficiency, Latency, the four token buckets, and the first and last seen ages on the left, then a per-active-day chart on the right.

The chart needs throughput and first-token latency per model, provider, and bucket, which the aggregate did not read, so readModelPerformance now reads them and a model with no timing sample reads as an empty state instead of an empty frame. Two units share one plot through a new right axis on the shared chart.
A card header carries 12px over 8px with a tighter description line, a flush card drops the shell's own bottom padding, and a pager footer drops to 40px with its trigger at the page-button height. Measured on six pages: header 55px, pager footer 39px, and no dead band under a flush table.
… name

The brand mark leads the model cell, the provider moves to its own dim line instead of dragging beside the name, and the marks gain a table size so a 18px glyph stops inflating the row from 35px to 49px.

The requests cell carries a share bar against the busiest model, the error rate reads as a chip whose tint follows the rate with the figure kept in ink for contrast, and the per-second and first-token figures drop the unit the header already states.
…al data layer

One change set across the dashboard, because five shared files (data.ts,
App.tsx, dialogs.tsx, lib/carbon.ts, cli/dashboard.ts) each carry hunks from
several of these modules, so any split would have produced commits that do not
build.

Data layer, host-neutral:
- extensions/shared/omp-stats.ts is now agent-stats.ts, the Omp* types are
  Agent*, useOmpData is useAgentData, the pages move from components/omp to
  components/agents, the API is /api/agent, and the seams are
  TERSIO_AGENT_STATS_DB and TERSIO_AGENT_SESSIONS_DB.
- The reader still opens omp's own databases; the naming no longer claims
  otherwise.

Brand marks:
- One catalog in extensions/shared/brand-marks.ts names every mark's URL:
  Simple Icons, the owner-hosted marks, the owner avatars, and the two marks no
  host carries. The vendored SVG/PNG/WebP files, the fetch script, and
  SOURCES.md are gone, and the dashboard export inlines the same catalog once.
- A tile fills with the brand's own color and takes the opposite ink by WCAG
  luminance, so DeepSeek blue, Meta blue, Kimi blue, Xiaomi orange and Mistral
  orange read white while Kilo yellow and StepFun teal read black. Groq draws as
  a masked glyph because its own favicon is one flat orange.
- Each tile now carries the same visible size of logo: 44px in a detail view,
  36px in a drawer, 30px with a 23px glyph in a table row, and Kimi, Z.ai and
  OpenRouter scale down to match while Magpie scales up.

Pages:
- Carbon page: the estimate per model, the equivalence figures, and the
  By model table with the provider under the model name.
- Costs: Where it went is the billing-component breakdown the reference shows,
  priced per component from the same records, and the chart card takes the 2fr
  side of the split the Providers page uses.
- Providers: a row expands in place to its token mix, both burn charts share one
  palette, and every picker renders its label instead of the raw value.
- A page table chains its wheel to the page again, and a page newer than the
  running server says so instead of blaming a missing database.

Share:
- The share action is back in the topbar beside the theme button, with a card
  built from the agent statistics database for the window in force.

Removed:
- The Usage page and everything that existed only to show Tersio's own ledger:
  /data.json, /reset, /backups, the ledger /export, the hero, savings bento,
  activity chart, ledger tables, share card, and reset pane. The ledger stays a
  product: tersio usage, tersio reset, and the backups still work.
@KurutoDenzeru KurutoDenzeru added enhancement New feature or request tests Test update refactor Refactor (no functional changes) labels Oct 12, 2026
@KurutoDenzeru
KurutoDenzeru merged commit 3a360ee into main Oct 12, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request refactor Refactor (no functional changes) tests Test update

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant