diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ffde74..80383fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- The Overview leads with a **Span activity** grid — a block of cells counting spans, GitHub-contribution-graph style, sitting directly under the KPI row. A line chart answers *how much and when*; it does not answer *what does a week here look like*, which is the question a telemetry front door gets asked most. The selected range picks both the grid and how much time one cell is: 53 × 7 day cells over a year (and over `All`), 31 × 6 four-hour cells over a month, 24 × 7 hourly cells over a week, 24 × 6 ten-minute cells over a day — each tiling its window exactly, and each about 115 px tall, so switching range does not move the page under the reader. Cells outside the queried window — the leading edge of the lattice, and the rest of today — are drawn as an outline with no fill: an empty cell means "we looked and there was nothing", an outline means "we did not look", and conflating the two is how a heatmap invents a quiet weekend. The grid is placed in UTC, which the footer and every tooltip say. Intensity is cut at the quartiles of the cells in view, not scaled against the busiest one: against a 722-span peak a 200-span day and a 700-span day are both "busy", so a max-relative ramp — linear or log — renders a working week as one flat block of full-intensity cells, which is the difference the grid exists to show. A step therefore means a rank, so the footer names the busiest cell in view and every tooltip gives the cell's own count. The scale lives in `frontend/src/lib/heat.ts` and is shared with the History page's calendar and hour-of-day heatmaps, which had a private copy of it and pick up the quartile cut with this change ([ADR-0016](docs/decisions/0016-overview-activity-grid.md)) +- `GET /api/v1/history` accepts two more bucket widths, `granularity=10m` and `granularity=4h`, so the activity grid asks for exactly the width it draws — one bucket, one cell — instead of re-bucketing an hourly series in the page, which could not have produced a ten-minute cell at all. Both are additive and no existing caller changes; an unrecognised width still falls back to `day` rather than 400ing. Like `hour` they are answered from `spans` alone and report the shortfall in `covered_since`, because `daily_usage` buckets whole UTC days and cannot produce a sub-day bucket. `bucket` is now documented as a UTC wall-clock label floored to the width, on any host, so a client can reconstruct it for an instant without asking what the server thinks midnight is - `scripts/seed-demo.py` fills a throwaway instance with a synthetic team — seven users, three models, 90 days of sessions and tool calls. It goes in over the OTLP endpoint rather than writing to DuckDB, so a seeded instance exercises the same ingest, cost-derivation and roll-up path a real one does, and it sends only attributes Claude Code actually sends: no `command` on `Bash` spans, so the Tools page shows the same "no command detail" state a real install sees. The RNG seed is fixed, so a re-run against a fresh volume reproduces the same numbers. `scripts/shoot-screenshots.mjs` turns that instance into the README images, each cropped at the bottom edge of a named element rather than at a pixel count ([docs/operations/screenshots.md](docs/operations/screenshots.md)) - `GET /api/v1/overview`, `/sessions`, `/costs` and `/models` accept `range`, the same five-key rolling window `/users` and `/tools` already took, and each echoes back the key it used. Defaults preserve today's behaviour instead of converging on one value: `/overview` and `/costs` default to `month` (the 30-day window they already applied), `/sessions` and `/models` to `all`, because they had no time filter and a `month` default would silently truncate every existing caller. Long ranges resolve against the `spans` ∪ `daily_usage` union at the raw-floor split, so `year` and `all` keep answering after retention has deleted the raw spans rather than repeating the `month` figure. On `/costs`, explicit `from`/`to` still beat the range key and the response then echoes `"range": null` ([ADR-0014](docs/decisions/0014-overview-single-range-selector.md)) - `GET /api/v1/sessions` returns `covered_since`. A session row needs a start time, model and status, none of which the roll-up keeps, so the list is raw-only; a range reaching past the raw floor is clamped and the field names the instant the list actually starts from (`null` when the range is fully covered). The Overview's Sessions block states that window in one line. The session *count* KPI is unaffected — `daily_usage` carries `session_id`, so counting distinct sessions across the union is exact diff --git a/README.md b/README.md index 1d2c312..7f8cf58 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 / 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 +- **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 Span activity / Users / Activity & Cost / Tools / Models / Sessions blocks below them. The Span activity grid is a GitHub-style block of cells counting spans, and the range picks how much time a cell is — a day over a year, four hours over a month, an hour over a week, ten minutes over a day. 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/decisions/0016-overview-activity-grid.md b/docs/decisions/0016-overview-activity-grid.md new file mode 100644 index 0000000..0bceecc --- /dev/null +++ b/docs/decisions/0016-overview-activity-grid.md @@ -0,0 +1,157 @@ +# ADR 0016 — Overview: an activity grid, one cell per bucket + +**Date:** 2026-08-21 +**Status:** Accepted +**Deciders:** Daedalus (CTO) + +--- + +## Context + +The Overview charts activity as a line over time. A line answers *how much, and +when* — it does not answer *what does a week here look like*. Rhythm is the +question a telemetry front door gets asked most: which hours are dead, whether +the weekend is quiet, whether the nightly bot really runs nightly. A line at 200 +px tall over 30 buckets flattens all of it. + +The board asked for a GitHub-contributions-style block of cells on the Overview, +counting spans, with a grid per range: + +| Range | Grid asked for | Cell | +|---|---|---| +| Year | 53 × 7 | day | +| Month | 31 × 6 | 4 hours | +| Week | 27 × 7 | hour | +| Day | 24 × 6 | 10 minutes | + +`All` keeps the year grid. Exact cell counts and the intensity ramp were left to +engineering ("количество и яркость сам подбери"). + +Three of those four are already coherent: **columns are the coarse unit, rows the +subdivision of it.** 53 weeks × 7 weekdays is GitHub's own layout; 31 days × 6 +four-hour slots and 24 hours × 6 ten-minute slots are the same idea one and two +zoom levels in. Every one of them tiles its window exactly — 371 days, 31 days, +24 hours. + +Two things had to be decided before any of it could be drawn: what the week grid +actually is, and where the data comes from. + +## Options considered + +### The week grid + +27 columns of hour cells over 7 rows is 189 hours — 7.875 days. It tiles neither +a week nor a day, so no column is a fixed hour and no row is a fixed day; the +grid would drift by three hours per row and mean nothing at either axis. + +**1. 7 × 24 — one column per day, 24 hour-rows.** The only layout that keeps +"columns coarse, rows fine" for the week too. It is also 24 rows tall: a 430 px +column of cells in a block whose other three ranges are ~115 px, so the page +jumps by a third of a screen when the range changes. + +**2. 24 × 7 — one column per hour of the day, one row per day (chosen).** 168 +cells, exactly the 7-day window. Same wide, short shape as the other three +grids. It is the transpose of option 1, so on this grid alone time runs *across* +a row rather than down a column — and it is the layout the History page's +hour-of-day heatmap already uses, so it is not a new idiom in the product, just +a second appearance of one. It is also what the board's `27 × 7` was reaching +for: seven rows, a day each. + +### Where the cells come from + +**A. Fold client-side from the hourly series.** `/history` already serves +`hour`. The month grid could fetch 744 hourly buckets and sum each four; the day +grid cannot be built at all, because nothing below an hour exists. Rejected on +the second count alone. + +**B. A dedicated `GET /activity?range=…` returning the grid.** The server would +own the grid shape. Rejected: a new public contract, forever, for a bucket width +— and it would duplicate the window, user-filter and roll-up-union logic +`/history` already carries. *(Lens: schema and public interfaces are forever.)* + +**C. Two more bucket widths on `/history` (chosen).** `10m` and `4h` join +`hour`, `day`, `week`, `month`. `/history` *is* this product's "spans over time, +bucketed" contract; ADR-0014 closed with "the next one is a parameter, not a +design", and this is that. The grid then asks for exactly the width it draws: +one bucket, one cell, no client-side re-bucketing to get wrong. + +## Decision + +**A `Span activity` block leads the Overview**, directly under the KPI row and +above the resource sections. It is the page's at-a-glance pulse; the sections +below it are the itemised answers. + +**One cell is one `/history` bucket.** The range picks both the grid and the +granularity it fetches, and nothing else in the page knows the mapping: + +| Range | Grid | Cell | `granularity` | Window tiled | +|---|---|---|---|---| +| `year`, `all` | 53 × 7, column-major | day | `day` | 371 days | +| `month` | 31 × 6, column-major | 4 h | `4h` | 31 days | +| `week` | 24 × 7, row-major | 1 h | `hour` | 7 days | +| `day` | 24 × 6, column-major | 10 min | `10m` | 24 hours | + +On `year` and `all` that is the identical request the History block already +makes, so SWR serves both blocks from one fetch. The other three ranges cost one +extra `/history` call, over a window of at most 31 days of raw spans. + +**Sub-day widths stay raw-only, like `hour`.** `daily_usage` buckets whole UTC +days, so `10m` and `4h` are answered from `spans` alone and report the shortfall +in `covered_since` — the rule ADR-0014 set, extended to two more widths rather +than re-argued. Only the day-celled year grid crosses the union, and it is the +one grid that needs to. + +**The grid is placed in UTC.** `CAST(start_time AS TIMESTAMP)` renders the +stored `TIMESTAMPTZ` in UTC whatever the server's timezone is — the same +property the roll-up depends on for a day to be a day — so every cell start +falls on a bucket boundary by construction and the client never has to reconcile +two notions of midnight. The footer says `UTC` and every tooltip repeats it. + +**A cell outside the queried window is drawn absent, not empty.** The lattice +is a fixed 53 × 7 and the window is a rolling 365 days, so the leading edge and +the rest of today are cells nothing was ever asked about. They are drawn as an +outline with no fill, distinct from the empty-but-covered colour, and carry no +tooltip. An empty cell means "we looked and there was nothing"; an outline means +"we did not look". Conflating the two is how a heatmap invents a quiet weekend. + +**The ramp is cut at the quartiles of the cells in view, in five steps, shared +with the History page.** Scaling against the maximum is the obvious choice and +it was the first one built; it renders as one flat block of full-intensity +cells. Against a 722-span peak a 200-span day and a 700-span day are both +"busy" — on a linear ramp and on a log ramp alike, since the log only +compresses the top harder — and that difference is the entire point of the +grid. Quartiles put about a quarter of the busy cells in each step whatever the +shape of the distribution, which is what GitHub's graph does and why it reads. +The cost is that a step means a rank, not an amount: the footer therefore names +the busiest cell in view, and every tooltip gives the cell's own count. Degenerate +input is handled explicitly — a run with no spread has no quartiles to cut at, +so every busy cell takes the top step rather than all landing in the bottom one. + +`frontend/src/lib/heat.ts` holds the scale that the History calendar and +hour-of-day heatmaps had a private copy of, so the three grids in the product +cannot drift apart. Four filled steps plus empty is about as many as the eye +separates at this cell size. Colours stay on `--color-chart-1` over +`--color-surface-2`: no new tokens, and the existing light/dark pair carries +over unchanged. + +## Consequences + +- `GET /history` takes `granularity=10m` and `granularity=4h`. Both are additive + and every existing caller is untouched; an unrecognised width still falls back + to `day` rather than 400ing. +- The Overview makes one extra request on the `month`, `week` and `day` ranges, + and none on `year` / `all`. +- The block holds one height (~115 px of cells) across all four ranges, so + switching range does not move the page under the reader. +- The week grid reads across, the other three read down. Both axes are labelled + on every grid, which is what actually resolves it for a reader; the + inconsistency is deliberate and is the price of keeping one block shape. +- The heat scale moves out of `History.tsx`. Any future cell grid gets it by + importing it, and a change to the ramp lands on every grid at once — including + the two History heatmaps, which pick up the quartile cut with this change. +- The grid shows a maximum of 371 days on `all`, however far back the data goes. + A fixed lattice cannot grow unbounded, and the History page is one click away + for the full series. +- Cells are `
`s in one CSS grid with explicit `gridColumn` / `gridRow`, at + most 371 of them. Placement is explicit rather than flow-ordered so the same + code renders both the column-major and the row-major grids. diff --git a/docs/decisions/index.md b/docs/decisions/index.md index e1f048d..8b3d645 100644 --- a/docs/decisions/index.md +++ b/docs/decisions/index.md @@ -23,3 +23,4 @@ New ADRs go in this directory as `NNNN-short-title.md`, numbered sequentially. | [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 | +| [ADR-0016](./0016-overview-activity-grid) | Overview — an activity grid, one cell per bucket | Accepted | diff --git a/docs/design/pages.md b/docs/design/pages.md index ead4150..75f5f16 100644 --- a/docs/design/pages.md +++ b/docs/design/pages.md @@ -119,16 +119,24 @@ These are rendered by a shared `` component that wraps every page's ### Shipped section order -The page stacks six `` blocks, each a summary of one resource with a -"View all" link to its full page, in this order: - -1. **Users** — top 5 by spend in the range. Hidden while the page is scoped to a +The page stacks six `` blocks, each a summary of one resource with +a "View all" link to its full page, in this order: + +1. **Span activity** — a grid of cells, one span-count bucket each, GitHub + contribution-graph style. First, because it is the page's pulse; everything + under it is the itemised answer. The range picks the grid and the + `granularity` it fetches: 53 × 7 days on `Year` and `All`, 31 × 6 four-hour + cells on `Month`, 24 × 7 hourly cells on `Week`, 24 × 6 ten-minute cells on + `Day`. Cells outside the queried window — the leading edge, and the rest of + today — are drawn as an outline, never as an empty cell + ([ADR-0016](../decisions/0016-overview-activity-grid.md)). +2. **Users** — top 5 by spend in the range. Hidden while the page is scoped to a single user via `?user_id=`, where a top-5-users table would be the one panel on the page not answering for that user. -2. **History** — activity area chart. `hour` granularity on the `Day` range, - `day` otherwise. -3. **Costs** — daily spend line. No inner by-model table: the Models block below - is the same data at full width. +3. **Activity & Cost** — spans as a filled area against the left axis and cost as + a line against the right, from one `/history` call. `hour` granularity on the + `Day` range, `day` otherwise; links to both full pages + ([ADR-0015](../decisions/0015-overview-activity-and-cost-one-block.md)). 4. **Tools** — top 5 by call count. 5. **Models** — all models by span count. 6. **Sessions** — 5 most recent. Last, because it is the only block that cannot diff --git a/docs/operations/api-reference.md b/docs/operations/api-reference.md index 995d076..32a59eb 100644 --- a/docs/operations/api-reference.md +++ b/docs/operations/api-reference.md @@ -87,7 +87,7 @@ Activity over time, bucketed at the requested `granularity`. | Param | Values | Default | Meaning | |---|---|---|---| -| `granularity` | `hour` \| `day` \| `week` \| `month` | `day` | Bucket width for `buckets` and `by_model` | +| `granularity` | `10m` \| `hour` \| `4h` \| `day` \| `week` \| `month` | `day` | Bucket width for `buckets` and `by_model`; an unrecognised value falls back to `day` | | `range` | `all` \| `year` \| `month` \| `week` \| `day` | `month` | Rolling window, as above | | `from`, `to` | `YYYY-MM-DD` | — | Explicit bounds; when either is present they win and `range` echoes `null` | | `user_id` | user id \| `__anonymous__` | — | Scopes every figure to one principal | @@ -116,16 +116,23 @@ Activity over time, bucketed at the requested `granularity`. does not bound — `range=all` reports `"from": null`, and any range-scoped request reports `"to": null` because the window runs to request time. +`bucket` is a UTC wall-clock label: `YYYY-MM-DD` at `day` and coarser, +`YYYY-MM-DD HH:MM` at the sub-day widths, floored to the width (`4h` to +`00:00`/`04:00`/…, `10m` to `:00`/`:10`/…). It is UTC on any host — +`CAST(start_time AS TIMESTAMP)` renders the stored `TIMESTAMPTZ` in UTC whatever +the server's timezone is — so a client can reconstruct a bucket label for an +instant without asking what the server thinks midnight is. + **Which parts span the roll-up.** At `day`, `week` and `month` granularity, `buckets` and `by_model` are answered from the `spans` ∪ `daily_usage` union at the raw-floor split, so `year` and `all` keep charting after retention has deleted the raw spans. Two fields report where that stops: - **`covered_since`** clamps `buckets` and `by_model`. It is always `null` at - `day`, `week` and `month`. At **`hour`** it names the raw floor when the window - reaches past it: `daily_usage` buckets whole UTC days and cannot produce a - sub-day bucket, so an hour series is raw-only rather than day-shaped data under - an hour label. + `day`, `week` and `month`. At the sub-day widths — **`10m`, `hour`, `4h`** — it + names the raw floor when the window reaches past it: `daily_usage` buckets + whole UTC days and cannot produce a sub-day bucket, so those series are + raw-only rather than day-shaped data under a sub-day label. - **`heatmap_covered_since`** clamps `heatmap`, which resolves hour of day at every granularity and is therefore always raw-only. diff --git a/frontend/src/components/ActivityGrid.module.css b/frontend/src/components/ActivityGrid.module.css new file mode 100644 index 0000000..8d2edf5 --- /dev/null +++ b/frontend/src/components/ActivityGrid.module.css @@ -0,0 +1,94 @@ +.wrap { + position: relative; +} + +.scroll { + overflow-x: auto; + padding-bottom: var(--space-1); +} + +.grid { + display: grid; + gap: 3px; + align-items: center; + /* Below this the cells stop being distinguishable, so the block scrolls + sideways instead of collapsing. */ + min-width: 560px; +} + +.colLabel { + font-size: 10px; + color: var(--color-text-3); + line-height: 14px; + white-space: nowrap; + /* Labels sit on every third column at most, so they may overhang the one + they label rather than being clipped by it. */ + overflow: visible; + justify-self: start; +} + +.rowLabel { + font-size: 10px; + color: var(--color-text-3); + text-align: right; + padding-right: var(--space-2); + white-space: nowrap; +} + +.cell { + height: 100%; + border-radius: 2px; + cursor: default; + transition: opacity var(--duration-fast); +} + +.cell:hover { + opacity: 0.8; +} + +/* Outside the queried window — before it opened or still in the future. Drawn + as an outline so it reads as "not asked for", never as a zero. */ +.cellAbsent { + height: 100%; + border-radius: 2px; + box-shadow: inset 0 0 0 1px var(--color-border); +} + +.footer { + display: flex; + align-items: center; + gap: var(--space-1); + margin-top: var(--space-3); +} + +.legendLabel { + font-size: var(--text-xs); + color: var(--color-text-3); +} + +.legendCell { + width: 12px; + height: 12px; + border-radius: 2px; +} + +.footNote { + margin-left: var(--space-3); + font-size: var(--text-xs); + color: var(--color-text-3); +} + +.floatTip { + position: fixed; + pointer-events: none; + z-index: var(--z-tooltip); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-base); + box-shadow: var(--shadow-2); + padding: var(--space-2) var(--space-3); + font-size: var(--text-sm); + color: var(--color-text-1); + line-height: 1.5; + white-space: nowrap; +} diff --git a/frontend/src/components/ActivityGrid.tsx b/frontend/src/components/ActivityGrid.tsx new file mode 100644 index 0000000..7d5ac9c --- /dev/null +++ b/frontend/src/components/ActivityGrid.tsx @@ -0,0 +1,272 @@ +import { useMemo, useState } from 'react' +import type { HistoryBucket } from '../api' +import type { RangeKey } from '../lib/range' +import { HEAT_STEPS, heatFill, heatScale } from '../lib/heat' +import styles from './ActivityGrid.module.css' + +const MINUTE = 60_000 +const HOUR = 60 * MINUTE +const DAY = 24 * HOUR + +// Every range draws the same shape — a wide, seven-or-six-row lattice about +// 115px tall — so the block does not resize under a reader who switches range. +// What changes is the two time units the axes carry. +interface GridSpec { + granularity: string + cols: number + rows: number + cellMs: number + cellHeight: number + // Cells run down a column before moving right, the way a GitHub contribution + // graph reads, except on the week grid: a column there is one hour of the + // day, so time runs across a row and each row is a whole day. + rowMajor?: boolean + // Width reserved for the row labels down the left edge. + labelWidth: number + cellLabel: string + // start is the first cell of the lattice, so that the last cell holds now. + start: (now: Date) => Date + colLabel: (t: Date, col: number, cols: number) => string + rowLabel: (t: Date, row: number) => string +} + +// The granularity each range asks /history for: one bucket per cell, so the +// grid never has to re-bucket a series the server already bucketed. +export const GRID_GRANULARITY: Record = { + all: 'day', + year: 'day', + month: '4h', + week: 'hour', + day: '10m', +} + +const SHORT_MONTH = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] +const SHORT_DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + +function utcMidnight(d: Date): Date { + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())) +} + +function utcHour(d: Date): Date { + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours())) +} + +const pad = (n: number) => String(n).padStart(2, '0') + +// bucketKey rebuilds the label /history emits for an instant. CAST(start_time +// AS TIMESTAMP) renders the stored TIMESTAMPTZ in UTC whatever the server's +// timezone is, so the whole grid is placed in UTC and every cell start falls on +// a bucket boundary by construction. +function bucketKey(ms: number, cellMs: number): string { + const d = new Date(ms) + const date = `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}` + if (cellMs >= DAY) return date + return `${date} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}` +} + +const YEAR_SPEC: GridSpec = { + granularity: 'day', + cols: 53, + rows: 7, + cellMs: DAY, + cellHeight: 14, + labelWidth: 30, + cellLabel: 'day', + // Anchored on the weekday, so a row is always the same weekday and the last + // column is the week in progress. + start: (now) => new Date(utcMidnight(now).getTime() - (52 * 7 + now.getUTCDay()) * DAY), + colLabel: (t) => (t.getUTCDate() <= 7 ? SHORT_MONTH[t.getUTCMonth()] : ''), + rowLabel: (_t, row) => (row % 2 === 1 ? SHORT_DOW[row] : ''), +} + +const MONTH_SPEC: GridSpec = { + granularity: '4h', + cols: 31, + rows: 6, + cellMs: 4 * HOUR, + cellHeight: 17, + labelWidth: 30, + cellLabel: '4 hours', + start: (now) => new Date(utcMidnight(now).getTime() - 30 * DAY), + colLabel: (t, col, cols) => { + if (t.getUTCDate() === 1) return SHORT_MONTH[t.getUTCMonth()] + return (cols - 1 - col) % 3 === 0 ? String(t.getUTCDate()) : '' + }, + rowLabel: (t) => `${pad(t.getUTCHours())}h`, +} + +const WEEK_SPEC: GridSpec = { + granularity: 'hour', + cols: 24, + rows: 7, + cellMs: HOUR, + cellHeight: 14, + rowMajor: true, + labelWidth: 52, + cellLabel: 'hour', + start: (now) => new Date(utcMidnight(now).getTime() - 6 * DAY), + colLabel: (t, col) => (col % 3 === 0 ? `${t.getUTCHours()}h` : ''), + rowLabel: (t) => `${SHORT_DOW[t.getUTCDay()]} ${t.getUTCDate()}`, +} + +const DAY_SPEC: GridSpec = { + granularity: '10m', + cols: 24, + rows: 6, + cellMs: 10 * MINUTE, + cellHeight: 17, + labelWidth: 30, + cellLabel: '10 minutes', + start: (now) => new Date(utcHour(now).getTime() - 23 * HOUR), + colLabel: (t, col) => (col % 3 === 0 ? `${t.getUTCHours()}h` : ''), + rowLabel: (t) => `:${pad(t.getUTCMinutes())}`, +} + +const SPECS: Record = { + // All keeps the year lattice: an unbounded window has no grid of its own, and + // a year of days is the coarsest cell the four ranges use. + all: YEAR_SPEC, + year: YEAR_SPEC, + month: MONTH_SPEC, + week: WEEK_SPEC, + day: DAY_SPEC, +} + +// Lower bound of the window /history answered, matching rangeSince server-side. +// A cell that ends at or before it was never queried, and is drawn as absent +// rather than as an empty cell — the grid must not show a zero it did not ask +// for. +const WINDOW_MS: Record = { + all: Infinity, + year: 365 * DAY, + month: 30 * DAY, + week: 7 * DAY, + day: DAY, +} + +interface Cell { + col: number + row: number + t: number + count: number + covered: boolean +} + +function cellTitle(t: number, cellMs: number): string { + const date = new Date(t).toLocaleDateString('en-GB', { + timeZone: 'UTC', weekday: 'short', day: 'numeric', month: 'short', + }) + if (cellMs >= DAY) return `${date} (UTC)` + const clock = (ms: number) => + new Date(ms).toLocaleTimeString('en-GB', { + timeZone: 'UTC', hour: '2-digit', minute: '2-digit', hour12: false, + }) + return `${date}, ${clock(t)}–${clock(t + cellMs)} UTC` +} + +export interface ActivityGridProps { + range: RangeKey + buckets: HistoryBucket[] +} + +export function ActivityGrid({ range, buckets }: ActivityGridProps) { + const [tip, setTip] = useState<{ x: number; y: number; cell: Cell } | null>(null) + const spec = SPECS[range] + + const { cells, fill, max } = useMemo(() => { + // Re-anchored on each payload rather than on each render, so the lattice + // holds still between refreshes instead of sliding under the cursor. + const now = Date.now() + const counts = new Map() + buckets.forEach((b) => counts.set(b.bucket, Number(b.spans))) + + const start = spec.start(new Date(now)).getTime() + const windowStart = now - WINDOW_MS[range] + const out: Cell[] = [] + let max = 0 + for (let i = 0; i < spec.cols * spec.rows; i++) { + const t = start + i * spec.cellMs + const count = counts.get(bucketKey(t, spec.cellMs)) ?? 0 + out.push({ + col: spec.rowMajor ? i % spec.cols : Math.floor(i / spec.rows), + row: spec.rowMajor ? Math.floor(i / spec.cols) : i % spec.rows, + t, + count, + covered: t + spec.cellMs > windowStart && t <= now, + }) + if (count > max) max = count + } + // The scale is cut on the cells actually drawn, not on the whole response: + // on `all` the series runs further back than the lattice reaches. + return { cells: out, fill: heatScale(out.filter((c) => c.covered).map((c) => c.count)), max } + }, [buckets, range, spec]) + + // Labels read off the lattice itself, so they cannot drift from the cells. + const colLabels = cells + .filter((c) => c.row === 0) + .map((c) => ({ col: c.col, text: spec.colLabel(new Date(c.t), c.col, spec.cols) })) + .filter((l) => l.text) + const rowLabels = cells + .filter((c) => c.col === 0) + .map((c) => ({ row: c.row, text: spec.rowLabel(new Date(c.t), c.row) })) + .filter((l) => l.text) + + return ( +
+
setTip(null)}> +
+ {colLabels.map(({ col, text }) => ( +
+ {text} +
+ ))} + {rowLabels.map(({ row, text }) => ( +
+ {text} +
+ ))} + {cells.map((cell) => ( +
cell.covered && setTip({ x: e.clientX, y: e.clientY, cell })} + onMouseMove={(e) => setTip((prev) => (prev ? { ...prev, x: e.clientX, y: e.clientY } : null))} + /> + ))} +
+
+ +
+ Less + {HEAT_STEPS.map((pct) => ( +
+ ))} + More + + one cell = {spec.cellLabel}, UTC · busiest {max.toLocaleString()} span{max === 1 ? '' : 's'} + +
+ + {tip && ( +
+ {cellTitle(tip.cell.t, spec.cellMs)} +
+ {tip.cell.count.toLocaleString()} span{tip.cell.count === 1 ? '' : 's'} +
+ )} +
+ ) +} diff --git a/frontend/src/components/index.tsx b/frontend/src/components/index.tsx index a331077..a056ba7 100644 --- a/frontend/src/components/index.tsx +++ b/frontend/src/components/index.tsx @@ -21,6 +21,7 @@ export { ErrorState } from './ErrorState' export { RefreshIndicator } from './RefreshIndicator' export { DateRangePicker } from './DateRangePicker' export { ChartTooltip } from './ChartTooltip' +export { ActivityGrid, GRID_GRANULARITY } from './ActivityGrid' // Alias for pages still importing LoadingState export { LoadingSkeleton as LoadingState } from './LoadingSkeleton' diff --git a/frontend/src/lib/heat.ts b/frontend/src/lib/heat.ts new file mode 100644 index 0000000..996388f --- /dev/null +++ b/frontend/src/lib/heat.ts @@ -0,0 +1,41 @@ +// Shared fill scale for every cell grid in the dashboard: the History calendar +// and hour-of-day heatmaps and the Overview activity grid all read against the +// same five steps, so a reader who learns the scale on one page keeps it on the +// next. + +// Percentages of --color-chart-1 mixed over the empty-cell colour. Four filled +// steps plus the empty one is what GitHub's contribution graph uses, and it is +// about as many as the eye separates reliably at this cell size. +const FILLED = [20, 45, 70, 100] + +export const HEAT_STEPS = [0, ...FILLED] + +export function heatFill(pct: number): string { + if (pct <= 0) return 'var(--color-surface-2)' + if (pct >= 100) return 'var(--color-chart-1)' + return `color-mix(in srgb, var(--color-chart-1) ${pct}%, var(--color-surface-2))` +} + +// heatScale cuts the counts in view at their own quartiles, so each step holds +// about a quarter of the busy cells whatever the shape of the distribution. +// Scaling against the maximum instead — linearly or on a log — puts most of a +// working week in the top step and reads as one flat block: a day of 200 spans +// and a day of 700 are both "busy" against a 722-span peak, which is exactly the +// difference the grid exists to show. +export function heatScale(counts: number[]): (count: number) => string { + const busy = counts.filter((c) => c > 0).sort((a, b) => a - b) + if (busy.length === 0) return () => heatFill(0) + + // A run with no spread has no quartiles to cut at; every busy cell is the peak. + if (busy[0] === busy[busy.length - 1]) { + return (count) => heatFill(count > 0 ? 100 : 0) + } + + const cuts = [0.25, 0.5, 0.75].map((p) => busy[Math.floor((busy.length - 1) * p)]) + return (count) => { + if (count <= 0) return heatFill(0) + let step = 0 + while (step < cuts.length && count > cuts[step]) step++ + return heatFill(FILLED[step]) + } +} diff --git a/frontend/src/pages/History.tsx b/frontend/src/pages/History.tsx index 127fc21..d53304f 100644 --- a/frontend/src/pages/History.tsx +++ b/frontend/src/pages/History.tsx @@ -9,6 +9,7 @@ import type { HistoryResponse, HeatmapCell } from '../api' import { Card, KpiCard, EmptyState, ErrorState, KpiSkeleton, ChartSkeleton, ChartTooltip, } from '../components' +import { HEAT_STEPS, heatFill, heatScale } from '../lib/heat' import styles from './History.module.css' type Granularity = 'hour' | 'day' | 'week' | 'month' @@ -68,18 +69,6 @@ interface CalHeatmapProps { to: string } -function heatColor(count: number, max: number): string { - if (count === 0) return 'var(--color-surface-2)' - const intensity = Math.min(1, Math.log(count + 1) / Math.log(max + 1)) - if (intensity < 0.25) - return 'color-mix(in srgb, var(--color-chart-1) 20%, var(--color-surface-2))' - if (intensity < 0.5) - return 'color-mix(in srgb, var(--color-chart-1) 45%, var(--color-surface-2))' - if (intensity < 0.75) - return 'color-mix(in srgb, var(--color-chart-1) 70%, var(--color-surface-2))' - return 'var(--color-chart-1)' -} - function CalendarHeatmap({ days, from, to }: CalHeatmapProps) { const [tip, setTip] = useState<{ x: number; y: number; info: DayInfo } | null>(null) @@ -89,7 +78,7 @@ function CalendarHeatmap({ days, from, to }: CalHeatmapProps) { return m }, [days]) - const maxCount = useMemo(() => Math.max(1, ...days.map(d => d.count)), [days]) + const fill = useMemo(() => heatScale(days.map(d => d.count)), [days]) // Build all dates from→to as UTC date strings const allDates = useMemo(() => { @@ -159,7 +148,7 @@ function CalendarHeatmap({ days, from, to }: CalHeatmapProps) { x={x} y={y} width={CELL} height={CELL} rx={2} - style={{ fill: heatColor(info.count, maxCount), cursor: 'default' }} + style={{ fill: fill(info.count), cursor: 'default' }} onMouseEnter={(e) => handleMouseEnter(e, info)} onMouseMove={(e) => setTip(t => t ? { ...t, x: e.clientX, y: e.clientY } : null)} /> @@ -173,16 +162,8 @@ function CalendarHeatmap({ days, from, to }: CalHeatmapProps) { {/* Legend */}
Less - {[0, 0.2, 0.45, 0.7, 1].map((v, i) => ( -
+ {HEAT_STEPS.map((pct) => ( +
))} More
@@ -219,10 +200,7 @@ function HourDowHeatmap({ heatmap }: HourDowHeatmapProps) { return g }, [heatmap]) - const maxCount = useMemo(() => - Math.max(1, ...grid.flatMap(row => row)), - [grid], - ) + const fill = useMemo(() => heatScale(grid.flat()), [grid]) const hours = Array.from({ length: 24 }, (_, i) => i) @@ -246,7 +224,7 @@ function HourDowHeatmap({ heatmap }: HourDowHeatmapProps) {
setTip({ x: e.clientX, y: e.clientY, dow, hour: h, count })} onMouseMove={(e) => setTip(t => t ? { ...t, x: e.clientX, y: e.clientY } : null)} /> diff --git a/frontend/src/pages/Overview.tsx b/frontend/src/pages/Overview.tsx index 3a50355..2eafa3b 100644 --- a/frontend/src/pages/Overview.tsx +++ b/frontend/src/pages/Overview.tsx @@ -11,6 +11,7 @@ import type { SessionItem, ToolItem, ModelItem, User } from '../api' import { KpiCard, DataTable, EmptyState, ErrorState, RefreshIndicator, SegmentedControl, KpiSkeleton, ChartSkeleton, LoadingSkeleton, sessionStatusBadge, failRateBadge, ChartTooltip, + ActivityGrid, GRID_GRANULARITY, } from '../components' import { StatSection } from '../components/StatSection' import { RANGE_OPTIONS, RANGE_SUFFIX, useRangeCookie } from '../lib/range' @@ -76,6 +77,37 @@ function UsersSection({ range }: { range: RangeKey }) { ) } +// The grid asks for one bucket per cell, so the cell width follows the range: +// days over a year, ten minutes over a day. On the year and all ranges that is +// the same request ActivitySection already makes, and SWR serves both from one +// fetch. +function ActivityGridSection({ range, userId }: SectionProps) { + const { data, isLoading, error } = useHistory( + GRID_GRANULARITY[range], + undefined, + undefined, + userId, + range, + ) + + if (isLoading && !data) return + if (error) return + if (!data || data.buckets.length === 0) + return + + return ( + <> + + {data.covered_since && ( +

+ Cells start at {formatDay(data.covered_since)} — anything finer than a day is built from raw + spans, and earlier days in this range survive only as whole-day totals. +

+ )} + + ) +} + // 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 @@ -393,6 +425,10 @@ export default function Overview() {
) : null} + + + + {!userId && ( diff --git a/internal/api/handler.go b/internal/api/handler.go index c5b72a8..7c2f940 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -1447,21 +1447,36 @@ type historyResponse struct { // from/to bounds superseded it (ADR-0014). Range *string `json:"range"` // CoveredSince names the start of the window buckets and by_model actually - // answer for, or null when the selected window is fully covered. Only "hour" - // can fall short: the roll-up consumes whole UTC days, so sub-day buckets - // exist for raw spans alone. Coarser granularities span the union and always - // report null. + // answer for, or null when the selected window is fully covered. Only the + // sub-day widths can fall short: the roll-up consumes whole UTC days, so + // sub-day buckets exist for raw spans alone. Coarser granularities span the + // union and always report null. CoveredSince *string `json:"covered_since"` // HeatmapCoveredSince is the same clamp for heatmap, which resolves hour of // day at every granularity and so is raw-only whatever the caller asked for. HeatmapCoveredSince *string `json:"heatmap_covered_since"` } +// isSubDayGranularity reports whether the bucket width is finer than a calendar +// day. The roll-up consumes whole UTC days, so a series at one of these widths +// can only be answered from raw spans. +func isSubDayGranularity(gran string) bool { + switch gran { + case "10m", "hour", "4h": + return true + } + return false +} + // historyBucketExpr buckets raw spans by their start time. func historyBucketExpr(gran string) string { switch gran { + case "10m": + return "strftime(time_bucket(INTERVAL '10 minutes', CAST(start_time AS TIMESTAMP)), '%Y-%m-%d %H:%M')" case "hour": return "strftime(CAST(start_time AS TIMESTAMP), '%Y-%m-%d %H:00')" + case "4h": + return "strftime(time_bucket(INTERVAL '4 hours', CAST(start_time AS TIMESTAMP)), '%Y-%m-%d %H:%M')" case "week": return "strftime(date_trunc('week', CAST(start_time AS TIMESTAMP))::TIMESTAMP, '%Y-%m-%d')" case "month": @@ -1472,7 +1487,7 @@ func historyBucketExpr(gran string) string { } // historyUnionBucketExpr buckets the usageCTE's whole-day rows. There is no -// "hour" case by construction: daily_usage keeps no intra-day timestamp, so an +// sub-day case by construction: daily_usage keeps no intra-day timestamp, so an // hour bucket cannot be built from the aggregate side at all. func historyUnionBucketExpr(gran string) string { switch gran { @@ -1510,7 +1525,7 @@ func isoDay(t *time.Time) *string { func (h *Handler) handleHistory(w http.ResponseWriter, r *http.Request) { gran := r.URL.Query().Get("granularity") switch gran { - case "hour", "day", "week", "month": + case "10m", "hour", "4h", "day", "week", "month": default: gran = "day" } @@ -1538,7 +1553,7 @@ func (h *Handler) handleHistory(w http.ResponseWriter, r *http.Request) { } var err error - if gran == "hour" { + if isSubDayGranularity(gran) { resp.CoveredSince = resp.HeatmapCoveredSince err = h.historyRawSeries(&resp, r, gran, from, to) } else { diff --git a/internal/api/history_grid_test.go b/internal/api/history_grid_test.go new file mode 100644 index 0000000..00f1e1c --- /dev/null +++ b/internal/api/history_grid_test.go @@ -0,0 +1,136 @@ +package api_test + +import ( + "net/http" + "testing" + "time" + + "github.com/Flopsstuff/cotel/internal/api" + "github.com/Flopsstuff/cotel/internal/storage" +) + +// gridAnchor is a whole UTC hour three hours back: inside every sub-day window, +// and far enough from both ends of the day range that the fixture cannot drift +// out of it while the test runs. +func gridAnchor() time.Time { + return time.Now().UTC().Truncate(time.Hour).Add(-3 * time.Hour) +} + +// seedGridFixture puts two spans in one 10-minute bucket and a third in the +// next, all inside the same hour and so inside one 4-hour bucket. +func seedGridFixture(t *testing.T, db *storage.DB) { + t.Helper() + anchor := gridAnchor() + for i, offset := range []time.Duration{0, 7 * time.Minute, 12 * time.Minute} { + start := anchor.Add(offset) + insertSpan(t, db, storage.Span{ + TraceID: "tr", SpanID: string(rune('a'+i)) + "-grid", Name: "llm", + SessionID: "s-grid", Model: "sonnet", ToolName: "Bash", UserID: "alice", + StartTime: start, EndTime: start.Add(time.Second), + CostUSD: ptr(1.0), InputTokens: ptr(int64(10)), OutputTokens: ptr(int64(1)), + }) + } +} + +// TestHistory_TenMinuteGranularity is the bucket width the Overview activity +// grid draws one cell per on the day range. The labels are asserted in UTC: +// CAST(start_time AS TIMESTAMP) renders the stored TIMESTAMPTZ in UTC whatever +// the session timezone is, and the grid places cells on that basis. +func TestHistory_TenMinuteGranularity(t *testing.T) { + db, ro := openTestDB(t) + seedGridFixture(t, db) + h := api.New(ro) + + code, body := getJSON(t, h, "/api/v1/history?granularity=10m&range=day") + if code != http.StatusOK { + t.Fatalf("want 200, got %d: %v", code, body) + } + if body["granularity"] != "10m" { + t.Fatalf("granularity echo: want 10m, got %v", body["granularity"]) + } + + anchor := gridAnchor() + want := map[string]float64{ + anchor.Format("2006-01-02 15:04"): 2, + anchor.Add(10 * time.Minute).Format("2006-01-02 15:04"): 1, + } + got := pairs(t, body, "buckets", "bucket", "spans") + if len(got) != len(want) { + t.Fatalf("buckets: want %v, got %v", want, got) + } + for bucket, spans := range want { + if got[bucket] != spans { + t.Errorf("bucket %s: want %v spans, got %v — full map %v", bucket, spans, got[bucket], got) + } + } +} + +// TestHistory_FourHourGranularity pins the month grid's cell: the three spans +// share one hour, so they land in one bucket, aligned to a multiple of four +// hours from UTC midnight rather than to the first span's own hour. +func TestHistory_FourHourGranularity(t *testing.T) { + db, ro := openTestDB(t) + seedGridFixture(t, db) + h := api.New(ro) + + code, body := getJSON(t, h, "/api/v1/history?granularity=4h&range=month") + if code != http.StatusOK { + t.Fatalf("want 200, got %d: %v", code, body) + } + if body["granularity"] != "4h" { + t.Fatalf("granularity echo: want 4h, got %v", body["granularity"]) + } + + a := gridAnchor() + slot := time.Date(a.Year(), a.Month(), a.Day(), a.Hour()/4*4, 0, 0, 0, time.UTC) + got := pairs(t, body, "buckets", "bucket", "spans") + if len(got) != 1 || got[slot.Format("2006-01-02 15:04")] != 3 { + t.Errorf("buckets: want the 3 spans in %s alone, got %v", slot.Format("2006-01-02 15:04"), got) + } +} + +// TestHistory_SubDayGranularitiesStayRawOnly extends to the two new widths the +// line TestHistory_HourStaysRawOnly holds for hour: daily_usage buckets whole +// UTC days, so nothing below a day may be answered from it, and the response +// says where its coverage starts instead of relabelling day-shaped data. +func TestHistory_SubDayGranularitiesStayRawOnly(t *testing.T) { + db, ro := openTestDB(t) + seedRangeFixture(t, db) + h := api.New(ro) + + for _, gran := range []string{"10m", "4h"} { + t.Run(gran, func(t *testing.T) { + _, body := getJSON(t, h, "/api/v1/history?granularity="+gran+"&range=all") + if got := len(bucketList(t, body)); got != 2 { + t.Errorf("buckets: want the 2 raw spans' buckets, got %d (%v)", got, body["buckets"]) + } + if got := sumBuckets(t, body, "spans"); got != 2 { + t.Errorf("spans: want 2 raw spans, got %v — aggregate rows leaked into a sub-day bucket", got) + } + if models := sumByModel(t, body); len(models) != 1 || models["sonnet"] != 2 { + t.Errorf("by_model: want only the raw sonnet spans, got %v", models) + } + if body["covered_since"] == nil { + t.Error("covered_since: want the raw floor on range=all, got null") + } + }) + } +} + +// TestHistory_UnknownGranularityFallsBack keeps the fallback-don't-400 rule the +// range keys already follow: a width we do not serve resolves to day. +func TestHistory_UnknownGranularityFallsBack(t *testing.T) { + db, ro := openTestDB(t) + seedGridFixture(t, db) + h := api.New(ro) + + for _, gran := range []string{"5m", "3h", ""} { + code, body := getJSON(t, h, "/api/v1/history?granularity="+gran+"&range=day") + if code != http.StatusOK { + t.Fatalf("granularity=%q: want 200, got %d", gran, code) + } + if body["granularity"] != "day" { + t.Errorf("granularity=%q: want the day fallback, got %v", gran, body["granularity"]) + } + } +} diff --git a/internal/dashboard/static/index.html b/internal/dashboard/static/index.html index e4b32b2..64f376a 100644 --- a/internal/dashboard/static/index.html +++ b/internal/dashboard/static/index.html @@ -5,8 +5,8 @@ cotel - - + +