From f706178523480d208eb343ae6d77b855215baaa3 Mon Sep 17 00:00:00 2001 From: Daedalus Date: Fri, 21 Aug 2026 19:28:31 +0200 Subject: [PATCH] feat(overview): chart spans and cost together in one block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Overview stacked a History block and a Costs block, two requests and two cards for two series that answer the same question at a glance. They are now one block, Activity & Cost: spans as a filled area against a left axis, cost as a line against a right axis, fed by a single /history call. /history buckets already carried cost_usd beside spans, from the same row of the same spans ∪ daily_usage union, so the two series are bucketed, windowed and user-filtered identically by construction rather than by a client-side join of two endpoints that has to keep three things in sync. Both series now start and stop together on every range, including Day, where cost is charted hourly for the first time — /costs is daily-only, so the old pair showed 24 hourly points above a single daily one. Two y-scales on one plot can invent a correlation the data does not hold. The series wear different marks and the legend names the axis each reads against in words, so the plot stays readable; ADR-0015 records what that mitigation buys and what it does not. The blue/amber pair is the existing chart-1/chart-4 tokens, validated for CVD separation rather than eyeballed. StatSection takes an optional links array so the block can reach both full pages; the single viewAllHref stays the default everywhere else. Co-Authored-By: Daedalus Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 +- README.md | 2 +- docs/assets/dashboard-overview.png | 4 +- ...15-overview-activity-and-cost-one-block.md | 105 ++++++++++++++++ docs/decisions/index.md | 1 + docs/operations/screenshots.md | 9 +- .../src/components/StatSection.module.css | 6 + frontend/src/components/StatSection.tsx | 27 +++- frontend/src/pages/Overview.module.css | 5 + frontend/src/pages/Overview.tsx | 115 +++++++++++------- internal/dashboard/static/index.html | 4 +- 11 files changed, 220 insertions(+), 63 deletions(-) create mode 100644 docs/decisions/0015-overview-activity-and-cost-one-block.md diff --git a/CHANGELOG.md b/CHANGELOG.md index cf025bf..3ffde74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,8 +23,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - The README screenshots are of the software as it now is. The single hero shot predated the Overview rework — it showed the deleted user-search cloud, Sessions at the top, KPI labels hardcoded to `(30d)` and no range switcher. It is replaced by the current Overview plus a Screens gallery of Sessions, Costs, Users and Tools, all taken from the demo seed, so no real user name or real spend is published. The README's logo `` pointed at `assets/logo.svg`, a path that does not exist in the repo and rendered as a broken image on GitHub; it now points at `docs/assets/logo.svg`. The current-release tag reads `:0.3`, not `:0.2` - The Overview is one window instead of five. A single range switcher in the header — its own `cotel_overview_range` cookie, so it does not move the Users or Tools page — scopes every figure on the page. Previously the KPIs showed 30 days, the Sessions and Models blocks showed all time, and only the KPI labels said which, as a literal `(30d)` baked into the string; a reader comparing the Sessions KPI against the Models table below it was comparing 30 days against all time. Labels now take their suffix from the selected range, and `All` renders none -- Overview section order is Users, History, Costs, Tools, Models, Sessions. A new Users block leads with the top 5 principals by spend in the selected range, and Sessions moves to the bottom as the one block that cannot honour a long range. The Costs block drops its inner by-model table — the Models block below it is the same data at full width -- The Overview's History block stops borrowing the sessions list's `covered_since` and reads the coverage the history response reports for its own series. The note it printed on `Year` and `All` is gone on every range the union now covers, and remains only where the chart is genuinely short — the `Day` range, which charts hourly. `RANGE_DAYS` goes with it: the page no longer derives `from`/`to` from the range key at all, so its copy of the window lengths cannot drift from the server's +- The Overview's History and Costs blocks are one block, `Activity & Cost`, charting spans and spend on one field: spans as a filled area against a left axis in `--color-chart-1`, cost as a line against a right axis in `--color-chart-4`, with the legend naming the axis each series reads against and the block header linking to both full pages. It is fed by one `/history` call rather than two requests — `buckets` already carried `cost_usd` beside `spans`, from the same row of the same `spans ∪ daily_usage` union, so the two series are bucketed, windowed and user-filtered identically by construction instead of by a client-side join that has to keep three things in sync. Both series now also start and stop together on every range, including `Day`, where cost is charted hourly for the first time — `/costs` is daily-only, so the old pair showed 24 hourly points above a single daily one. Two y-scales on one plot is a shape that can invent a correlation the data does not hold; the two series wear different marks and the legend states the sides in words to keep the plot readable, and [ADR-0015](docs/decisions/0015-overview-activity-and-cost-one-block.md) records what that mitigation does and does not buy. The colour pair was validated, not eyeballed: CVD separation ΔE 32.3 protan / 29.3 tritan in light, 29.9 / 24.6 in dark, against a target of ≥ 8 +- Overview section order is Users, Activity & Cost, Tools, Models, Sessions. A new Users block leads with the top 5 principals by spend in the selected range, and Sessions moves to the bottom as the one block that cannot honour a long range. The Overview charts cost without a by-model table beside it — the Models block below is the same data at full width +- The Overview's activity chart stops borrowing the sessions list's `covered_since` and reads the coverage the history response reports for its own series. The note it printed on `Year` and `All` is gone on every range the union now covers, and remains only where the chart is genuinely short — the `Day` range, which charts hourly. `RANGE_DAYS` goes with it: the page no longer derives `from`/`to` from the range key at all, so its copy of the window lengths cannot drift from the server's - The Overview's user-search typeahead is gone, and the `UserSearch` component with it. Scoping is reached from a user's page ("View activity"); `?user_id=` now shows a chip in the header naming the user and clearing the scope on click, instead of a page that was silently filtered with nothing on it to say so - A deploy now fails when the container does not come up. The Deploy workflow ended at `docker compose up -d`, which returns once the container has *started*, not once it works — so the last thing it observed of a deploy was `Up Less than a second (health: starting)` and it went green on that, reporting a container whose `storage.Open` had died identically to one serving traffic. It now runs `scripts/wait-for-healthy.sh`, which blocks on the container's own `HEALTHCHECK` and fails the job on `unhealthy`, on an exit, on a crash loop (in under a second, rather than waiting out the timeout — only restarts seen *during* the wait indict a deploy, since `up -d` leaves an already-current container in place and one that crashed once and recovered carries a restart count for the rest of its life, including while it legitimately replays a WAL), on a service that defines no healthcheck at all, or on a 120 s timeout — dumping `docker compose ps`, the last health-probe output and the container logs so the reason is in the run log instead of on the runner. `workflow_dispatch` takes a `health_timeout` input for the one deploy that legitimately needs longer: a start following a hard kill replays the WAL. The CI smoke job runs the same script in place of its `curl`-until-ready loop, so a break in the gate surfaces on a PR rather than on a deploy. Measured against a 109 MB copy of production, healthy at 6 s from cold with a 3.9 MB WAL to replay (the open itself 2.8 s, including the v10 migration) and 6 s on a redeploy of the warm database — the 6 s is the probe cadence, not the database - The image `HEALTHCHECK` gains `--start-interval=5s`. `--interval=30s` also governed the probes during `start-period`, so a container that was ready in two seconds still reported `starting` for thirty, and the deploy gate above would have waited out all of it diff --git a/README.md b/README.md index 7a6dff7..1d2c312 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ One Docker container. OTLP ingest on `:4318`, interactive analytics dashboard on ## What you get -- **Overview dashboard** — one range switcher in the header (All / Year / Month / Week / Day, default 30 days) that every figure on the page obeys: the KPI cards for sessions, users, total cost and token counts, and the Users / History / Costs / Tools / Models / Sessions blocks below them. The Users block ranks your top 5 principals by spend in the selected range. Arriving with `?user_id=` scopes the whole page to one user, with a chip in the header to clear it +- **Overview dashboard** — one range switcher in the header (All / Year / Month / Week / Day, default 30 days) that every figure on the page obeys: the KPI cards for sessions, users, total cost and token counts, and the Users / Activity & Cost / Tools / Models / Sessions blocks below them. The Activity & Cost block charts spans and spend together on one field — spans in blue against the left axis, cost in amber against the right — so a spend spike lands under the activity that caused it. The Users block ranks your top 5 principals by spend in the selected range. Arriving with `?user_id=` scopes the whole page to one user, with a chip in the header to clear it - **Sessions** — live table of every Claude Code session with user, model, duration, cost, and status (OK / ERROR); search by user and click any user to filter the table to their sessions - **History** — time-series and daily-activity heatmaps for sessions and token spend over time. The day, week and month series keep charting after retention has rolled raw spans into daily totals; the hourly series and both heatmaps need a per-span timestamp, so they cover raw days only and say from when - **Costs** — cumulative spend chart + breakdown table by model diff --git a/docs/assets/dashboard-overview.png b/docs/assets/dashboard-overview.png index 7db9585..1784dd6 100644 --- a/docs/assets/dashboard-overview.png +++ b/docs/assets/dashboard-overview.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:52a0c1f40f661054a49cb31d6dec096f11ddf5d0c16b85351df71d053b5cea92 -size 234617 +oid sha256:b4ec510e3663289e64e72a709b18fb23978c0fb853f58b765bed9bfc4342d0a2 +size 275332 diff --git a/docs/decisions/0015-overview-activity-and-cost-one-block.md b/docs/decisions/0015-overview-activity-and-cost-one-block.md new file mode 100644 index 0000000..8ae8277 --- /dev/null +++ b/docs/decisions/0015-overview-activity-and-cost-one-block.md @@ -0,0 +1,105 @@ +# ADR 0015 — Overview: spans and cost share one block, and one plot + +**Date:** 2026-08-21 +**Status:** Accepted +**Deciders:** Daedalus (CTO) + +--- + +## Context + +The Overview stacked two adjacent chart blocks, History and Costs, each holding a +single-series chart over the same window: + +| Block | Endpoint | Series | Height | +|---|---|---|---| +| History | `GET /history` (`granularity=day`, or `hour` on the `day` range) | `spans` per bucket | 160 px | +| Costs | `GET /costs` | `cost_usd` per day | 140 px | + +Two requests, two cards, two headers, two axes — for two series that answer the +same question at a glance: *how much did we do, and what did it cost?* Reading +the pair meant comparing the shape of one chart against the shape of another +card below it, from memory, with no shared x-position to sight along. + +Worse, the two blocks did not always agree on their x-axis. `/history` buckets +hourly on the `day` range, `/costs` is daily-only, so on `Day` the top chart +showed 24 hourly points and the one below it showed a single daily point. And +the two answered different windows until ADR-0014 gave `/history` a `range` key — +before that, History stopped a month back while Costs beside it charted a year. + +The board asked for one block with both series on one field in different colours. + +## Options considered + +**1. Keep two blocks, tighten them.** Cheapest, but does not answer the request +and leaves the `Day` mismatch in place. + +**2. Client-side join of `/history` and `/costs`.** Merge the two payloads on the +date key in the page. Rejected: it re-derives an alignment the server already +guarantees, and it can only be correct when both endpoints happen to have +resolved the same window, the same user filter and the same bucket width. Three +things to keep in sync at the call site is three things to get wrong. *(Lens: +trust the boundary.)* + +**3. One series from `/history` alone.** `historyBucket` already carries +`cost_usd` beside `spans` — the same row, from the same `spans ∪ daily_usage` +union at the same raw-floor split (ADR-0014). Both series are bucketed, windowed +and user-filtered identically **by construction**, and the Overview drops from +two requests to one. Chosen. + +Given one dataset, the plot itself is the second decision: the two measures are +counts and dollars, three orders of magnitude apart. Plotted on one scale the +cost line is a flat trace on the axis floor. + +- **3a. Two y-axes on one plot.** What the board asked for, and the shape the + request only has as a single field. +- **3b. Index both to a common base (=100 at t0).** One honest axis, but an + Overview panel exists to be read in absolute terms — "417 spans, $2.14" — and + "137 vs. base" is not that. +- **3c. Two stacked plots sharing an x-axis inside one block.** Honest scales and + sightable alignment, but two plots again. + +## Decision + +The Overview carries **one block, `Activity & Cost`**, fed by a single +`/history` call, plotting `spans` as a filled area against a left axis and +`cost_usd` as a line against a right axis (option 3 + 3a). The block header links +to both full pages, `History →` and `Costs →`, so neither is orphaned. + +Colours are `--color-chart-1` (blue) for spans and `--color-chart-4` (amber) for +cost — the existing tokens, unchanged. The pair was validated rather than eyeballed: +CVD separation ΔE 32.3 protan / 29.3 tritan in light, 29.9 / 24.6 in dark, against +a target of ≥ 8. + +A two-scale plot is a known way to mislead: the alignment of the scales is +arbitrary, so where the two lines cross means nothing, and a chart can invent a +correlation the data does not hold. We take it deliberately, and pay for it: + +- The two series wear **different marks** — a filled area and a bare line — so + the eye does not read them as two comparable lines with a meaningful crossing. +- The legend names the axis each series reads against, in words: `Spans (left + axis)`, `Cost (right axis, USD)`. Identity is not carried by colour alone. +- The right axis is tick-formatted in dollars, the left in bare counts, so the + two scales are self-labelling. +- Axis and legend text stay in ink tokens, not series colours. + +The mitigation is honest about what it is. It makes the plot *readable*; it does +not make the scale alignment meaningful. If the board later wants the stronger +form, 3c is a contained change behind the same block. + +## Consequences + +- The Overview issues one fewer request per range change. `useCosts` is no longer + called from the page; the Costs page is its only caller. +- Both series now stop and start together on every range, including `Day`, where + cost is charted hourly for the first time. +- The `covered_since` note already shown for spans now covers cost too — the same + buckets carry both, so there is one coverage statement instead of one stated + and one silent. +- `StatSection` takes an optional `links` array beside `viewAllHref`; the single + link stays the default for every other block. +- The README's Overview hero and its Overview bullet change shape; both are + re-taken and rewritten with this change, not after it. +- The two-scale plot is a standing exception to the usual one-axis rule, recorded + here so the next person to open the file finds the reasoning rather than + re-deriving it — or "fixing" it into a flat line on one axis. diff --git a/docs/decisions/index.md b/docs/decisions/index.md index f215b33..e1f048d 100644 --- a/docs/decisions/index.md +++ b/docs/decisions/index.md @@ -22,3 +22,4 @@ New ADRs go in this directory as `NNNN-short-title.md`, numbered sequentially. | [ADR-0012](./0012-tools-list-ranged-stats-and-server-side-sort) | Tools list — time-ranged stats, server-side sort and pagination | Accepted | | [ADR-0013](./0013-spans-has-no-derived-columns) | `spans` carries no derived columns: drop `duration_ms` | Accepted | | [ADR-0014](./0014-overview-single-range-selector) | Overview — one range selector every panel obeys | Accepted | +| [ADR-0015](./0015-overview-activity-and-cost-one-block) | Overview — spans and cost share one block, and one plot | Accepted | diff --git a/docs/operations/screenshots.md b/docs/operations/screenshots.md index a52e0ab..1305f4b 100644 --- a/docs/operations/screenshots.md +++ b/docs/operations/screenshots.md @@ -47,10 +47,11 @@ BASE=http://localhost:18080 node scripts/shoot-screenshots.mjs 1440×1400 viewport at 2× DPR, dark scheme, each page cropped at the bottom edge of a named element. The files land straight in `docs/assets/`. -`playwright-core` has to be resolvable from the repo — `npx playwright-core@latest ---help` once is enough to populate the npx cache, then symlink or set `NODE_PATH` to -it. Chromium comes from `CHROMIUM` (default `/usr/bin/chromium`); this box has no -Playwright-managed browser. +`playwright-core` has to be resolvable from the script — `npx playwright-core@latest +--help` once is enough to populate the npx cache, then symlink it into a +`node_modules/` beside the script. It has to be a symlink: the script is an ES +module and ESM resolution ignores `NODE_PATH`. Chromium comes from `CHROMIUM` +(default `/usr/bin/chromium`); this box has no Playwright-managed browser. ## 5. Tear down diff --git a/frontend/src/components/StatSection.module.css b/frontend/src/components/StatSection.module.css index 85b0ec6..bc90dc8 100644 --- a/frontend/src/components/StatSection.module.css +++ b/frontend/src/components/StatSection.module.css @@ -39,6 +39,12 @@ font-weight: 600; } +.links { + display: flex; + align-items: center; + gap: var(--space-3); +} + .viewAll { font-size: var(--text-sm); color: var(--color-accent); diff --git a/frontend/src/components/StatSection.tsx b/frontend/src/components/StatSection.tsx index dfcc8d7..7fa6a68 100644 --- a/frontend/src/components/StatSection.tsx +++ b/frontend/src/components/StatSection.tsx @@ -3,15 +3,28 @@ import { Link } from 'react-router-dom' import { ChevronDown, ChevronUp } from 'lucide-react' import styles from './StatSection.module.css' +interface SectionLink { + label: string + href: string +} + interface StatSectionProps { title: string children: ReactNode viewAllHref?: string + links?: SectionLink[] defaultExpanded?: boolean } -export function StatSection({ title, children, viewAllHref, defaultExpanded = true }: StatSectionProps) { +export function StatSection({ + title, + children, + viewAllHref, + links, + defaultExpanded = true, +}: StatSectionProps) { const [expanded, setExpanded] = useState(defaultExpanded) + const headerLinks = links ?? (viewAllHref ? [{ label: 'View all', href: viewAllHref }] : []) return (
@@ -24,10 +37,14 @@ export function StatSection({ title, children, viewAllHref, defaultExpanded = tr {expanded ? : } {title} - {viewAllHref && ( - - View all → - + {headerLinks.length > 0 && ( +
+ {headerLinks.map((l) => ( + + {l.label} → + + ))} +
)}
diff --git a/frontend/src/pages/Overview.module.css b/frontend/src/pages/Overview.module.css index 2b0f8e7..de73d0b 100644 --- a/frontend/src/pages/Overview.module.css +++ b/frontend/src/pages/Overview.module.css @@ -32,6 +32,11 @@ white-space: nowrap; } +.legendLabel { + font-size: var(--text-xs); + color: var(--color-text-2); +} + .coverageNote { font-size: var(--text-sm); color: var(--color-text-3); diff --git a/frontend/src/pages/Overview.tsx b/frontend/src/pages/Overview.tsx index 803cf0f..3a50355 100644 --- a/frontend/src/pages/Overview.tsx +++ b/frontend/src/pages/Overview.tsx @@ -1,11 +1,11 @@ import { useMemo, useState } from 'react' import { Link, useNavigate, useSearchParams } from 'react-router-dom' import { - LineChart, Line, AreaChart, Area, - XAxis, YAxis, Tooltip, ResponsiveContainer, + ComposedChart, Line, Area, + XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, } from 'recharts' import { - useOverview, useSessions, useCosts, useHistory, useTools, useModels, useUsersPage, + useOverview, useSessions, useHistory, useTools, useModels, useUsersPage, } from '../api' import type { SessionItem, ToolItem, ModelItem, User } from '../api' import { @@ -76,7 +76,11 @@ function UsersSection({ range }: { range: RangeKey }) { ) } -function HistorySection({ range, userId }: SectionProps) { +// Spans and cost share the history buckets, so the two series are bucketed, +// windowed and user-filtered identically — a client-side join of /history with +// /costs could not guarantee that. They carry different units, so each keeps its +// own axis and the legend names the side it reads against. +function ActivitySection({ range, userId }: SectionProps) { const { data, isLoading, error } = useHistory( range === 'day' ? 'hour' : 'day', undefined, @@ -88,7 +92,7 @@ function HistorySection({ range, userId }: SectionProps) { if (isLoading && !data) return if (error) return if (!data || data.buckets.length === 0) - return + return return ( <> @@ -98,10 +102,10 @@ function HistorySection({ range, userId }: SectionProps) { earlier days in this range survive only as whole-day totals.

)} - - + + - + @@ -112,51 +116,66 @@ function HistorySection({ range, userId }: SectionProps) { tickFormatter={(b) => String(b).slice(5)} interval="preserveStartEnd" /> - - [String(Math.round(v as number)), 'Spans']} />} /> + + `$${Number(v).toFixed(2)}`} + width={52} + /> + + name === 'Cost' + ? [`$${Number(v).toFixed(2)}`, 'Cost'] + : [Math.round(Number(v)).toLocaleString(), 'Spans'] + } + /> + } + /> + ( + + {value} {value === 'Cost' ? '(right axis, USD)' : '(left axis)'} + + )} + /> - + + ) } -function CostsSection({ range, userId }: SectionProps) { - const { data, isLoading, error } = useCosts(undefined, undefined, userId, range) - - if (isLoading && !data) return - if (error) return - if (!data || data.daily.length === 0) - return - - return ( - - - String(d).slice(5)} - interval="preserveStartEnd" - /> - `$${Number(v).toFixed(2)}`} - width={52} - /> - [`$${Number(v).toFixed(2)}`, 'Cost']} />} /> - - - - ) -} - function ToolsSection({ range, userId }: SectionProps) { const { data, isLoading, error } = useTools({ range, @@ -380,12 +399,14 @@ export default function Overview() { )} - - - - - - + + diff --git a/internal/dashboard/static/index.html b/internal/dashboard/static/index.html index 0b794b2..e4b32b2 100644 --- a/internal/dashboard/static/index.html +++ b/internal/dashboard/static/index.html @@ -5,8 +5,8 @@ cotel - - + +