From aefad039a210fec78451c9a3622d27a6634c9aef Mon Sep 17 00:00:00 2001 From: Daedalus Date: Thu, 20 Aug 2026 22:27:41 +0200 Subject: [PATCH 1/5] =?UTF-8?q?docs(decisions):=20ADR-0014=20=E2=80=94=20o?= =?UTF-8?q?ne=20range=20selector=20every=20Overview=20panel=20obeys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Overview stacks five panels on three different time windows: KPIs and Costs on a hardcoded 30 days, History on 30 days computed in the page, Sessions and Models on all time with no filter available, Tools on the range key it already accepts. Only the KPI labels name their window, and they do it as a literal "(30d)" in the string. Extend the range contract from ADR-0011/0012 to /overview, /sessions, /costs and /models rather than translating in the frontend (which cannot scope three of them at all, and would answer year/all from spans alone after retention has deleted them) or adding a second aggregated endpoint that would drift from the pages each section links to. Defaults preserve current behaviour instead of converging: month for /overview and /costs, all for /sessions and /models. Explicit from/to beats range on /costs. Long ranges resolve against the spans ∪ daily_usage union. The sessions list cannot reconstruct rows for rolled-up days, so it clamps to raw coverage and reports covered_since rather than absorbing the shortfall. Co-Authored-By: Daedalus Co-Authored-By: Claude Opus 5 --- .../0014-overview-single-range-selector.md | 119 ++++++++++++++++++ docs/decisions/index.md | 1 + 2 files changed, 120 insertions(+) create mode 100644 docs/decisions/0014-overview-single-range-selector.md diff --git a/docs/decisions/0014-overview-single-range-selector.md b/docs/decisions/0014-overview-single-range-selector.md new file mode 100644 index 0000000..944da86 --- /dev/null +++ b/docs/decisions/0014-overview-single-range-selector.md @@ -0,0 +1,119 @@ +# ADR 0014 — Overview: one range selector every panel obeys + +**Date:** 2026-08-20 +**Status:** Accepted +**Deciders:** Daedalus (CTO) + +--- + +## Context + +The Overview page is the dashboard's front door: five KPIs and a stack of +sections that each summarise one resource and link to its full page. Today those +panels do not agree on what "now" means. + +| Panel | Endpoint | Window it actually shows | +|---|---|---| +| KPIs | `GET /overview` | last 30 days, hardcoded server-side | +| Sessions | `GET /sessions` | all time — no time filter exists | +| History | `GET /history` | last 30 days, from/to computed in the page | +| Costs | `GET /costs` | last 30 days, from/to defaulted server-side | +| Tools | `GET /tools` | `range=all`, passed by the page | +| Models | `GET /models` | all time — no time filter exists | + +Three different windows on one screen, and only the KPI labels say which — as a +literal `(30d)` baked into the string. A reader comparing the Sessions count KPI +against the Models table below it is comparing 30 days against all time, with +nothing on the page to say so. + +`range` already exists as a contract. ADR-0011 introduced it for the Users list +and ADR-0012 extended it to Tools: five rolling-window keys +(`all|year|month|week|day`), `month` the default, unrecognised values falling +back rather than 400ing, and the window answered from a **union of raw `spans` +and rolled-up `daily_usage`** split at the earliest surviving raw day. That union +is not decoration. The retention worker (ADR-0009) rolls spans older than +`RawDays` (default 30) into `daily_usage` and deletes them, so any endpoint that +queries `spans` alone answers `year` and `all` with the same number it answers +`month` — a confident wrong total, which ADR-0011 rejected as worse than having +no switcher at all. + +The board asked for a single range selector in the Overview header that every +figure on the page obeys. That request is unsatisfiable without deciding how far +the existing `range` contract reaches. + +## Options considered + +1. **Translate in the frontend only.** `/costs` and `/history` already take + `from`/`to`; the page could derive them from the selected range and leave the + API alone. Rejected on two counts. It cannot scope `/overview`, `/sessions` + or `/models` at all — they have no time parameter to translate into. And it + would answer long ranges from `spans` alone, reintroducing the exact defect + ADR-0011 exists to prevent. + +2. **One fat `GET /overview?range=…` returning every panel.** One request, one + window, trivially consistent within the page. Rejected: it is a second + contract for numbers the per-resource endpoints already own, and each section + links to a full page served by those endpoints. Two independent + implementations of "cost in the last 30 days" will diverge, and the place it + shows up is a summary that disagrees with the page it links to. + +3. **Extend the existing `range` contract to the remaining read endpoints + (chosen).** One parameter, one meaning, one union, everywhere the dashboard + reads. The Overview then holds no time logic of its own — it picks a key and + passes it down. + +## Decision + +`GET /api/v1/overview`, `/sessions`, `/costs` and `/models` accept `range` with +the same five keys, the same rolling-window semantics, and the same +fallback-don't-400 rule as `/users` and `/tools`. Each response echoes the +`range` it actually used. + +**Defaults preserve today's behaviour rather than converging on one value.** +`/overview` and `/costs` default to `month`, which is the 30-day window they +already apply. `/sessions` and `/models` default to `all`, because they have no +time filter today and a `month` default would silently truncate every existing +caller. A page that wants a window asks for one; no caller's meaning changes +under it. + +**Explicit bounds beat the range key.** `/costs` keeps `from`/`to`. When a +request carries both, `from`/`to` wins and `range` is ignored — the narrower, +more specific statement is the one the caller meant. + +**Long ranges are answered from the union, not from `spans`.** Every metric that +recomposes from additive parts — cost, token totals, span and session counts, +distinct users, per-model and per-tool totals — sums across `spans` ∪ +`daily_usage` at the raw-floor split defined in ADR-0011. + +**A panel that cannot honour the range says so.** The Sessions *list* is the one +that cannot: `daily_usage` aggregates a session's day, cost and counts, but not +the start time, model and status a session row shows, so rows for rolled-up days +cannot be reconstructed. It stays raw-only, clamps the range to raw coverage, +and returns `covered_since` (RFC3339, `null` when the range is fully covered) so +the UI states the shortfall in one line. This is the rule ADR-0012 set for the +Bash breakdown: the constraint is displayed, not absorbed. The *session count* +KPI is unaffected — `daily_usage` carries `session_id`, so counting distinct +sessions across the union is exact. + +The selected range persists in its own cookie, `cotel_overview_range`, per the +per-page rule in `useRangeCookie`: changing the range on Overview must not move +the Users or Tools page under a reader who switches tabs. + +## Consequences + +- The Overview stops being five windows stacked vertically. Every number on it + answers the same question, and the KPI labels carry the range suffix + (`RANGE_SUFFIX`) instead of a hardcoded `(30d)`. +- `/sessions` and `/models` gain a parameter and change no existing behaviour. + Callers that pass no `range` see exactly what they see today. +- `/sessions` grows `covered_since`, so a client can tell "no sessions in this + window" apart from "the window reaches past raw retention". It self-retires + the same way `duration_stats_since` does not: raw coverage is a standing + property of retention, so this field is permanent, not transitional. +- Six of the dashboard's read endpoints now share one time contract. The next + one is a parameter, not a design. +- `?user_id=` scoping on Overview is orthogonal and stays. It composes with + `range` on every endpoint above. +- The union costs a second scan over `daily_usage` on every Overview load. The + table is one row per (day, session, model, tool) and capped at + `AggregateDays` (90); the Users list already pays this on its default range. diff --git a/docs/decisions/index.md b/docs/decisions/index.md index 9b4dd07..f215b33 100644 --- a/docs/decisions/index.md +++ b/docs/decisions/index.md @@ -21,3 +21,4 @@ New ADRs go in this directory as `NNNN-short-title.md`, numbered sequentially. | [ADR-0011](./0011-users-list-ranged-stats-and-server-side-sort) | Users list — time-ranged stats, server-side sort and pagination | Accepted | | [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 | From 70e54f6c5bbbc62adf3131039898aa2cf6527774 Mon Sep 17 00:00:00 2001 From: Wayland Date: Thu, 20 Aug 2026 22:38:44 +0200 Subject: [PATCH 2/5] feat(api): extend the range contract to overview, sessions, costs and models The dashboard's read endpoints disagreed on what "now" means: /overview was hardcoded to 30 days, /sessions and /models had no time filter at all, and /costs defaulted to 30 days via from/to. A single Overview range selector is unsatisfiable while those four windows are independent. All four now accept `range` with the same five keys, the same fallback-don't-400 rule, and the same spans-union-daily_usage resolution as /users and /tools. Defaults preserve today's behaviour rather than converging: /overview and /costs default to month, /sessions and /models to all, so no existing caller's meaning changes. Explicit from/to on /costs still beat the range key. Long ranges resolve over the shared usageCTE, the ADR-0011 raw-floor split, so year and all keep answering after retention has deleted the raw spans instead of silently repeating the month figure. The sessions list is the one panel that cannot come from the roll-up - daily_usage keeps no start time, model or status - so it stays raw-only, clamps, and reports covered_since rather than absorbing the shortfall. Also fixes users_count, which ignored both the range and the user_id filter, and counted the anonymous bucket as zero principals rather than one. Co-Authored-By: Wayland Co-Authored-By: Claude Opus 4.8 --- internal/api/handler.go | 375 +++++++++++++++-------- internal/api/overview_range_test.go | 445 ++++++++++++++++++++++++++++ 2 files changed, 690 insertions(+), 130 deletions(-) create mode 100644 internal/api/overview_range_test.go diff --git a/internal/api/handler.go b/internal/api/handler.go index 49fbe9a..998a263 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -151,15 +151,22 @@ func rangeSince(rangeKey string, now time.Time) *time.Time { return &t } -// parseRange reads the range query parameter, falling back to the default -// "month" for missing or unrecognised values, and returns the normalised key -// with its lower bound. +// parseRange reads the range query parameter, falling back to "month" for +// missing or unrecognised values, and returns the normalised key with its lower +// bound. func parseRange(r *http.Request) (string, *time.Time) { + return parseRangeDefault(r, "month") +} + +// parseRangeDefault is parseRange with a caller-chosen fallback. /sessions and +// /models pass "all" because they had no time filter before ADR-0014, and a +// "month" default would silently truncate every existing caller. +func parseRangeDefault(r *http.Request, def string) (string, *time.Time) { rk := r.URL.Query().Get("range") switch rk { case "all", "year", "month", "week", "day": default: - rk = "month" + rk = def } return rk, rangeSince(rk, time.Now()) } @@ -233,6 +240,102 @@ func userIDClauseOn(r *http.Request, prefix string) (clause string, arg string) return " AND " + prefix + "user_id = ?", uid } +// usageCTE unions raw spans with rolled-up daily_usage into one row set that +// every additive range-scoped figure sums over (ADR-0014). It is the ADR-0011 +// split: the roll-up consumes whole UTC days, so the earliest surviving raw day +// is fully raw and the aggregate side is bounded by `day < raw_floor` (strict) — +// `<=` would double count the boundary day. +// +// The roll-up writes UnknownSentinel into the session_id/model/tool_name primary +// key columns for spans that carried none. Mapping it back to NULL here keeps +// the aggregate side filtering exactly like the raw side, which drops NULL and +// ''; without it a phantom "unknown" model or tool appears once a window is old +// enough to have been rolled up. +// +// first_seen degrades to the aggregate's day at midnight for rolled-up rows: +// daily_usage keeps no intra-day timestamp. It is a lower bound on the real +// start, never a later one. +const usageCTE = ` +WITH raw_floor AS ( + SELECT MIN(start_time) AS ts FROM spans +), +usage AS ( + SELECT + CAST(CAST(start_time AS TIMESTAMP) AS DATE) AS day, + start_time AS first_seen, + NULLIF(session_id, '') AS session_id, + user_id, + NULLIF(model, '') AS model, + NULLIF(tool_name, '') AS tool_name, + CAST(1 AS BIGINT) AS spans, + cost_usd AS cost, + input_tokens, + output_tokens, + COALESCE(cache_read_tokens, 0) + COALESCE(cache_write_tokens, 0) AS cache_tokens + FROM spans + WHERE TRUE%[1]s + UNION ALL + SELECT + du.day, + CAST(du.day AS TIMESTAMPTZ), + NULLIF(du.session_id, '%[2]s'), + du.user_id, + NULLIF(du.model, '%[2]s'), + NULLIF(du.tool_name, '%[2]s'), + du.span_count, + du.total_cost_usd, + du.total_input_tokens, + du.total_output_tokens, + COALESCE(du.total_cache_read_tokens, 0) + COALESCE(du.total_cache_write_tokens, 0) + FROM daily_usage du CROSS JOIN raw_floor rf + WHERE (rf.ts IS NULL OR du.day < CAST(CAST(rf.ts AS TIMESTAMP) AS DATE))%[3]s +)` + +// usageFilter carries the WHERE fragments that scope usageCTE to a time window +// and, optionally, one user, together with their arguments in statement order — +// raw side first, aggregate side second. +type usageFilter struct { + raw string + agg string + args []any +} + +// newUsageFilter builds the scoping fragments for usageCTE. from and to are +// inclusive bounds; nil leaves that side unbounded. +func newUsageFilter(r *http.Request, from, to *time.Time) usageFilter { + rawUID, rawUIDArg := userIDClause(r) + aggUID, aggUIDArg := userIDClauseOn(r, "du.") + + f := usageFilter{raw: rawUID, agg: aggUID} + if rawUIDArg != "" { + f.args = append(f.args, rawUIDArg) + } + if from != nil { + f.raw += " AND start_time >= ?" + f.args = append(f.args, *from) + } + if to != nil { + f.raw += " AND start_time <= ?" + f.args = append(f.args, *to) + } + if aggUIDArg != "" { + f.args = append(f.args, aggUIDArg) + } + if from != nil { + f.agg += " AND du.day >= CAST(CAST(? AS TIMESTAMP) AS DATE)" + f.args = append(f.args, *from) + } + if to != nil { + f.agg += " AND du.day <= CAST(CAST(? AS TIMESTAMP) AS DATE)" + f.args = append(f.args, *to) + } + return f +} + +func (f usageFilter) cte() string { + return fmt.Sprintf(usageCTE, f.raw, storage.UnknownSentinel, f.agg) +} + // ---- /api/v1/users — delegated to users.go ---- // ---- /api/v1/health ---- @@ -298,6 +401,7 @@ func (h *Handler) retentionHealth() retentionHealth { // ---- /api/v1/overview ---- type overviewResponse struct { + Range string `json:"range"` SessionsCount int64 `json:"sessions_count"` UsersCount int64 `json:"users_count"` TotalCostUSD float64 `json:"total_cost_usd"` @@ -325,46 +429,43 @@ type topToolRow struct { } func (h *Handler) handleOverview(w http.ResponseWriter, r *http.Request) { - since := time.Now().AddDate(0, 0, -30) - uidClause, uid := userIDClause(r) - - var resp overviewResponse - args := []any{since} - if uid != "" { - args = append(args, uid) - } - _ = h.db.QueryRow(` - SELECT - COUNT(DISTINCT session_id), - COALESCE(SUM(cost_usd), 0), - COALESCE(SUM(input_tokens), 0), - COALESCE(SUM(output_tokens), 0), - COALESCE(SUM(cache_read_tokens) + SUM(cache_write_tokens), 0) - FROM spans - WHERE start_time >= ? AND session_id IS NOT NULL`+uidClause, - args..., - ).Scan( + rangeKey, since := parseRange(r) + f := newUsageFilter(r, since, nil) + cte := f.cte() + + resp := overviewResponse{ + Range: rangeKey, + DailyCosts: []dailyCostRow{}, + TopModels: []topModelRow{}, + TopTools: []topToolRow{}, + } + + // The anonymous bucket has no user_id, so COUNT(DISTINCT user_id) would drop + // it; it is one principal on the users list and counts as one here too. + _ = h.db.QueryRow(cte+` +SELECT + COUNT(DISTINCT session_id), + COUNT(DISTINCT user_id) + CASE WHEN COUNT(*) FILTER (WHERE user_id IS NULL) > 0 THEN 1 ELSE 0 END, + COALESCE(SUM(cost), 0), + COALESCE(SUM(input_tokens), 0), + COALESCE(SUM(output_tokens), 0), + COALESCE(SUM(cache_tokens), 0) +FROM usage +`, f.args...).Scan( &resp.SessionsCount, + &resp.UsersCount, &resp.TotalCostUSD, &resp.TotalInputTokens, &resp.TotalOutputTokens, &resp.TotalCacheTokens, ) - // Count distinct users (NULL counts as one "default" group). - _ = h.db.QueryRow(`SELECT COUNT(DISTINCT user_id) FROM spans`).Scan(&resp.UsersCount) - - costArgs := []any{since} - if uid != "" { - costArgs = append(costArgs, uid) - } - rows, _ := h.db.Query(` - SELECT strftime(CAST(start_time AS TIMESTAMP), '%Y-%m-%d') AS day, - COALESCE(SUM(cost_usd), 0) - FROM spans - WHERE start_time >= ? AND cost_usd IS NOT NULL`+uidClause+` - GROUP BY day ORDER BY day ASC - `, costArgs...) + rows, _ := h.db.Query(cte+` +SELECT strftime(day, '%Y-%m-%d') AS d, COALESCE(SUM(cost), 0) +FROM usage +WHERE cost IS NOT NULL +GROUP BY d ORDER BY d ASC +`, f.args...) if rows != nil { defer rows.Close() for rows.Next() { @@ -374,16 +475,12 @@ func (h *Handler) handleOverview(w http.ResponseWriter, r *http.Request) { } } - modelArgs := []any{since} - if uid != "" { - modelArgs = append(modelArgs, uid) - } - mrows, _ := h.db.Query(` - SELECT model, COUNT(*) AS span_count - FROM spans - WHERE start_time >= ? AND model IS NOT NULL AND model <> ''`+uidClause+` - GROUP BY model ORDER BY span_count DESC LIMIT 5 - `, modelArgs...) + mrows, _ := h.db.Query(cte+` +SELECT model, CAST(SUM(spans) AS BIGINT) AS span_count +FROM usage +WHERE model IS NOT NULL +GROUP BY model ORDER BY span_count DESC, model ASC LIMIT 5 +`, f.args...) if mrows != nil { defer mrows.Close() for mrows.Next() { @@ -393,16 +490,12 @@ func (h *Handler) handleOverview(w http.ResponseWriter, r *http.Request) { } } - toolArgs := []any{since} - if uid != "" { - toolArgs = append(toolArgs, uid) - } - trows, _ := h.db.Query(` - SELECT tool_name, COUNT(*) AS call_count - FROM spans - WHERE start_time >= ? AND tool_name IS NOT NULL AND tool_name <> ''`+uidClause+` - GROUP BY tool_name ORDER BY call_count DESC LIMIT 5 - `, toolArgs...) + trows, _ := h.db.Query(cte+` +SELECT tool_name, CAST(SUM(spans) AS BIGINT) AS call_count +FROM usage +WHERE tool_name IS NOT NULL +GROUP BY tool_name ORDER BY call_count DESC, tool_name ASC LIMIT 5 +`, f.args...) if trows != nil { defer trows.Close() for trows.Next() { @@ -412,16 +505,6 @@ func (h *Handler) handleOverview(w http.ResponseWriter, r *http.Request) { } } - if resp.DailyCosts == nil { - resp.DailyCosts = []dailyCostRow{} - } - if resp.TopModels == nil { - resp.TopModels = []topModelRow{} - } - if resp.TopTools == nil { - resp.TopTools = []topToolRow{} - } - jsonOK(w, resp) } @@ -445,9 +528,16 @@ type sessionsResponse struct { Total int64 `json:"total"` Page int `json:"page"` Limit int `json:"limit"` + Range string `json:"range"` + // CoveredSince names the start of the window this list actually answers for, + // or null when the selected range is fully covered by raw spans. A session row + // needs a start time, model and status, none of which daily_usage keeps + // (ADR-0014), so the list is raw-only and a longer range is clamped. + CoveredSince *string `json:"covered_since"` } func (h *Handler) handleSessions(w http.ResponseWriter, r *http.Request) { + rangeKey, since := parseRangeDefault(r, "all") page := queryInt(r, "page", 1) limit := queryInt(r, "limit", 50) sort := r.URL.Query().Get("sort") @@ -470,13 +560,19 @@ func (h *Handler) handleSessions(w http.ResponseWriter, r *http.Request) { } uidClause, uid := userIDClause(r) - - var total int64 - totalArgs := []any{} + sinceClause := "" + scopeArgs := []any{} if uid != "" { - totalArgs = append(totalArgs, uid) + scopeArgs = append(scopeArgs, uid) + } + if since != nil { + sinceClause = " AND start_time >= ?" + scopeArgs = append(scopeArgs, *since) } - _ = h.db.QueryRow(`SELECT COUNT(DISTINCT session_id) FROM spans WHERE session_id IS NOT NULL`+uidClause, totalArgs...).Scan(&total) + uidClause += sinceClause + + var total int64 + _ = h.db.QueryRow(`SELECT COUNT(DISTINCT session_id) FROM spans WHERE session_id IS NOT NULL`+uidClause, scopeArgs...).Scan(&total) offset := (page - 1) * limit q := fmt.Sprintf(` @@ -498,11 +594,7 @@ func (h *Handler) handleSessions(w http.ResponseWriter, r *http.Request) { LIMIT ? OFFSET ? `, uidClause, sortExpr, order) - listArgs := []any{} - if uid != "" { - listArgs = append(listArgs, uid) - } - listArgs = append(listArgs, limit, offset) + listArgs := append(append([]any{}, scopeArgs...), limit, offset) rows, err := h.db.Query(q, listArgs...) if err != nil { jsonError(w, "query failed", http.StatusInternalServerError) @@ -529,10 +621,12 @@ func (h *Handler) handleSessions(w http.ResponseWriter, r *http.Request) { } jsonOK(w, sessionsResponse{ - Items: items, - Total: total, - Page: page, - Limit: limit, + Items: items, + Total: total, + Page: page, + Limit: limit, + Range: rangeKey, + CoveredSince: h.rawCoveredSince(r, since), }) } @@ -667,27 +761,38 @@ type costsResponse struct { Daily []costDayRow `json:"daily"` ByModel []costModelRow `json:"by_model"` TopSessions []topSessionRow `json:"top_sessions"` + // Range is the range key that scoped this response, or null when explicit + // from/to bounds superseded it (ADR-0014). + Range *string `json:"range"` } func (h *Handler) handleCosts(w http.ResponseWriter, r *http.Request) { - from, to := parseDateRange(r) - uidClause, uid := userIDClause(r) + rangeKey, since := parseRange(r) - baseArgs := []any{from, to} - if uid != "" { - baseArgs = append(baseArgs, uid) + from, to, explicit := explicitDateRange(r) + echo := &rangeKey + if explicit { + echo = nil + } else { + from, to = since, nil } - var resp costsResponse + f := newUsageFilter(r, from, to) + cte := f.cte() - drows, _ := h.db.Query(` - SELECT strftime(CAST(start_time AS TIMESTAMP), '%Y-%m-%d') AS day, - COALESCE(SUM(cost_usd), 0) - FROM spans - WHERE start_time >= ? AND start_time <= ? AND cost_usd IS NOT NULL`+uidClause+` - GROUP BY day ORDER BY day ASC - `, baseArgs...) - resp.Daily = []costDayRow{} + resp := costsResponse{ + Daily: []costDayRow{}, + ByModel: []costModelRow{}, + TopSessions: []topSessionRow{}, + Range: echo, + } + + drows, _ := h.db.Query(cte+` +SELECT strftime(day, '%Y-%m-%d') AS d, COALESCE(SUM(cost), 0) +FROM usage +WHERE cost IS NOT NULL +GROUP BY d ORDER BY d ASC +`, f.args...) if drows != nil { defer drows.Close() for drows.Next() { @@ -697,13 +802,12 @@ func (h *Handler) handleCosts(w http.ResponseWriter, r *http.Request) { } } - mrows, _ := h.db.Query(` - SELECT model, COALESCE(SUM(cost_usd), 0) - FROM spans - WHERE start_time >= ? AND start_time <= ? AND model IS NOT NULL AND model <> ''`+uidClause+` - GROUP BY model ORDER BY SUM(cost_usd) DESC - `, baseArgs...) - resp.ByModel = []costModelRow{} + mrows, _ := h.db.Query(cte+` +SELECT model, COALESCE(SUM(cost), 0) AS cost +FROM usage +WHERE model IS NOT NULL +GROUP BY model ORDER BY cost DESC, model ASC +`, f.args...) if mrows != nil { defer mrows.Close() for mrows.Next() { @@ -713,13 +817,12 @@ func (h *Handler) handleCosts(w http.ResponseWriter, r *http.Request) { } } - srows, _ := h.db.Query(` - SELECT session_id, COALESCE(SUM(cost_usd), 0), MIN(start_time) - FROM spans - WHERE start_time >= ? AND start_time <= ? AND session_id IS NOT NULL`+uidClause+` - GROUP BY session_id ORDER BY SUM(cost_usd) DESC LIMIT 10 - `, baseArgs...) - resp.TopSessions = []topSessionRow{} + srows, _ := h.db.Query(cte+` +SELECT session_id, COALESCE(SUM(cost), 0) AS cost, MIN(first_seen) +FROM usage +WHERE session_id IS NOT NULL +GROUP BY session_id ORDER BY cost DESC, session_id ASC LIMIT 10 +`, f.args...) if srows != nil { defer srows.Close() for srows.Next() { @@ -734,6 +837,18 @@ func (h *Handler) handleCosts(w http.ResponseWriter, r *http.Request) { jsonOK(w, resp) } +// explicitDateRange reports the from/to bounds when the caller supplied either +// one. They are the narrower, more specific statement, so they beat the range +// key; ok is false when neither is present and the caller falls back to range. +func explicitDateRange(r *http.Request) (from, to *time.Time, ok bool) { + q := r.URL.Query() + if q.Get("from") == "" && q.Get("to") == "" { + return nil, nil, false + } + f, t := parseDateRange(r) + return &f, &t, true +} + func parseDateRange(r *http.Request) (time.Time, time.Time) { to := time.Now() from := to.AddDate(0, 0, -30) @@ -1070,14 +1185,15 @@ ORDER BY %s %s NULLS LAST, command ASC%s Range: rangeKey, Sort: sort, Order: order, - CoveredSince: h.bashCoveredSince(r, since), + CoveredSince: h.rawCoveredSince(r, since), }) } -// bashCoveredSince returns the raw floor when the selected range reaches back +// rawCoveredSince returns the raw floor when the selected range reaches back // past it into days that only survive as aggregates, and nil when the range is -// fully covered by raw spans. -func (h *Handler) bashCoveredSince(r *http.Request, since *time.Time) *string { +// fully covered by raw spans. Shared by the endpoints that cannot answer from +// the roll-up — the Bash breakdown (ADR-0012) and the sessions list (ADR-0014). +func (h *Handler) rawCoveredSince(r *http.Request, since *time.Time) *string { aggUID, aggUIDArg := userIDClauseOn(r, "du.") args := []any{} if aggUIDArg != "" { @@ -1118,25 +1234,24 @@ type modelItem struct { type modelsResponse struct { Items []modelItem `json:"items"` + Range string `json:"range"` } func (h *Handler) handleModels(w http.ResponseWriter, r *http.Request) { - uidClause, uid := userIDClause(r) - args := []any{} - if uid != "" { - args = append(args, uid) - } - rows, _ := h.db.Query(` - SELECT - model, - COUNT(*) AS span_count, - COALESCE(SUM(cost_usd), 0), - COALESCE(SUM(input_tokens), 0), - COALESCE(SUM(output_tokens), 0) - FROM spans - WHERE model IS NOT NULL AND model <> ''`+uidClause+` - GROUP BY model ORDER BY span_count DESC - `, args...) + rangeKey, since := parseRangeDefault(r, "all") + f := newUsageFilter(r, since, nil) + + rows, _ := h.db.Query(f.cte()+` +SELECT + model, + CAST(SUM(spans) AS BIGINT) AS span_count, + COALESCE(SUM(cost), 0), + COALESCE(SUM(input_tokens), 0), + COALESCE(SUM(output_tokens), 0) +FROM usage +WHERE model IS NOT NULL +GROUP BY model ORDER BY span_count DESC, model ASC +`, f.args...) items := []modelItem{} if rows != nil { @@ -1148,7 +1263,7 @@ func (h *Handler) handleModels(w http.ResponseWriter, r *http.Request) { } } - jsonOK(w, modelsResponse{Items: items}) + jsonOK(w, modelsResponse{Items: items, Range: rangeKey}) } // ---- /api/v1/history ---- diff --git a/internal/api/overview_range_test.go b/internal/api/overview_range_test.go new file mode 100644 index 0000000..a447a1c --- /dev/null +++ b/internal/api/overview_range_test.go @@ -0,0 +1,445 @@ +package api_test + +import ( + "net/http" + "testing" + "time" + + "github.com/Flopsstuff/cotel/internal/api" + "github.com/Flopsstuff/cotel/internal/storage" +) + +// usageRow is one rolled-up daily_usage row seeded by the range tests. +type usageRow struct { + day time.Time + session string + model string + tool string + user string + spans int64 + cost float64 + in, out int64 +} + +func addUsageRow(t *testing.T, db *storage.DB, r usageRow) { + t.Helper() + var user any + if r.user != "" { + user = r.user + } + _, err := db.Exec(` + INSERT INTO daily_usage + (day, session_id, model, tool_name, user_id, span_count, + total_input_tokens, total_output_tokens, + total_cache_read_tokens, total_cache_write_tokens, total_cost_usd) + VALUES (CAST(? AS DATE), ?, ?, ?, ?, ?, ?, ?, 0, 0, ?) + `, r.day.UTC().Format("2006-01-02"), r.session, r.model, r.tool, user, + r.spans, r.in, r.out, r.cost) + if err != nil { + t.Fatalf("insert daily_usage: %v", err) + } +} + +// seedRangeFixture lays raw spans and rolled-up rows either side of the raw +// floor, so each range key reaches a different subset: +// +// day -400 agg carol 5 spans $8 in 80 — only "all" +// day -60 agg bob 3 spans $4 in 40 — "year" and "all" +// day -5 agg bob 7 spans $99 in 700 — the floor day: never counted +// day -5 raw alice 1 span $2 in 20 — the raw floor itself +// hour -1 raw alice 1 span $1 in 10 +// +// The floor-day aggregate is the double-count trap: the roll-up consumes whole +// UTC days, so that day is already fully represented by the raw span. +func seedRangeFixture(t *testing.T, db *storage.DB) { + t.Helper() + floor := floorNoon() + + insertSpan(t, db, storage.Span{ + TraceID: "tr", SpanID: "raw-floor", Name: "llm", + SessionID: "s-raw-1", Model: "sonnet", ToolName: "Bash", UserID: "alice", + StartTime: floor, EndTime: floor.Add(time.Second), + CostUSD: ptr(2.0), InputTokens: ptr(int64(20)), OutputTokens: ptr(int64(2)), + }) + recent := time.Now().Add(-time.Hour) + insertSpan(t, db, storage.Span{ + TraceID: "tr", SpanID: "raw-recent", Name: "llm", + SessionID: "s-raw-2", Model: "sonnet", ToolName: "Read", UserID: "alice", + StartTime: recent, EndTime: recent.Add(time.Second), + CostUSD: ptr(1.0), InputTokens: ptr(int64(10)), OutputTokens: ptr(int64(1)), + }) + + addUsageRow(t, db, usageRow{ + day: floor, session: "s-floor", model: "opus", tool: "Grep", user: "bob", + spans: 7, cost: 99, in: 700, out: 70, + }) + addUsageRow(t, db, usageRow{ + day: floor.AddDate(0, 0, -55), session: "s-year", model: "opus", tool: "Grep", user: "bob", + spans: 3, cost: 4, in: 40, out: 4, + }) + addUsageRow(t, db, usageRow{ + day: floor.AddDate(0, 0, -395), session: "s-old", model: "haiku", tool: "Glob", user: "carol", + spans: 5, cost: 8, in: 80, out: 8, + }) +} + +func num(t *testing.T, body map[string]any, key string) float64 { + t.Helper() + v, ok := body[key].(float64) + if !ok { + t.Fatalf("%s: want number, got %v (%T)", key, body[key], body[key]) + } + return v +} + +// pairs flattens a list-of-objects response field into label→number. +func pairs(t *testing.T, body map[string]any, field, labelKey, valueKey string) map[string]float64 { + t.Helper() + items, _ := body[field].([]any) + out := map[string]float64{} + for _, it := range items { + m := it.(map[string]any) + out[m[labelKey].(string)] = m[valueKey].(float64) + } + return out +} + +// TestOverview_RangeAcrossUnion walks every range key over the fixture: the +// short windows resolve from raw spans alone, the long ones pick up the +// aggregate rows, and the floor day is counted exactly once. +func TestOverview_RangeAcrossUnion(t *testing.T) { + db, ro := openTestDB(t) + seedRangeFixture(t, db) + h := api.New(ro) + + cases := []struct { + rangeKey string + sessions float64 + users float64 + cost float64 + input float64 + output float64 + dailyBuckets int + }{ + {"day", 1, 1, 1, 10, 1, 1}, + {"week", 2, 1, 3, 30, 3, 2}, + {"month", 2, 1, 3, 30, 3, 2}, + {"year", 3, 2, 7, 70, 7, 3}, + {"all", 4, 3, 15, 150, 15, 4}, + } + for _, tc := range cases { + t.Run(tc.rangeKey, func(t *testing.T) { + code, body := getJSON(t, h, "/api/v1/overview?range="+tc.rangeKey) + if code != http.StatusOK { + t.Fatalf("want 200, got %d: %v", code, body) + } + if body["range"] != tc.rangeKey { + t.Errorf("range echo: want %q, got %v", tc.rangeKey, body["range"]) + } + if got := num(t, body, "sessions_count"); got != tc.sessions { + t.Errorf("sessions_count: want %v, got %v", tc.sessions, got) + } + if got := num(t, body, "users_count"); got != tc.users { + t.Errorf("users_count: want %v, got %v", tc.users, got) + } + if got := num(t, body, "total_cost_usd"); got != tc.cost { + t.Errorf("total_cost_usd: want %v, got %v", tc.cost, got) + } + if got := num(t, body, "total_input_tokens"); got != tc.input { + t.Errorf("total_input_tokens: want %v, got %v", tc.input, got) + } + if got := num(t, body, "total_output_tokens"); got != tc.output { + t.Errorf("total_output_tokens: want %v, got %v", tc.output, got) + } + daily, _ := body["daily_costs"].([]any) + if len(daily) != tc.dailyBuckets { + t.Errorf("daily_costs: want %d buckets, got %d (%v)", tc.dailyBuckets, len(daily), daily) + } + }) + } +} + +// TestOverview_FloorDayNotDoubleCounted pins the boundary directly: the raw +// floor's calendar day also has a $99 aggregate row, which belongs to the same +// day the raw span already covers and must not be added to it. +func TestOverview_FloorDayNotDoubleCounted(t *testing.T) { + db, ro := openTestDB(t) + seedRangeFixture(t, db) + h := api.New(ro) + + _, body := getJSON(t, h, "/api/v1/overview?range=all") + byDay := pairs(t, body, "daily_costs", "date", "cost_usd") + floorDay := floorNoon().UTC().Format("2006-01-02") + if got := byDay[floorDay]; got != 2 { + t.Errorf("floor day %s: want cost 2 (raw only), got %v — full map %v", floorDay, got, byDay) + } + if got := num(t, body, "total_cost_usd"); got != 15 { + t.Errorf("total_cost_usd: want 15, got %v (floor-day aggregate leaked in?)", got) + } +} + +// TestOverview_RangeComposesWithUserID checks the two filters intersect rather +// than one overriding the other, including for users_count, which used to +// ignore both. +func TestOverview_RangeComposesWithUserID(t *testing.T) { + db, ro := openTestDB(t) + seedRangeFixture(t, db) + h := api.New(ro) + + cases := []struct { + path string + sessions float64 + users float64 + cost float64 + }{ + {"/api/v1/overview?range=all&user_id=alice", 2, 1, 3}, + {"/api/v1/overview?range=all&user_id=bob", 1, 1, 4}, + {"/api/v1/overview?range=all&user_id=carol", 1, 1, 8}, + {"/api/v1/overview?range=month&user_id=bob", 0, 0, 0}, + {"/api/v1/overview?range=week&user_id=alice", 2, 1, 3}, + } + for _, tc := range cases { + t.Run(tc.path, func(t *testing.T) { + _, body := getJSON(t, h, tc.path) + if got := num(t, body, "sessions_count"); got != tc.sessions { + t.Errorf("sessions_count: want %v, got %v", tc.sessions, got) + } + if got := num(t, body, "users_count"); got != tc.users { + t.Errorf("users_count: want %v, got %v", tc.users, got) + } + if got := num(t, body, "total_cost_usd"); got != tc.cost { + t.Errorf("total_cost_usd: want %v, got %v", tc.cost, got) + } + }) + } +} + +// TestOverview_TopListsSpanTheUnion confirms top_models and top_tools count +// rolled-up spans too, and that the roll-up's 'unknown' sentinel never shows up +// as a model or tool of its own. +func TestOverview_TopListsSpanTheUnion(t *testing.T) { + db, ro := openTestDB(t) + seedRangeFixture(t, db) + addUsageRow(t, db, usageRow{ + day: floorNoon().AddDate(0, 0, -55), session: storage.UnknownSentinel, + model: storage.UnknownSentinel, tool: storage.UnknownSentinel, + spans: 11, cost: 1, + }) + h := api.New(ro) + + _, body := getJSON(t, h, "/api/v1/overview?range=all") + + models := pairs(t, body, "top_models", "model", "span_count") + wantModels := map[string]float64{"haiku": 5, "opus": 3, "sonnet": 2} + for m, want := range wantModels { + if models[m] != want { + t.Errorf("top_models[%s]: want %v, got %v (full %v)", m, want, models[m], models) + } + } + if _, ok := models[storage.UnknownSentinel]; ok { + t.Errorf("top_models leaked the roll-up sentinel: %v", models) + } + + tools := pairs(t, body, "top_tools", "tool_name", "call_count") + wantTools := map[string]float64{"Glob": 5, "Grep": 3, "Bash": 1, "Read": 1} + for tn, want := range wantTools { + if tools[tn] != want { + t.Errorf("top_tools[%s]: want %v, got %v (full %v)", tn, want, tools[tn], tools) + } + } + if _, ok := tools[storage.UnknownSentinel]; ok { + t.Errorf("top_tools leaked the roll-up sentinel: %v", tools) + } +} + +// TestOverview_UnknownRangeFallsBack keeps the never-400 rule: a nonsense key +// resolves to the endpoint default. +func TestOverview_UnknownRangeFallsBack(t *testing.T) { + db, ro := openTestDB(t) + seedRangeFixture(t, db) + h := api.New(ro) + + for _, path := range []string{"/api/v1/overview?range=fortnight", "/api/v1/overview?range="} { + code, body := getJSON(t, h, path) + if code != http.StatusOK { + t.Fatalf("%s: want 200, got %d", path, code) + } + if body["range"] != "month" { + t.Errorf("%s: want range=month, got %v", path, body["range"]) + } + if got := num(t, body, "total_cost_usd"); got != 3 { + t.Errorf("%s: want the month total 3, got %v", path, got) + } + } +} + +// TestModels_RangeDefaultsToAll pins criterion (e) for /models: no range +// parameter must return exactly what it returned before ADR-0014. +func TestModels_RangeDefaultsToAll(t *testing.T) { + db, ro := openTestDB(t) + seedRangeFixture(t, db) + h := api.New(ro) + + code, body := getJSON(t, h, "/api/v1/models") + if code != http.StatusOK { + t.Fatalf("want 200, got %d", code) + } + if body["range"] != "all" { + t.Errorf("range echo: want all, got %v", body["range"]) + } + spans := pairs(t, body, "items", "model", "span_count") + want := map[string]float64{"haiku": 5, "opus": 3, "sonnet": 2} + if len(spans) != len(want) { + t.Fatalf("items: want %d models, got %v", len(want), spans) + } + for m, n := range want { + if spans[m] != n { + t.Errorf("span_count[%s]: want %v, got %v", m, n, spans[m]) + } + } + + costs := pairs(t, body, "items", "model", "total_cost_usd") + if costs["opus"] != 4 { + t.Errorf("opus cost: want 4 (aggregate only), got %v", costs["opus"]) + } + if costs["sonnet"] != 3 { + t.Errorf("sonnet cost: want 3 (raw only), got %v", costs["sonnet"]) + } +} + +func TestModels_RangeScopesToRawWindow(t *testing.T) { + db, ro := openTestDB(t) + seedRangeFixture(t, db) + h := api.New(ro) + + _, body := getJSON(t, h, "/api/v1/models?range=month") + if body["range"] != "month" { + t.Errorf("range echo: want month, got %v", body["range"]) + } + spans := pairs(t, body, "items", "model", "span_count") + if len(spans) != 1 || spans["sonnet"] != 2 { + t.Errorf("month window: want only sonnet=2, got %v", spans) + } + + _, yearBody := getJSON(t, h, "/api/v1/models?range=year") + yearSpans := pairs(t, yearBody, "items", "model", "span_count") + if yearSpans["opus"] != 3 { + t.Errorf("year window: want opus=3 from the aggregate, got %v", yearSpans) + } + if _, ok := yearSpans["haiku"]; ok { + t.Errorf("year window: 400-day-old aggregate leaked in: %v", yearSpans) + } +} + +// TestSessions_RangeDefaultsToAllAndClamps covers criterion (e) for /sessions +// and the covered_since clamp the list reports instead of absorbing. +func TestSessions_RangeDefaultsToAllAndClamps(t *testing.T) { + db, ro := openTestDB(t) + seedRangeFixture(t, db) + h := api.New(ro) + + code, body := getJSON(t, h, "/api/v1/sessions") + if code != http.StatusOK { + t.Fatalf("want 200, got %d", code) + } + if body["range"] != "all" { + t.Errorf("range echo: want all, got %v", body["range"]) + } + if got := num(t, body, "total"); got != 2 { + t.Errorf("total: want the 2 raw sessions, got %v", got) + } + // "all" reaches past the raw floor into aggregate-only days, which cannot + // produce session rows — so the list must say where its coverage starts. + covered, ok := body["covered_since"].(string) + if !ok { + t.Fatalf("covered_since: want the raw floor, got %v", body["covered_since"]) + } + ts, err := time.Parse(time.RFC3339, covered) + if err != nil { + t.Fatalf("covered_since %q is not RFC3339: %v", covered, err) + } + if d := ts.Sub(floorNoon()); d < -time.Second || d > time.Second { + t.Errorf("covered_since: want the raw floor %s, got %s", floorNoon(), ts) + } +} + +func TestSessions_RangeScopesAndReportsFullCoverage(t *testing.T) { + db, ro := openTestDB(t) + seedRangeFixture(t, db) + h := api.New(ro) + + cases := []struct { + rangeKey string + total float64 + wantClamp bool + }{ + {"day", 1, false}, + {"week", 2, false}, + {"month", 2, false}, + {"year", 2, true}, + {"all", 2, true}, + } + for _, tc := range cases { + t.Run(tc.rangeKey, func(t *testing.T) { + _, body := getJSON(t, h, "/api/v1/sessions?range="+tc.rangeKey) + if got := num(t, body, "total"); got != tc.total { + t.Errorf("total: want %v, got %v", tc.total, got) + } + items, _ := body["items"].([]any) + if float64(len(items)) != tc.total { + t.Errorf("items: want %v rows, got %d", tc.total, len(items)) + } + if got := body["covered_since"] != nil; got != tc.wantClamp { + t.Errorf("covered_since set = %v, want %v (got %v)", got, tc.wantClamp, body["covered_since"]) + } + }) + } +} + +func TestCosts_RangeAndExplicitBounds(t *testing.T) { + db, ro := openTestDB(t) + seedRangeFixture(t, db) + h := api.New(ro) + + t.Run("defaults to month", func(t *testing.T) { + _, body := getJSON(t, h, "/api/v1/costs") + if body["range"] != "month" { + t.Errorf("range echo: want month, got %v", body["range"]) + } + daily := pairs(t, body, "daily", "date", "cost_usd") + if len(daily) != 2 { + t.Errorf("daily: want the 2 raw days, got %v", daily) + } + }) + + t.Run("all reaches the aggregates", func(t *testing.T) { + _, body := getJSON(t, h, "/api/v1/costs?range=all") + daily := pairs(t, body, "daily", "date", "cost_usd") + if len(daily) != 4 { + t.Errorf("daily: want 4 days, got %v", daily) + } + byModel := pairs(t, body, "by_model", "model", "cost_usd") + if byModel["haiku"] != 8 || byModel["opus"] != 4 || byModel["sonnet"] != 3 { + t.Errorf("by_model: want haiku=8 opus=4 sonnet=3, got %v", byModel) + } + sessions := pairs(t, body, "top_sessions", "session_id", "cost_usd") + if sessions["s-old"] != 8 || sessions["s-year"] != 4 { + t.Errorf("top_sessions: want the rolled-up sessions ranked by cost, got %v", sessions) + } + if _, ok := sessions["s-floor"]; ok { + t.Errorf("top_sessions: floor-day aggregate leaked in: %v", sessions) + } + }) + + t.Run("explicit bounds beat the range key", func(t *testing.T) { + from := floorNoon().UTC().Format("2006-01-02") + _, body := getJSON(t, h, "/api/v1/costs?range=all&from="+from) + if body["range"] != nil { + t.Errorf("range echo: want null when from/to win, got %v", body["range"]) + } + daily := pairs(t, body, "daily", "date", "cost_usd") + if len(daily) != 2 { + t.Errorf("daily: want only the 2 days inside from/to, got %v", daily) + } + }) +} From c47e52a345e57e59770d6c0e42c4836c96f7cb33 Mon Sep 17 00:00:00 2001 From: Wayland Date: Thu, 20 Aug 2026 22:43:31 +0200 Subject: [PATCH 3/5] feat(overview): one range selector every panel obeys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Overview was five windows stacked vertically: the KPIs showed 30 days, the Sessions and Models blocks showed all time, History and Costs 30 days each by their own arithmetic, 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. The header now carries one SegmentedControl, persisted under its own cotel_overview_range cookie so it cannot move the Users or Tools page, and every section takes the selection: Users, History (hour granularity on Day, day otherwise), Costs, Tools, Models, Sessions. KPI labels take their suffix from RANGE_SUFFIX; All renders none. A new Users block leads the stack with the top 5 principals by spend, answered by the existing /users list rather than a new endpoint. Sessions moves to the bottom, as the one block that cannot honour a long range, and states the window it actually covers when the server clamps it. The Costs block drops its inner by-model table — the Models block below it is the same data at full width. The user-search typeahead is gone and UserSearch with it; nothing else imported it. ?user_id= still scopes the page, and now says so: a chip in the header names the user and clears the scope on click, instead of a silently filtered page. Co-Authored-By: Wayland Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 9 + README.md | 2 +- docs/design/pages.md | 23 ++ docs/operations/api-reference.md | 49 +++ frontend/src/api/index.ts | 61 +++- frontend/src/components/UserSearch.module.css | 61 ---- frontend/src/components/UserSearch.tsx | 61 ---- frontend/src/components/index.tsx | 1 - frontend/src/pages/Overview.module.css | 27 ++ frontend/src/pages/Overview.tsx | 294 +++++++++++------- internal/dashboard/static/index.html | 4 +- 11 files changed, 352 insertions(+), 240 deletions(-) delete mode 100644 frontend/src/components/UserSearch.module.css delete mode 100644 frontend/src/components/UserSearch.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index c73910a..84085e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `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 + ### Fixed +- `GET /api/v1/overview`'s `users_count` obeyed neither the time window nor the `user_id` filter — it was a bare `SELECT COUNT(DISTINCT user_id) FROM spans`, so the "Users" KPI answered all-time and unscoped next to four KPIs that did not. It now counts the distinct principals active in the selected range, and counts unattributed spans as the single `__anonymous__` principal the users list shows rather than as zero +- The Overview's `total_cost_usd` and token KPIs no longer drop spans that carry no `session_id`. They were computed under the same `session_id IS NOT NULL` clause as the session count, so the page's cost total could sit below the Costs page's for the same window - A bare `WHERE col = ` on `spans` returns the matching rows again instead of silently returning none. `duration_ms` was a VIRTUAL generated column declared mid-table: it took a logical slot but no storage slot, so every column after it had a logical index one ahead of its physical index, and an equality on such a column probed an unrelated ART index and found nothing — a wrong result, not an error. Schema version 10 drops the column and computes the duration at the four queries that read it, so logical and physical indexes line up and the trap is gone rather than worked around; the engine-fragile `COALESCE(tool_name, '') = 'Bash'` workaround (DuckDB 1.4+ pushes `COALESCE` down too) goes with it. Verified against a copy of production (108 MB, 34 706 spans): `tool_name = 'Bash'` counted 0 rows before the migration and 6 440 after, `service_name = 'claude-code'` 0 before and 34 705 after, with the span count unchanged ([ADR-0013](docs/decisions/0013-spans-has-no-derived-columns.md)) - A failing `GET /api/v1/bash-commands` no longer renders as the "no command detail in this data" explainer. The Bash section branched on row count alone, so a request that errored looked identical to one that legitimately returned nothing, blaming Claude Code's telemetry for what was actually a server fault. Fetch failures now show the error ### Changed +- 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 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 - Schema version 10 removes `spans.duration_ms`. The migration moves no row data: it drops the four secondary indexes, drops the column (DuckDB refuses to `ALTER` a table an index depends on), and the existing `CREATE INDEX IF NOT EXISTS` block rebuilds them. On the 108 MB production copy the whole upgrade added 0.4 s to a cold start already dominated by WAL replay (9.5 s → 9.8 s), and a later re-apply of `schema.sql` costs 138 ms. No downgrade path: an older binary still starts against a v10 database (`CREATE TABLE IF NOT EXISTS` cannot bring the column back) but every query naming `duration_ms` then errors, so a rollback needs the pre-upgrade database too. Exported CSVs are unaffected — the `duration_ms` column of `spans.csv` was already derived in Go, so the format version does not move diff --git a/README.md b/README.md index dd462c9..9070c70 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** — KPI cards for sessions, unique users, total cost, and token counts (30-day window), with per-user filter across all charts +- **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 - **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 - **Costs** — cumulative spend chart + breakdown table by model diff --git a/docs/design/pages.md b/docs/design/pages.md index f5bd755..24fd10f 100644 --- a/docs/design/pages.md +++ b/docs/design/pages.md @@ -116,6 +116,29 @@ These are rendered by a shared `` component that wraps every page's - Recent sessions table: non-sortable (fixed: most recent first), non-paginated, no filter bar. - "View all sessions" link: right-aligned, `--text-sm`, `--color-accent`. +### 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 + 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. +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 + honour a long range (see `covered_since` in the API reference). + +The header carries one `` bound to `RANGE_OPTIONS` and +persisted in the `cotel_overview_range` cookie — its own key, so changing the +range here does not move the Users or Tools page. KPI labels take their suffix +from `RANGE_SUFFIX` (`All` renders none) +([ADR-0014](../decisions/0014-overview-single-range-selector.md)). + --- ## 2. Sessions (`/sessions`) diff --git a/docs/operations/api-reference.md b/docs/operations/api-reference.md index 4a23501..b07bec9 100644 --- a/docs/operations/api-reference.md +++ b/docs/operations/api-reference.md @@ -34,6 +34,54 @@ Three rules hold everywhere: Every response echoes `total`, `page`, `limit`, `range`, `sort` and `order` back, so a client can render its controls from the response alone. +## `range` on the summary endpoints + +`GET /overview`, `GET /sessions`, `GET /costs` and `GET /models` accept the same +`range` parameter, with the same five keys, the same rolling-window semantics and +the same fall-back-don't-`400` rule +([ADR-0014](../decisions/0014-overview-single-range-selector.md)). Each echoes +back the `range` it used. All four also accept `user_id`, which composes with +`range` rather than overriding it. + +**Defaults preserve each endpoint's previous behaviour rather than converging on +one value:** + +| Endpoint | `range` default | Why | +|---|---|---| +| `GET /overview` | `month` | The 30-day window it always applied | +| `GET /costs` | `month` | The 30-day window `from`/`to` already defaulted to | +| `GET /sessions` | `all` | Had no time filter; a `month` default would truncate existing callers | +| `GET /models` | `all` | Same | + +Every additive figure — cost, token totals, span and session counts, distinct +users, per-model and per-tool totals — is answered from the union of raw `spans` +and the `daily_usage` roll-up, split at the earliest surviving raw day. `year` +and `all` therefore keep answering after retention has deleted the raw spans, +instead of silently repeating the `month` figure. + +Two consequences are worth knowing: + +- **`GET /costs`: explicit bounds beat the range key.** When a request carries + `from` and/or `to`, those win and `range` is ignored — the narrower, more + specific statement is the one the caller meant. The response then echoes + `"range": null`. `top_sessions[].first_seen` degrades to the aggregate's day at + midnight UTC for rolled-up sessions, since `daily_usage` keeps no intra-day + timestamp; it is a lower bound on the real start, never a later one. +- **`GET /sessions` clamps and says so.** A session row needs a start time, model + and status, none of which the roll-up carries, so the list is computed from raw + spans alone. A range reaching past the raw floor is clamped, and the response + reports `covered_since`: the RFC3339 instant the list actually starts from, or + `null` when the selected range is fully covered. The *session count* on + `/overview` is unaffected — `daily_usage` carries `session_id`, so counting + distinct sessions across the union is exact. + +`GET /overview`'s `users_count` counts the distinct principals active in the +range, with all unattributed spans counting as the single `__anonymous__` +principal the users list shows. + +`GET /history` is not part of this contract; it takes `from`/`to` and reads raw +spans only. + ## `GET /tools` One row per tool, with call volume, average duration and error rate. @@ -143,4 +191,5 @@ returns one user in the same shape and accepts `range`. See - [ADR-0011 — Users list: ranged stats and server-side sort](../decisions/0011-users-list-ranged-stats-and-server-side-sort.md) - [ADR-0012 — Tools list: ranged stats and server-side sort](../decisions/0012-tools-list-ranged-stats-and-server-side-sort.md) +- [ADR-0014 — Overview: one range selector every panel obeys](../decisions/0014-overview-single-range-selector.md) - [ADR-0009 — daily_usage unknown sentinel](../decisions/0009-daily-usage-unknown-sentinel.md) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 6a55a4d..d298522 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -7,6 +7,7 @@ export const fetcher = (url: string) => }) export interface OverviewResponse { + range: string sessions_count: number users_count: number total_cost_usd: number @@ -36,6 +37,11 @@ export interface SessionsResponse { total: number page: number limit: number + range: string + // Set when the range reaches further back than raw spans go: a session row + // needs a start time, model and status, none of which the roll-up keeps, so + // the list answers for a shorter window than asked. + covered_since: string | null } export interface SpanDetail { @@ -67,6 +73,9 @@ export interface CostsResponse { daily: { date: string; cost_usd: number }[] by_model: { model: string; cost_usd: number }[] top_sessions: { session_id: string; cost_usd: number; first_seen: string }[] + // The range key that scoped the response, or null when explicit from/to + // bounds superseded it. + range: string | null } export interface ToolItem { @@ -268,28 +277,50 @@ export async function updateSettings(settings: Partial): Promi return res.json() } -export function useOverview(refreshInterval = 30_000, userId?: string) { - const qs = userId ? `?user_id=${encodeURIComponent(userId)}` : '' - return useSWR(`/api/v1/overview${qs}`, fetcher, { refreshInterval }) +// The four hooks below take range last and omit the parameter when it is +// undefined, so a caller that does not pass one keeps the server-side default +// for that endpoint (ADR-0014). +export function useOverview(refreshInterval = 30_000, userId?: string, range?: string) { + const params = new URLSearchParams() + if (userId) params.set('user_id', userId) + if (range) params.set('range', range) + const qs = params.toString() + return useSWR(`/api/v1/overview${qs ? `?${qs}` : ''}`, fetcher, { + refreshInterval, + keepPreviousData: true, + }) } -export function useSessions(page = 1, limit = 50, sort = 'start_time', order = 'desc', userId?: string) { +export function useSessions( + page = 1, + limit = 50, + sort = 'start_time', + order = 'desc', + userId?: string, + range?: string, +) { const params = new URLSearchParams({ page: String(page), limit: String(limit), sort, order }) if (userId) params.set('user_id', userId) - return useSWR(`/api/v1/sessions?${params.toString()}`, fetcher) + if (range) params.set('range', range) + return useSWR(`/api/v1/sessions?${params.toString()}`, fetcher, { + keepPreviousData: true, + }) } export function useSession(id: string) { return useSWR(id ? `/api/v1/sessions/${id}` : null, fetcher) } -export function useCosts(from?: string, to?: string, userId?: string) { +export function useCosts(from?: string, to?: string, userId?: string, range?: string) { const params = new URLSearchParams() if (from) params.set('from', from) if (to) params.set('to', to) if (userId) params.set('user_id', userId) + if (range) params.set('range', range) const qs = params.toString() - return useSWR(`/api/v1/costs${qs ? `?${qs}` : ''}`, fetcher) + return useSWR(`/api/v1/costs${qs ? `?${qs}` : ''}`, fetcher, { + keepPreviousData: true, + }) } export function useTools(params: ToolsParams) { @@ -302,9 +333,19 @@ export function useBashCommands(params: ListParams) { }) } -export function useModels(userId?: string) { - const qs = userId ? `?user_id=${encodeURIComponent(userId)}` : '' - return useSWR<{ items: ModelItem[] }>(`/api/v1/models${qs}`, fetcher) +export interface ModelsResponse { + items: ModelItem[] + range: string +} + +export function useModels(userId?: string, range?: string) { + const params = new URLSearchParams() + if (userId) params.set('user_id', userId) + if (range) params.set('range', range) + const qs = params.toString() + return useSWR(`/api/v1/models${qs ? `?${qs}` : ''}`, fetcher, { + keepPreviousData: true, + }) } export interface HistoryBucket { diff --git a/frontend/src/components/UserSearch.module.css b/frontend/src/components/UserSearch.module.css deleted file mode 100644 index 600194d..0000000 --- a/frontend/src/components/UserSearch.module.css +++ /dev/null @@ -1,61 +0,0 @@ -.wrap { - margin-bottom: var(--space-5); -} - -.searchInput { - width: 100%; - padding: var(--space-2) var(--space-3); - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-surface); - font-size: var(--text-sm); - font-family: var(--font-sans); - color: var(--color-text-1); - margin-bottom: var(--space-3); - box-sizing: border-box; - transition: border-color var(--duration-fast) var(--easing-default); -} - -.searchInput:focus { - border-color: var(--color-accent); - outline: none; -} - -.chips { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); -} - -.chip { - display: inline-flex; - align-items: center; - gap: 4px; - padding: var(--space-1) var(--space-3); - border: 1px solid var(--color-border); - border-radius: var(--radius-full); - background: var(--color-surface); - color: var(--color-text-2); - font-size: var(--text-sm); - font-weight: 500; - cursor: pointer; - white-space: nowrap; - transition: all var(--duration-fast) var(--easing-default); -} - -.chip:hover { - border-color: var(--color-accent); - color: var(--color-accent); -} - -.chipActive { - border-color: var(--color-accent); - background: var(--color-accent-bg); - color: var(--color-accent); -} - -.clear { - font-size: var(--text-base); - line-height: 1; - opacity: 0.7; -} diff --git a/frontend/src/components/UserSearch.tsx b/frontend/src/components/UserSearch.tsx deleted file mode 100644 index 15616f4..0000000 --- a/frontend/src/components/UserSearch.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useState } from 'react' -import { useSearchParams } from 'react-router-dom' -import { useUsers } from '../api' -import styles from './UserSearch.module.css' - -export function UserSearch() { - const [searchParams, setSearchParams] = useSearchParams() - const { data } = useUsers() - const userId = searchParams.get('user_id') ?? '' - const [query, setQuery] = useState('') - - const users = data?.users ?? [] - const filtered = query - ? users.filter((u) => u.name.toLowerCase().includes(query.toLowerCase())) - : users - - function selectUser(name: string) { - setSearchParams((prev) => { - const next = new URLSearchParams(prev) - if (name) { - next.set('user_id', name) - } else { - next.delete('user_id') - } - return next - }) - } - - return ( -
- setQuery(e.target.value)} - aria-label="Search users" - /> -
- - {filtered.map((u) => { - const active = userId === u.name - return ( - - ) - })} -
-
- ) -} diff --git a/frontend/src/components/index.tsx b/frontend/src/components/index.tsx index 23d3073..a331077 100644 --- a/frontend/src/components/index.tsx +++ b/frontend/src/components/index.tsx @@ -24,5 +24,4 @@ export { ChartTooltip } from './ChartTooltip' // Alias for pages still importing LoadingState export { LoadingSkeleton as LoadingState } from './LoadingSkeleton' -export { UserSearch } from './UserSearch' export { StatSection } from './StatSection' diff --git a/frontend/src/pages/Overview.module.css b/frontend/src/pages/Overview.module.css index cb3ae82..2b0f8e7 100644 --- a/frontend/src/pages/Overview.module.css +++ b/frontend/src/pages/Overview.module.css @@ -5,12 +5,39 @@ margin-bottom: var(--space-6); } +.headerLeft { + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; +} + .headerRight { display: flex; align-items: center; gap: var(--space-4); } +.userChip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: var(--space-1) var(--space-3); + border: 1px solid var(--color-accent); + border-radius: var(--radius-full); + background: var(--color-accent-bg); + color: var(--color-accent); + font-size: var(--text-sm); + cursor: pointer; + white-space: nowrap; +} + +.coverageNote { + font-size: var(--text-sm); + color: var(--color-text-3); + margin-top: var(--space-3); +} + .title { font-size: var(--text-xl); font-weight: 700; diff --git a/frontend/src/pages/Overview.tsx b/frontend/src/pages/Overview.tsx index dda9bb1..714e34b 100644 --- a/frontend/src/pages/Overview.tsx +++ b/frontend/src/pages/Overview.tsx @@ -1,21 +1,42 @@ import { useMemo, useState } from 'react' import { Link, useNavigate, useSearchParams } from 'react-router-dom' import { - BarChart, Bar, LineChart, Line, AreaChart, Area, + LineChart, Line, AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, } from 'recharts' import { - useOverview, useSessions, useCosts, useHistory, useTools, useModels, + useOverview, useSessions, useCosts, useHistory, useTools, useModels, useUsersPage, } from '../api' -import type { SessionItem, ToolItem, ModelItem } from '../api' +import type { SessionItem, ToolItem, ModelItem, User } from '../api' import { - Card, KpiCard, DataTable, EmptyState, ErrorState, RefreshIndicator, + KpiCard, DataTable, EmptyState, ErrorState, RefreshIndicator, SegmentedControl, KpiSkeleton, ChartSkeleton, LoadingSkeleton, sessionStatusBadge, failRateBadge, ChartTooltip, } from '../components' -import { UserSearch } from '../components/UserSearch' import { StatSection } from '../components/StatSection' +import { RANGE_OPTIONS, RANGE_SUFFIX, useRangeCookie } from '../lib/range' +import type { RangeKey } from '../lib/range' import styles from './Overview.module.css' +const RANGE_COOKIE = 'cotel_overview_range' +const ANON_ID = '__anonymous__' + +// /history is bounded by from/to rather than the range key, so "All" has to name +// a start date. Nothing cotel can store predates this by decades. +const HISTORY_EPOCH = '2000-01-01' + +const RANGE_DAYS: Record = { + all: null, + year: 365, + month: 30, + week: 7, + day: 1, +} + +interface SectionProps { + range: RangeKey + userId?: string +} + function fmtTokens(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K` @@ -26,64 +47,61 @@ function isoDate(d: Date): string { return d.toISOString().slice(0, 10) } +function formatDay(iso: string): string { + return new Date(iso).toLocaleDateString() +} + // ---- Compact section components ---- -function SessionsSection({ userId }: { userId?: string }) { +function UsersSection({ range }: { range: RangeKey }) { const navigate = useNavigate() - const { data, isLoading, error } = useSessions(1, 5, 'start_time', 'desc', userId) + const { data, isLoading, error } = useUsersPage({ + range, + sort: 'cost', + order: 'desc', + page: 1, + limit: 5, + }) - if (isLoading) return + if (isLoading && !data) return if (error) return - if (!data || data.items.length === 0) - return + if (!data || data.users.length === 0) + return return ( - + columns={[ { - key: 'session_id', - label: 'Session', - render: (v) => ( - - {String(v).slice(0, 16)}… - + key: 'name', + label: 'Name', + render: (v, row) => ( + {String(v)} ), }, + { key: 'cost', label: 'Cost', render: (v) => `$${Number(v).toFixed(2)}` }, + { key: 'sessions', label: 'Sessions', render: (v) => Number(v).toLocaleString() }, { - key: 'user_id', - label: 'User', - render: (v) => - v ? String(v) : anonymous, - }, - { - key: 'first_seen', - label: 'Started', - render: (v) => new Date(String(v)).toLocaleString(), - }, - { key: 'model', label: 'Model' }, - { - key: 'cost_usd', - label: 'Cost', - render: (v) => `$${Number(v).toFixed(2)}`, - }, - { - key: 'status', - label: 'Status', - render: (v) => sessionStatusBadge(String(v)), + key: 'last_seen', + label: 'Last seen', + render: (v) => (v ? new Date(String(v)).toLocaleString() : '—'), }, ]} - rows={data.items} - onRowClick={(row) => navigate(`/sessions/${row.session_id}`)} + rows={data.users} + onRowClick={(row) => navigate(`/users/${encodeURIComponent(row.id)}`)} /> ) } -function HistorySection({ userId }: { userId?: string }) { - const from = useMemo(() => isoDate(new Date(Date.now() - 30 * 86400_000)), []) +function HistorySection({ range, userId }: SectionProps) { + const days = RANGE_DAYS[range] + const from = useMemo( + () => (days === null ? HISTORY_EPOCH : isoDate(new Date(Date.now() - days * 86400_000))), + [days], + ) const to = useMemo(() => isoDate(new Date()), []) - const { data, isLoading, error } = useHistory('day', from, to, userId) + const { data, isLoading, error } = useHistory(range === 'day' ? 'hour' : 'day', from, to, userId) - if (isLoading) return + if (isLoading && !data) return if (error) return if (!data || data.buckets.length === 0) return @@ -118,56 +136,38 @@ function HistorySection({ userId }: { userId?: string }) { ) } -function CostsSection({ userId }: { userId?: string }) { - const { data, isLoading, error } = useCosts(undefined, undefined, userId) +function CostsSection({ range, userId }: SectionProps) { + const { data, isLoading, error } = useCosts(undefined, undefined, userId, range) - if (isLoading) return + if (isLoading && !data) return if (error) return if (!data || data.daily.length === 0) return - const topModels = data.by_model.slice(0, 3) - return ( -
- - - String(d).slice(5)} - interval="preserveStartEnd" - /> - `$${Number(v).toFixed(2)}`} - width={52} - /> - [`$${Number(v).toFixed(2)}`, 'Cost']} />} /> - - - - {topModels.length > 0 && ( - - columns={[ - { key: 'model', label: 'Model', sortable: true }, - { - key: 'cost_usd', - label: 'Cost', - sortable: true, - render: (v) => `$${Number(v).toFixed(2)}`, - }, - ]} - rows={topModels} + + + String(d).slice(5)} + interval="preserveStartEnd" /> - )} -
+ `$${Number(v).toFixed(2)}`} + width={52} + /> + [`$${Number(v).toFixed(2)}`, 'Cost']} />} /> + + + ) } -function ToolsSection({ userId }: { userId?: string }) { +function ToolsSection({ range, userId }: SectionProps) { const { data, isLoading, error } = useTools({ - range: 'all', + range, sort: 'calls', order: 'desc', page: 1, @@ -175,7 +175,7 @@ function ToolsSection({ userId }: { userId?: string }) { user_id: userId, }) - if (isLoading) return + if (isLoading && !data) return if (error) return if (!data || data.items.length === 0) return @@ -203,8 +203,8 @@ function ToolsSection({ userId }: { userId?: string }) { ) } -function ModelsSection({ userId }: { userId?: string }) { - const { data, isLoading, error } = useModels(userId) +function ModelsSection({ range, userId }: SectionProps) { + const { data, isLoading, error } = useModels(userId, range) const rows = useMemo(() => { if (!data) return [] @@ -213,7 +213,7 @@ function ModelsSection({ userId }: { userId?: string }) { const maxCost = useMemo(() => Math.max(...rows.map((r) => r.total_cost_usd), 1), [rows]) - if (isLoading) return + if (isLoading && !data) return if (error) return if (rows.length === 0) return @@ -259,22 +259,104 @@ function ModelsSection({ userId }: { userId?: string }) { ) } +function SessionsSection({ range, userId }: SectionProps) { + const navigate = useNavigate() + const { data, isLoading, error } = useSessions(1, 5, 'start_time', 'desc', userId, range) + + if (isLoading && !data) return + if (error) return + if (!data || data.items.length === 0) + return + + return ( + <> + + columns={[ + { + key: 'session_id', + label: 'Session', + render: (v) => ( + + {String(v).slice(0, 16)}… + + ), + }, + { + key: 'user_id', + label: 'User', + render: (v) => + v ? String(v) : anonymous, + }, + { + key: 'first_seen', + label: 'Started', + render: (v) => new Date(String(v)).toLocaleString(), + }, + { key: 'model', label: 'Model' }, + { + key: 'cost_usd', + label: 'Cost', + render: (v) => `$${Number(v).toFixed(2)}`, + }, + { + key: 'status', + label: 'Status', + render: (v) => sessionStatusBadge(String(v)), + }, + ]} + rows={data.items} + onRowClick={(row) => navigate(`/sessions/${row.session_id}`)} + /> + {data.covered_since && ( +

+ Sessions are listed from {formatDay(data.covered_since)} — earlier days in this range were + rolled up into daily totals, which carry no per-session start time, model or status. The + totals above still cover the whole range. +

+ )} + + ) +} + // ---- Main page ---- export default function Overview() { const [paused, setPaused] = useState(false) - const [searchParams] = useSearchParams() + const [searchParams, setSearchParams] = useSearchParams() const userId = searchParams.get('user_id') ?? undefined + const [range, setRange] = useRangeCookie(RANGE_COOKIE) - const { data, error, isLoading, isValidating, mutate } = useOverview(paused ? 0 : 30_000, userId) + const { data, error, isLoading, isValidating, mutate } = useOverview(paused ? 0 : 30_000, userId, range) const userParam = userId ? `?user_id=${encodeURIComponent(userId)}` : '' + const suffix = RANGE_SUFFIX[range] + const scoped = (label: string) => (suffix ? `${label} (${suffix})` : label) + + const clearUserFilter = () => + setSearchParams((prev) => { + const next = new URLSearchParams(prev) + next.delete('user_id') + return next + }) return (
-

Overview

+
+

Overview

+ {userId && ( + + )} +
+
- - - {isLoading ? ( + {isLoading && !data ? ( <>
@@ -294,32 +374,38 @@ export default function Overview() { mutate()} /> ) : data ? (
- - - - - + + + + +
) : null} - - - + {!userId && ( + + + + )} - + - + - + - + + + + +
) diff --git a/internal/dashboard/static/index.html b/internal/dashboard/static/index.html index 8d11231..3bd221d 100644 --- a/internal/dashboard/static/index.html +++ b/internal/dashboard/static/index.html @@ -5,8 +5,8 @@ cotel - - + +
From 13e949a2398df39621bb560a9cf97d87a425fe46 Mon Sep 17 00:00:00 2001 From: Wayland Date: Thu, 20 Aug 2026 22:50:37 +0200 Subject: [PATCH 4/5] fix(overview): state the raw-only window on History and drop phantom sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two disagreements survived the range rework, both found by driving the page against a copy of production after a real retention roll-up. /history reads raw spans only and has no range parameter, so on a long range it charted a visibly shorter window than the Costs panel beside it with nothing saying so — the exact defect the single selector exists to remove. Its coverage starts at the same raw floor the sessions list already reports, so the page now states it in one line, the same way the Sessions block does. The two share one request: same SWR key, not a second fetch. /sessions also counted a span with an empty session_id as a session of its own. That produced a list row whose link 404s, and put the Sessions page one ahead of the Overview's session count for the same window. An empty session_id is not a session — it is what the roll-up records as the unknown sentinel — so both the count and the list now exclude it. Co-Authored-By: Wayland Co-Authored-By: Claude Opus 4.8 --- frontend/src/pages/Overview.tsx | 70 +++++++++++++++++----------- internal/api/handler.go | 9 +++- internal/api/overview_range_test.go | 34 ++++++++++++++ internal/dashboard/static/index.html | 2 +- 4 files changed, 84 insertions(+), 31 deletions(-) diff --git a/frontend/src/pages/Overview.tsx b/frontend/src/pages/Overview.tsx index 714e34b..07be9e3 100644 --- a/frontend/src/pages/Overview.tsx +++ b/frontend/src/pages/Overview.tsx @@ -92,7 +92,7 @@ function UsersSection({ range }: { range: RangeKey }) { ) } -function HistorySection({ range, userId }: SectionProps) { +function HistorySection({ range, userId, coveredSince }: SectionProps & { coveredSince: string | null }) { const days = RANGE_DAYS[range] const from = useMemo( () => (days === null ? HISTORY_EPOCH : isoDate(new Date(Date.now() - days * 86400_000))), @@ -107,32 +107,40 @@ function HistorySection({ range, userId }: SectionProps) { return return ( - - - - - - - - - String(b).slice(5)} - interval="preserveStartEnd" - /> - - [String(Math.round(v as number)), 'Spans']} />} /> - - - + <> + {coveredSince && ( +

+ Charted from {formatDay(coveredSince)} — this chart is built from raw spans, and earlier + days in this range survive only as daily totals. +

+ )} + + + + + + + + + String(b).slice(5)} + interval="preserveStartEnd" + /> + + [String(Math.round(v as number)), 'Spans']} />} /> + + + + ) } @@ -328,6 +336,12 @@ export default function Overview() { const { data, error, isLoading, isValidating, mutate } = useOverview(paused ? 0 : 30_000, userId, range) + // History is charted from raw spans, so it starts at the same raw floor the + // sessions list reports. Same SWR key as the Sessions section below, so the + // two share one request rather than issuing two. + const { data: sessions } = useSessions(1, 5, 'start_time', 'desc', userId, range) + const coveredSince = sessions?.covered_since ?? null + const userParam = userId ? `?user_id=${encodeURIComponent(userId)}` : '' const suffix = RANGE_SUFFIX[range] const scoped = (label: string) => (suffix ? `${label} (${suffix})` : label) @@ -389,7 +403,7 @@ export default function Overview() { )} - + diff --git a/internal/api/handler.go b/internal/api/handler.go index 998a263..9ca4da2 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -536,6 +536,11 @@ type sessionsResponse struct { CoveredSince *string `json:"covered_since"` } +// hasSession excludes spans that carry no session. An empty session_id is not a +// session: it groups into a row whose link 404s, and it is what the roll-up +// records as UnknownSentinel rather than as a session of its own. +const hasSession = `session_id IS NOT NULL AND session_id <> ''` + func (h *Handler) handleSessions(w http.ResponseWriter, r *http.Request) { rangeKey, since := parseRangeDefault(r, "all") page := queryInt(r, "page", 1) @@ -572,7 +577,7 @@ func (h *Handler) handleSessions(w http.ResponseWriter, r *http.Request) { uidClause += sinceClause var total int64 - _ = h.db.QueryRow(`SELECT COUNT(DISTINCT session_id) FROM spans WHERE session_id IS NOT NULL`+uidClause, scopeArgs...).Scan(&total) + _ = h.db.QueryRow(`SELECT COUNT(DISTINCT session_id) FROM spans WHERE `+hasSession+uidClause, scopeArgs...).Scan(&total) offset := (page - 1) * limit q := fmt.Sprintf(` @@ -588,7 +593,7 @@ func (h *Handler) handleSessions(w http.ResponseWriter, r *http.Request) { MAX(CASE WHEN status_code = 2 THEN 1 ELSE 0 END), COALESCE(MAX(user_id), '') FROM spans - WHERE session_id IS NOT NULL%s + WHERE `+hasSession+`%s GROUP BY session_id ORDER BY %s %s LIMIT ? OFFSET ? diff --git a/internal/api/overview_range_test.go b/internal/api/overview_range_test.go index a447a1c..1f96679 100644 --- a/internal/api/overview_range_test.go +++ b/internal/api/overview_range_test.go @@ -396,6 +396,40 @@ func TestSessions_RangeScopesAndReportsFullCoverage(t *testing.T) { } } +// TestSessions_BlankSessionIDIsNotASession pins the two panels to the same +// number: a span with an empty session_id used to group into a list row of its +// own, whose link 404s, while the Overview count excluded it. +func TestSessions_BlankSessionIDIsNotASession(t *testing.T) { + db, ro := openTestDB(t) + seedRangeFixture(t, db) + now := time.Now().Add(-2 * time.Hour) + insertSpan(t, db, storage.Span{ + TraceID: "tr", SpanID: "no-session", Name: "llm", SessionID: "", UserID: "alice", + StartTime: now, EndTime: now.Add(time.Second), + }) + h := api.New(ro) + + _, sessions := getJSON(t, h, "/api/v1/sessions?limit=100") + if got := num(t, sessions, "total"); got != 2 { + t.Errorf("/sessions total: want 2, got %v", got) + } + for _, it := range sessions["items"].([]any) { + if id := it.(map[string]any)["session_id"].(string); id == "" { + t.Errorf("/sessions listed a blank session_id row: %v", it) + } + } + + // Compared on "month", where the union adds no aggregate sessions, so the + // count and the list are answering for exactly the same set. On "all" they + // legitimately differ: the count spans the roll-up, the list cannot. + _, monthSessions := getJSON(t, h, "/api/v1/sessions?range=month&limit=100") + _, overview := getJSON(t, h, "/api/v1/overview?range=month") + if got := num(t, overview, "sessions_count"); got != num(t, monthSessions, "total") { + t.Errorf("sessions_count %v disagrees with /sessions total %v", + got, num(t, monthSessions, "total")) + } +} + func TestCosts_RangeAndExplicitBounds(t *testing.T) { db, ro := openTestDB(t) seedRangeFixture(t, db) diff --git a/internal/dashboard/static/index.html b/internal/dashboard/static/index.html index 3bd221d..2661485 100644 --- a/internal/dashboard/static/index.html +++ b/internal/dashboard/static/index.html @@ -5,7 +5,7 @@ cotel - + From 0640eacea1d63541c62ea6afb200f131dff6b025 Mon Sep 17 00:00:00 2001 From: Daedalus Date: Thu, 20 Aug 2026 23:56:14 +0200 Subject: [PATCH 5/5] fix(overview): name the users KPI for what it counts The KPI reads COUNT(DISTINCT user_id) over the selected range, so it counts principals with usage in that window. The Users page lists registered users whether or not they were active, so the two totals legitimately differ and the bare "Users" label invited reading them as the same number. Co-Authored-By: Daedalus Co-Authored-By: Claude Opus 4.8 --- docs/design/pages.md | 1 + frontend/src/pages/Overview.tsx | 2 +- internal/dashboard/static/index.html | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/design/pages.md b/docs/design/pages.md index 24fd10f..ead4150 100644 --- a/docs/design/pages.md +++ b/docs/design/pages.md @@ -96,6 +96,7 @@ These are rendered by a shared `` component that wraps every page's | Region | Component | Field | Source | Format | |---|---|---|---|---| | KPI row | `` | Sessions | `GET /overview → total_sessions` | Integer | +| KPI row | `` | Active Users | `GET /overview → users_count` | Integer — principals with usage in the range, not the registered-user total the Users page lists | | KPI row | `` | Total Cost | `GET /overview → total_cost_usd` | `$0.00` | | KPI row | `` | Input Tokens | `GET /overview → total_input_tokens` | `1.2M`, `890K`, `12.4K` | | KPI row | `` | Output Tokens | `GET /overview → total_output_tokens` | Same | diff --git a/frontend/src/pages/Overview.tsx b/frontend/src/pages/Overview.tsx index 07be9e3..f67fd97 100644 --- a/frontend/src/pages/Overview.tsx +++ b/frontend/src/pages/Overview.tsx @@ -389,7 +389,7 @@ export default function Overview() { ) : data ? (
- + diff --git a/internal/dashboard/static/index.html b/internal/dashboard/static/index.html index 2661485..f5c9a49 100644 --- a/internal/dashboard/static/index.html +++ b/internal/dashboard/static/index.html @@ -5,7 +5,7 @@ cotel - +