feat: analytics parity — tool cost, date ranges, budgets, query explorer, replay, snapshots (v2.4.0)

Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
  Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
  with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
  Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
  its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
  status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
  reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
  ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files

Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
This commit is contained in:
2026-10-06 17:12:28 +07:00
parent 837da6e5de
commit 4aae61297f
146 changed files with 13565 additions and 537 deletions
+119 -5
View File
@@ -20,6 +20,8 @@ Complete REST API and WebSocket documentation for Agent Dashboard.
- [Workspaces](#workspaces)
- [Notifications](#notifications)
- [Remote Data Sources](#remote-data-sources)
- [Query Explorer](#query-explorer)
- [Scheduled reports](#scheduled-reports)
- [WebSocket API](#websocket-api)
- [Error Handling](#error-handling)
- [Rate Limiting](#rate-limiting)
@@ -1169,6 +1171,10 @@ since every call also emits a `PostToolUse` and counting both would double
every number. `status` filters by session lifecycle, same convention as
`GET /api/workflows`; omit it (or pass `all`) for no filter.
Both `GET /api/workflows` and `/skill-usage` also take `from=YYYY-MM-DD&to=YYYY-MM-DD`
(+ `tz_offset`): only the sessions STARTED in those local days. A bad date, or
`from` after `to`, is `400 INVALID_INPUT`.
```json
{
"skills": [
@@ -1328,7 +1334,7 @@ Returns all sessions, ordered by most recent activity.
| `awaiting` | `1` | - | Only sessions blocked on the user: `awaiting_input_since IS NOT NULL AND status = 'active'` (the UI's **Waiting** filter) |
| `from` / `to` | ISO 8601 | - | Inclusive bounds on `started_at`; an unparseable value is ignored. Like every filter here, applied before `limit`/`offset`, so `total` stays exact |
| `cwd` | string | - | Exact working directory |
| `tool` | string | - | One CLI's sessions: `claude`, `codex`, `antigravity` |
| `tool` | string | - | One CLI's sessions: `claude`, `codex`, `antigravity`. Also accepted on `/api/stats`, `/api/analytics`, `/api/workflows` (and `/workflows/skill-usage`) and `/api/pricing/cost`, where a malformed id is `400 INVALID_INPUT` |
| `lane` | integer | - | The sessions a workspace OWNS (`sessions.lane_id`): the ones its panes held. `400 INVALID_INPUT` when not an id |
| `archived` | `0` \| `1` | - | `0` = not archived, `1` = archived only; omitted = both |
| `sources` | string | - | Comma-separated data-source ids to include (the built-in local history is `local`; remote SSH machines use their `remote_sources.id`). Omit for all sources. Also accepted on `/api/events`, `/api/agents`, `/api/stats`, `/api/analytics`, and `/api/pricing/cost`. See [Remote Data Sources](#remote-data-sources) |
@@ -1800,6 +1806,7 @@ Response `Content-Type: text/plain; version=0.0.4; charset=utf-8`.
| `agenthub_websocket_clients` | gauge | — | Connected realtime clients |
| `agenthub_remote_sources` | gauge | `enabled` (`true`/`false`) | Configured Remote Data Sources |
| `agenthub_tokens_total` | counter | `kind` (`input`/`output`/`cache_read`/`cache_write`) | Cumulative token usage |
| `agenthub_cost_usd_total` | counter | `tool`, `model` | Cumulative token cost in USD, priced like `GET /api/pricing/cost` (code-execution container time excluded) |
Status series are always emitted (even at `0`) so a series never disappears from the exposition. The endpoint is mounted under `/api`, so it sits behind the same two guards as every other route: the **Host-header (DNS-rebinding) guard** and the optional **`DASHBOARD_TOKEN`** guard. A scraper that reaches the server as anything other than loopback (e.g. Prometheus in Docker hitting `host.docker.internal`) must be allowlisted with `DASHBOARD_ALLOWED_HOSTS`, or the scrape returns `403 EBADHOST`; if a token is set, the scrape must also send it.
@@ -1815,6 +1822,8 @@ scrape_configs:
# credentials: "<DASHBOARD_TOKEN>"
```
A ready-made Prometheus config, recording rules and three Grafana boards built on these series live in `monitoring/`, runnable with `docker-compose.monitoring.yml` — see [MONITORING.md](./MONITORING.md).
---
### Pricing
@@ -2003,13 +2012,16 @@ curl -X DELETE http://localhost:4820/api/pricing/gpt-5.1-codex
#### Total Cost
```http
GET /api/pricing/cost?tz_offset=<minutes>&sources=<csv>&lane=<id>&date=<YYYY-MM-DD>
GET /api/pricing/cost?tz_offset=<minutes>&sources=<csv>&lane=<id>&tool=<id>&from=<YYYY-MM-DD>&to=<YYYY-MM-DD>&date=<YYYY-MM-DD>
```
The total cost with a per-model `breakdown`, plus `daily_costs: [{date, cost}]` by local date (`tz_offset` is the browser's `getTimezoneOffset()`). `date=<YYYY-MM-DD>` prices only that local day — `total_cost`, `breakdown` and `daily_costs` all cover just it (any other shape is `400 INVALID_INPUT`); the status bar's **Today** asks for the local date and shows the day's token total beside it, listing tokens and cost per model on hover (as it does for **Workspace** and **Session**). A day is the day the tokens were SPENT: each session's total is split over the local days of its `token_usage_hourly` rows (one per UTC hour, from each Claude transcript message's timestamp), and whatever the total holds beyond those rows — a session recorded before that table and whose transcript is gone, sub-agent files, Codex and Antigravity sessions — is booked to the session's start date. The days always add up to the total. `lane=<id>` narrows it to the sessions that workspace owns (`sessions.lane_id`, or held by one of its `dashboard_runs` and not stamped yet — the rule of `GET /api/sessions?lane=`; a non-numeric id is `400 INVALID_INPUT`); the status bar shows it as **Workspace $…** on the Workspace page, beside **Session $…** from `GET /api/pricing/cost/:sessionId` for the replayed session or the focused pane's.
The total cost with a per-model `breakdown`, plus `daily_costs: [{date, cost}]` by local date (`tz_offset` is the browser's `getTimezoneOffset()`). `date=<YYYY-MM-DD>` prices only that local day — `total_cost`, `breakdown`, `by_tool` and `daily_costs` all cover just it (any other shape is `400 INVALID_INPUT`); the status bar's **Today** asks for the local date and shows the day's token total beside it, listing tokens and cost per model on hover (as it does for **Workspace** and **Session**). A day is the day the tokens were SPENT: each session's total is split over the local days of its `token_usage_hourly` rows (one per UTC hour, from each Claude transcript message's timestamp), and whatever the total holds beyond those rows — a session recorded before that table and whose transcript is gone, sub-agent files, Codex and Antigravity sessions — is booked to the session's start date. The days always add up to the total. `lane=<id>` narrows it to the sessions that workspace owns (`sessions.lane_id`, or held by one of its `dashboard_runs` and not stamped yet — the rule of `GET /api/sessions?lane=`; a non-numeric id is `400 INVALID_INPUT`); the status bar shows it as **Workspace $…** on the Workspace page, beside **Session $…** from `GET /api/pricing/cost/:sessionId` for the replayed session or the focused pane's. `tool=<id>` narrows it to one AI tool's sessions (`sessions.tool`), and `by_tool: [{tool, cost}]` (most expensive first) splits the total per tool — each tool priced on its own rows, so the code-execution free allowance applies per tool. A Codex turn on the fast/priority service tier is booked as `speed = fast` (priced with the model's priority rates, which the pricing sync writes into the fast columns), one on the flex tier as `service_tier = flex` (half price, like batch).
`from` / `to` (local dates, inclusive, read with `tz_offset`; either end may be left open) narrow `total_cost`, `breakdown`, `by_tool`, `feature_costs` and `unpriced_models` to the tokens SPENT on those days (the same split as `daily_costs`). `daily_costs` itself stays the whole series, so a 30-day trend or a month-to-date pace is unaffected by the range. A malformed date, a date from year 9999, or `from` after `to` is `400 INVALID_INPUT`.
```bash
curl "http://localhost:4820/api/pricing/cost?tz_offset=-420"
curl "http://localhost:4820/api/pricing/cost?tz_offset=-420&from=2026-09-01&to=2026-09-30"
```
#### Get Plan Quota
@@ -2019,6 +2031,39 @@ GET /api/pricing/quota[?force=1]
```
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex", active, label?, account?, expiresAt?, windows: [{ label, usedPercent, resetsAt }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's `auth.json` access-token `exp` (JWT claims read unverified; no token leaves the server). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time.
#### Analytics
```http
GET /api/analytics?tz_offset=<minutes>&sources=<csv>&tool=<id>&from=<YYYY-MM-DD>&to=<YYYY-MM-DD>
```
The Analytics bundle (`tokens`, `total_cost`, `tool_usage`, `daily_events`, `daily_sessions`, `agent_types`, `event_types`, `avg_events_per_session`, `total_subagents`, `overview`, `agents_by_status`, `sessions_by_status`). With `from`/`to` (same rules as above) the shape is unchanged and the numbers narrow: sessions and agents count by `started_at`, events by `created_at`, and `tokens` / `total_cost` come from the cost rows of the days in the range (`token_usage` has no date, so tokens follow the spend-day split). `daily_events` and `daily_sessions` keep their 365-day window.
#### Spend Budgets
A budget is an alert rule of type `budget`, created and edited through `/api/alerts/rules`:
```json
{
"name": "Monthly",
"rule_type": "budget",
"config": { "period": "month", "limit_usd": 200, "thresholds": [80, 100], "tool": "codex", "tz_offset": -420 }
}
```
`period` is `day` | `week` (from Monday) | `month`, in the local time of `tz_offset` (the browser's `getTimezoneOffset()`, saved with the rule); `thresholds` are 1–10 whole percents (default `[80, 100]`), each firing ONCE per period through the alerts feed, webhooks and push; `tool` / `lane` narrow it to one AI tool's or one workspace's sessions. Spend is priced like `GET /api/pricing/cost`, by the day the tokens were spent. `cost_threshold` (`{ "usd": 5 }`) is the per-session rule.
```http
GET /api/budgets/status
```
Every enabled budget with its current period: `{ budgets: [{ id, name, config, period_start, period_end, spend, projected, pct, crossed }] }` — `projected` carries the pace so far to the period's end, `crossed` lists the thresholds reached.
```http
POST /api/alerts/rules/preview
```
`{ rule_type, config, days?, cooldown_seconds? }` → what the rule WOULD have fired over the last `days` (1–90, default 7): `{ supported, window_days, fires, sessions, samples }`, and for a budget `periods` (this one and the five before). Nothing is saved, fired or broadcast; `status_duration` answers `supported: false` (agent status history is not stored).
---
@@ -2074,6 +2119,8 @@ The `/api/remote-sources/*` namespace configures **remote SSH machines** the das
"ssh_port": 22,
"identity_file": "~/.ssh/id_ed25519",
"remote_home": "~/.claude",
"sync_codex": true,
"remote_codex_home": null,
"enabled": true,
"status": "ok",
"last_error": null,
@@ -2091,7 +2138,9 @@ The `/api/remote-sources/*` namespace configures **remote SSH machines** the das
}
```
`ssh_port`, `identity_file`, `remote_home`, `last_error`, `last_sync_at`, and `last_sync_counts` are nullable. `status` is one of `idle`, `syncing`, `ok`, `error`.
`ssh_port`, `identity_file`, `remote_home`, `remote_codex_home`, `last_error`, `last_sync_at`, and `last_sync_counts` are nullable. `status` is one of `idle`, `syncing`, `ok`, `error`.
`sync_codex` (default `false`) also pulls the machine's Codex history: `<remote_codex_home>/sessions` (default `~/.codex`, `wsl:~/.codex` when `remote_home` is a `wsl:` path) and its `session_index.jsonl` are mirrored with their mtimes kept into `<data dir>/remote-sources/<id>/codex/` and read by the same journal reader as local Codex history (`server/lib/codex-usage.js`), so those sessions arrive as `tool = 'codex'` rows tagged with this source, with their tokens; `last_sync_counts` then carries `codex_files` / `codex_sessions`. With Codex on, a machine may have only one of the two CLIs: a sync that finds one is `ok` with the other's failure in `last_error`; finding neither is an `error`.
#### List Remote Sources
@@ -2201,6 +2250,24 @@ Pulls history from **every enabled** source sequentially (one SSH connection at
{ "ok": true, "synced": 2, "results": [{ "id": "src_a", "ok": true }, { "id": "src_b", "ok": false, "error": "ssh exited with code 255" }] }
```
#### Push ingest (`POST /api/hooks/ingest-batch`)
For a machine the dashboard cannot reach over SSH: it pushes its own hook payloads. Off until **Settings → Data → Push ingest** creates a token (`POST /api/settings/push-token`, returned once; only its SHA-256 is stored as `AGENTHUB_PUSH_TOKEN_SHA256`).
```http
POST /api/hooks/ingest-batch
Authorization: Bearer ahp_…
Content-Type: application/json
{ "source": "laptop", "events": [ { "hook_type": "SessionStart", "data": { "session_id": "…", "cwd": "/home/me/app" } } ] }
```
- **503** `EPUSHDISABLED` with no token, **401** `EUNAUTHORIZED` on a wrong one (compared in constant time), **400** `EBADSOURCE` / `EBADBATCH`.
- `source`: 1–64 of `[A-Za-z0-9._-]`; the sessions are tagged `push:<source>` and appear in the `sources` filter.
- 1–500 events, 1 MB per request. `tool` per event is the same allowlist as `/api/hooks/event` (`codex`, else Claude Code; an Antigravity hook is answered `ignored`, since it must name a file under this machine's agy directory).
- Each event takes the `/api/hooks/event` path, except: `transcript_path` fields are dropped (they name the sender's files), no workspace or pane is touched, and a session another source owns is refused per item (`EFOREIGN`). Privacy settings apply.
- Response: `{ ok, source, stored, results: [{ok} | {ok, ignored} | {ok, duplicate} | {ok: false, error}] }`. Token cost needs the transcripts, which only an SSH source brings.
#### The `sources` filter
`GET /api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics` accept an optional `sources` query parameter: a comma-separated list of source ids to include (omit for all). `GET /api/sessions/facets` correspondingly returns a `sources: string[]` array (alongside `cwds`) listing the distinct `sessions.source` values so the UI can build the filter dropdown.
@@ -2438,10 +2505,57 @@ DELETE /api/pets/:id Delete a pet (its folder under the data di
The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens the pet block of **Settings → Appearance** (`/settings/appearance#pet`, `client/src/components/settings/PetSettings.tsx`; the old `/settings/pet` redirects there): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, idle/asleep → idle). Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly that size (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, idle/asleep → idle); dragged it runs left or right with the drag (and along the glide to its edge), hovered it waves. Each row keeps its own pace with a longer hold on its last frame (idle ~220 ms a frame, then ~1.4 s). Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly that size (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
**Featured pets are downloaded, never bundled.** The three offered (Goku, Border Collie, 11) are other people's uploads to codex-pets.net with no licence to redistribute them, so AgentHub ships none of their art: `POST /api/pets/featured/:id` takes only an id from the fixed list in `server/lib/pets.js` (`404 ENOPET` for any other, with no request made) and fetches `https://codex-pets.net/api/pets/<id>` and `/spritesheet` — no URL from the request, no redirects followed. A pet id is always made by the server (`p-<12 hex>` for an upload, `cp-<featured id>`), every write is same-origin (`403 EBADORIGIN`), and at most 50 pets are kept (`409 ETOOMANY`). Other errors: `413 ETOOBIG`, `502 EFETCH` (codex-pets.net unreachable or refusing).
### Session snapshots
```
GET /api/snapshots { snapshots: [{ id, token, session_id, created_at, session_name, redacted, event_count, bytes }] } — newest first, no payload
POST /api/snapshots { session_id, redact?: { file_paths?: boolean } } → 201 { snapshot: { id, token, session_id, created_at } }
GET /api/snapshots/:token { snapshot, payload } — the frozen copy
DELETE /api/snapshots/:id { ok: true }
```
A snapshot freezes a session as it is at that moment into one immutable `session_snapshots` row (`server/routes/snapshots.js`): `payload` is `{ version: 1, captured_at, redacted: { file_paths }, session, agents, events, stats, cost }` — the session row, its agents (each with its own `cost`), every stored event oldest first (`events.data` exactly as stored), the `/api/sessions/:id/stats` body and the `/api/pricing/cost/:id` result priced at the session's start date. Its `token` is 24 random bytes in base64url (32 characters) and is the only way to read it; the page is `/snapshot/<token>`. With `redact.file_paths`, paths are replaced **before anything is stored**: every `cwd`, `transcript_path`, `agent_transcript_path`, `file_path`, `filePath`, `notebook_path` and `path` value (also inside `events.data` and `metadata` JSON) becomes `[redacted]`, and the session's cwd / the home directory inside any other string (a Bash command, a summary) become `<cwd>` / `~`. A row cannot be updated (a trigger aborts it), outlives its session, and survives **Clear Data** — it goes only through `DELETE` (Settings → **Snapshots**). Both writes are same-origin (`403 EBADORIGIN`); `400 INVALID_INPUT` without `session_id`, `404 NOT_FOUND` for an unknown session, token or id.
### Query Explorer
```http
GET /api/query?entity=sessions|events|agents&q=&tool=&status=&cwd=&from=&to=&tz_offset=&sources=&sort=&dir=asc|desc&limit=&offset=&format=csv|json
```
Filters one table and answers one page — `{ entity, columns, sortable, statuses, rows, total, limit, offset, export_cap }` (`limit` 1–500, default 50) — or, with `format=csv|json`, the WHOLE filtered set as a download (`Content-Disposition: attachment`) capped at `export_cap` = 50,000 rows; the headers `X-Total-Count` and `X-Truncated: 1|0` say whether it was cut, and the JSON export is `{ entity, total, truncated, cap, rows }`. `server/routes/query.js`.
| Entity | Columns | `sort` accepts | Date range bounds | `q` searches | `status` |
|---|---|---|---|---|---|
| `sessions` (default) | id, name, status, tool, model, cwd, started_at, ended_at | started_at (default), ended_at, name, status, tool, model, cwd | `started_at` | id, name, cwd | active, completed, error, abandoned |
| `events` | id, session_id, agent_id, event_type, tool_name, summary, tool, cwd, created_at | created_at (default), event_type, tool_name, session_id, tool | `created_at` | summary, event_type, tool_name, session_id | — |
| `agents` | id, session_id, name, type, subagent_type, status, task, tool, cwd, started_at, ended_at | started_at (default), ended_at, name, type, subagent_type, status, tool | `started_at` | id, name, task, subagent_type | working, waiting, completed, error |
`tool`, `cwd` and `sources` apply to the row's session; `cwd` matches the folder and everything under it. `q` is a literal substring (`%` and `_` are not wildcards). `sort` and `dir` (default `desc`, ties broken by id) must come from the table above — the SQL column and direction are taken from that allowlist, never from the request — and anything else (an unknown entity/sort/dir/status/format, a malformed tool or date) is `400 INVALID_INPUT`. CSV is RFC 4180 (CRLF, a cell with `"`, `,` or a line break quoted, `"` doubled) with a UTF-8 BOM, and a text cell starting with `=`, `+`, `-`, `@`, a tab or a CR gets a leading `'` so a spreadsheet never runs it as a formula.
```bash
curl -OJ "http://localhost:4820/api/query?entity=events&q=Bash&from=2026-09-01&format=csv"
```
### Scheduled reports
```
GET /api/reports { reports: [{ id, name, window_days, frequency, hour, weekday, tz_offset, formats, enabled, last_slot, created_at, last_run }] }
POST /api/reports { name, window_days, frequency, hour, weekday?, tz_offset?, formats, enabled? } → 201 { report }
PUT /api/reports/:id any of those fields (the rest kept) → { report }
DELETE /api/reports/:id { ok: true } — the definition, its runs and its files
POST /api/reports/:id/run { run } — run now (also when disabled)
GET /api/reports/:id/runs { runs: [{ id, report_id, created_at, trigger, status, window_from, window_to, stamp, files, error }] } — newest 100
GET /api/reports/:id/runs/:runId/:format the file (html | json), as an attachment
```
A report (`server/lib/reports.js`) covers the `window_days` (1–366) full local days BEFORE the day it runs, at its `tz_offset` (the browser's `getTimezoneOffset()`, −840…840). `frequency` is `daily` or `weekly` (on `weekday`, 0 = Sunday, default Monday) at local `hour` 0–23; `formats` is a non-empty subset of `html`, `json`. Its content is computed like `GET /api/analytics` and `GET /api/pricing/cost` over that window — sessions (with status counts), agents, events, tokens, cost by model and by AI tool, unpriced models, the top 10 tools — plus every enabled budget's CURRENT period from `GET /api/budgets/status`. Each run writes `<data dir>/reports/<id>/<stamp>.<format>` and a `report_runs` row (`trigger` schedule | manual, `status` ok | error with `error`); the download path is built from the two ids and the stored stamp only (`404 NOT_FOUND` for anything else). Every value in the HTML is escaped.
The scheduler (started with the server's background services, every `DASHBOARD_REPORTS_MS`, default 60000, `0` = off) runs a report when its latest scheduled instant is newer than `last_slot`, claiming it with one conditional UPDATE so two dashboards on one database never both run it; a slot missed while the server was down runs once at the next start. Creating, editing or re-enabling a report claims the slot already past, so it never runs at once for an hour gone by. Writes are same-origin (`403 EBADORIGIN`); `400 INVALID_INPUT` for a bad field. Definitions and runs survive **Clear Data**, like alert rules and snapshots.
---
## WebSocket API