Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img>` 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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/assets/dashboard-overview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
105 changes: 105 additions & 0 deletions docs/decisions/0015-overview-activity-and-cost-one-block.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/decisions/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
9 changes: 5 additions & 4 deletions docs/operations/screenshots.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions frontend/src/components/StatSection.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
27 changes: 22 additions & 5 deletions frontend/src/components/StatSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className={styles.card}>
Expand All @@ -24,10 +37,14 @@ export function StatSection({ title, children, viewAllHref, defaultExpanded = tr
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
<span className={styles.title}>{title}</span>
</button>
{viewAllHref && (
<Link to={viewAllHref} className={styles.viewAll}>
View all →
</Link>
{headerLinks.length > 0 && (
<div className={styles.links}>
{headerLinks.map((l) => (
<Link key={l.href} to={l.href} className={styles.viewAll}>
{l.label} →
</Link>
))}
</div>
)}
</div>
<div className={styles.content}>
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/pages/Overview.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading