think
16px
820px

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 ratewindowCacheRate: 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 / out1,200 / 400 style pair from summary rows.
- Busiest operationbusiestOperation 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 as lib/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 from SLAView.test.tsx.
  • src/views/ReportView.tsx (+ .test.tsx, 9 tests) — includes CSV-content assertions via a captured Blob and a window.print spy.
  • src/lib/nav.ts / src/App.tsx (+ test updates) — analytics and report items/routes between SLA and Calls, both executive-level (no RequireRole wrapper, like /sla).
  • src/styles/carbon.scss.report__* screen layout + the @media print block.

Judgment calls

  1. cache_hits missing = 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.
  2. "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.
  3. 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.
  4. Report error rate uses the SLA convention ({ok, completed} = success via lib/sla/metrics.windowErrorRate), not summarizeCalls' 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's completed calls came back as a 100% error rate — before the switch.
  5. The report fetches /api/summary once and derives every figure (KPIs, rollup, SLA table) from that single response, so a printed page can never mix two windows; generatedAt is set atomically with the range.
  6. Report tables are plain static Carbon Tables, not DataTable — nothing to sort/select on a printout, and simpler DOM prints predictably.
  7. Print colors are literal #000/#fff inside @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.
  8. CSV exports raw counts + _pct columns (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).
  9. /analytics uses 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).