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 - - + +