Operational Analytics view + Report Builder v1 — build report
Commit: f7d0ad7 feat(analytics+report): operational analytics view + print report builder v1 (master)
Gates: npx tsc --noEmit clean · npx vitest run 39 files / 305 tests, all green (was 254 — 51 added) · npm run build passes (pre-existing chunk-size warning only).
What was built
A. /analytics — nav label Analytics, minRole: 'executive'
Tiles (all StatTile, honest loading/unavailable states):
- Total calls (window) — summarizeCalls over summary rows.
- Cache-hit rate — windowCacheRate: sum cache_hits over the calls of rows that report it; — + "Requires the observatory cache extension" when every row omits the field. A real all-miss window shows a genuine 0.0%, visibly distinct from —.
- Tokens in / out — 1,200 / 400 style pair from summary rows.
- Busiest operation — busiestOperation over the group_by=operation series summed across buckets (ties break alphabetically → deterministic); — + "No operations recorded" on an empty series.
Charts (shared ChartPanel/LineTimeSeries, WIB axes/tooltips, settled-bucket race guard across all three bucketed series):
- Calls by operation — one line per operation (group_by=operation).
- Tokens by engine — one line per engine, each point that engine's own tokens_in + tokens_out for the bucket. Choice noted per the brief: the fleet-wide In-vs-Out split already lives on the Overview, so this view shows the per-engine cut instead of duplicating it (caption says so).
- Cache-hit rate (%) — single exact line: per bucket, Σ cache_hits / Σ calls folded from the already-fetched engine series (counts recombine safely across any one grouping). Buckets where no row reports cache_hits are not plotted; a fully pre-cache range gets a labeled empty state, never a fabricated 0% line.
- Calls by model (upstream) — group_by=upstream, with the standard "no model dimension; upstream ≈ model" caption.
Table — per-tenant (engine) rollup from summary rows via tenantRollup: calls, errors (outside {ok, completed}), error rate %, tokens in/out, cache hits, cache-hit rate %. Sorted calls-descending; cache cells read — where the build omits cache_hits.
B. /report — nav label Report, minRole: 'executive'
A PRINT-OPTIMIZED page (code comment states why there are no charts: SVG charts get sliced at page breaks and D3 sizing needs a live viewport; tables paginate cleanly):
- Header — title, Generated <ts> WIB, Window <from> WIB → <to> WIB (<preset>) via lib/report/format.ts's formatReportTimestamp (fixed Asia/Jakarta, mirrors lib/operator/format.ts, adds the year — a printed report outlives the session).
- Key figures (static Metric/Value table): fleet p50/p95/p99 from summary.overall ONLY (— + reason when absent), total calls, error rate ({ok, completed} = success), tokens in/out, cache-hit rate (— when unreported).
- Per-engine rollup and Latency by engine and status — static Carbon tables (no interactive DataTable plumbing), same formatting rules as the on-screen views (— for absent percentiles/cache).
- Print / Save as PDF — Carbon primary Button → window.print().
- Download CSV per data table — client-side from the same in-memory data the table renders via lib/report/csv.ts toCsv (RFC-4180 escaping: commas/quotes/newlines quoted, quotes doubled, CRLF) + a tiny downloadCsv Blob/anchor trigger. Absent optional fields export as empty cells, never 0; rates export as _pct columns with 2dp. Buttons disable on an empty window.
- Range — the same 1h/24h/7d presets, default 24h; generatedAt is captured in the same state update as the range so the header can never mix two windows.
- Print CSS — @media print block appended to styles/carbon.scss: hides .cds--header, .cds--side-nav, .alerts-bar, and every .report__actions; strips the Content offsets; forces black-on-white with 1px-solid bordered table cells; break-inside: avoid on rows/sections. Spacing uses Sass $spacing-NN tokens per the file's own rule (never var(--cds-spacing-*)).
Files
src/lib/analytics/metrics.ts(+.test.ts, 18 tests) —tenantRollup,windowCacheRate,cacheRateByBucket,busiestOperation,tokensPointsByGroup. Pure, side-effect-free, same discipline aslib/sla/metrics.ts.src/lib/report/csv.ts(+.test.ts, 6 tests) —toCsv(escaping proven in tests) +downloadCsv.src/lib/report/format.ts(+.test.ts, 4 tests) —formatReportTimestamp(WIB, with year, date-line-crossing test).src/views/AnalyticsView.tsx(+.test.tsx, 12 tests) — chart stubs + spy pattern fromSLAView.test.tsx.src/views/ReportView.tsx(+.test.tsx, 9 tests) — includes CSV-content assertions via a captured Blob and awindow.printspy.src/lib/nav.ts/src/App.tsx(+ test updates) —analyticsandreportitems/routes between SLA and Calls, both executive-level (noRequireRolewrapper, like/sla).src/styles/carbon.scss—.report__*screen layout + the@media printblock.
Judgment calls
cache_hitsmissing = UNKNOWN, not zero — on both sides of the division. Rows without the field are excluded from the numerator and the denominator of every cache rate (window, per-tenant, per-bucket); counting their calls would silently deflate the rate. In practice one observatory build serves a whole response so it's all-or-nothing, but the mixed case is handled and unit-tested.- "Tokens by engine" chose the per-engine cut (one in+out line per engine) over the two-line fleet In/Out — the latter already exists on the Overview; duplicating it would waste the panel. The choice and rationale are in the panel caption and the view's header comment, as the brief asked.
- The cache-rate trend reuses the engine series already fetched for the tokens chart (hits/calls are counts → exact under any single grouping) — one fewer request, same number.
- Report error rate uses the SLA convention (
{ok, completed}= success vialib/sla/metrics.windowErrorRate), notsummarizeCalls' ok-only rule, so the KPI row agrees with the rollup table printed below it. The KPI text says "outside ok/completed" explicitly. (The Overview's ok-only tile is untouched.) This surfaced as an actual test failure — the fixture'scompletedcalls came back as a 100% error rate — before the switch. - The report fetches
/api/summaryonce and derives every figure (KPIs, rollup, SLA table) from that single response, so a printed page can never mix two windows;generatedAtis set atomically with the range. - Report tables are plain static Carbon
Tables, notDataTable— nothing to sort/select on a printout, and simpler DOM prints predictably. - Print colors are literal
#000/#fffinside@media print(with a comment): print is theme-independent, and a Carbon token would carry the active g90 theme onto paper (white-on-white). Spacing stays on Sass tokens. - CSV exports raw counts +
_pctcolumns (2dp), empty cells for unknowns — machine-readable, and an absent percentile/cache figure can never be mistaken for a measured 0. Filenames embed the preset (ahu-tenant-rollup-24h.csv). /analyticsuses four queries (summary + operation/engine/upstream series) — the minimum set that serves every tile/chart/table; the settled-bucket guard trips if any of the three bucketed series is still placeholder data across a bucket change (same strict superset as SLAView).