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:
+28
-5
@@ -459,13 +459,22 @@ The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`),
|
||||
| `POST` | `/api/agents` | Create agent (idempotent by `id`) |
|
||||
| `PATCH` | `/api/agents/:id` | Update agent |
|
||||
| `GET` | `/api/events` | List events (`session_id`, `limit`, `offset`); each row also carries `session_name`/`session_cwd`/`session_lane_id`/`agent_name`/`agent_subagent_type` (REST only, `new_event` unchanged) |
|
||||
| `GET` | `/api/snapshots` | Session snapshots, newest first, without their payload |
|
||||
| `POST` | `/api/snapshots` | `{session_id, redact?: {file_paths?}}` → freeze the session into an immutable row under a random token (same-origin) |
|
||||
| `GET` | `/api/snapshots/:token` | `{snapshot, payload}` — the frozen session, agents, events, stats and cost |
|
||||
| `DELETE`| `/api/snapshots/:id` | Delete a snapshot (same-origin) |
|
||||
| `GET` | `/api/query` | Query Explorer: sessions / events / agents filtered, sorted from an allowlist, paged; `format=csv\|json` exports the whole filter (≤ 50,000 rows) — `server/routes/query.js` |
|
||||
| `GET` | `/api/reports` | Scheduled report definitions with their latest run (`server/lib/reports.js`) |
|
||||
| `POST`/`PUT`/`DELETE` | `/api/reports[/:id]` | Create / update / delete a report definition (same-origin; delete also removes its runs and files) |
|
||||
| `POST` | `/api/reports/:id/run` | Run a report now (same-origin) |
|
||||
| `GET` | `/api/reports/:id/runs[/:runId/:format]` | The run log; one run's HTML/JSON file, path built from the ids only |
|
||||
| `GET` | `/api/stats` | Dashboard aggregate counters |
|
||||
| `GET` | `/api/analytics` | Analytics aggregates for charts/trends |
|
||||
| `GET` | `/api/metrics` | Prometheus / OpenMetrics exposition (text; v0.0.4) |
|
||||
|
||||
**Prometheus metrics (`GET /api/metrics`).** Exposes the dashboard's live counters — `agenthub_sessions`/`agenthub_agents` by status, `agenthub_events_total`, `agenthub_tokens_total` by kind, `agenthub_websocket_clients`, `agenthub_remote_sources` by enabled state, `agenthub_process_uptime_seconds`/`agenthub_process_resident_memory_bytes`, and `agenthub_build_info{version}` — in the Prometheus v0.0.4 text-exposition format for scraping into Prometheus / Grafana (`server/routes/metrics.js`). Values come from the same `server/db.js` prepared statements the REST API uses, so they match the UI; status series are enumerated so a gauge never drops out of the exposition at zero. The route is read-only and, being under `/api`, sits behind both the Host-header (DNS-rebinding) guard and the optional `DASHBOARD_TOKEN` guard: a non-loopback scraper (e.g. Prometheus in Docker via `host.docker.internal`) must be allowlisted with `DASHBOARD_ALLOWED_HOSTS` or it gets `403 EBADHOST`, and must send the token when one is set.
|
||||
**Prometheus metrics (`GET /api/metrics`).** Exposes the dashboard's live counters — `agenthub_sessions`/`agenthub_agents` by status, `agenthub_events_total`, `agenthub_tokens_total` by kind, `agenthub_cost_usd_total` by tool and model, `agenthub_websocket_clients`, `agenthub_remote_sources` by enabled state, `agenthub_process_uptime_seconds`/`agenthub_process_resident_memory_bytes`, and `agenthub_build_info{version}` — in the Prometheus v0.0.4 text-exposition format for scraping into Prometheus / Grafana (`server/routes/metrics.js`). Values come from the same `server/db.js` prepared statements the REST API uses, so they match the UI; status series are enumerated so a gauge never drops out of the exposition at zero. The route is read-only and, being under `/api`, sits behind both the Host-header (DNS-rebinding) guard and the optional `DASHBOARD_TOKEN` guard: a non-loopback scraper (e.g. Prometheus in Docker via `host.docker.internal`) must be allowlisted with `DASHBOARD_ALLOWED_HOSTS` or it gets `403 EBADHOST`, and must send the token when one is set.
|
||||
|
||||
**Data scope (`?sources=`).** `GET /api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics` all accept an optional `sources` query param — a comma-separated list of source ids (`local` plus any remote source id, see [Remote Data Sources](#remote-data-sources)) — that narrows the result to sessions with a matching `sessions.source`. It is parsed by `server/lib/source-filter.js` into SQL predicates; `/api/stats` and `/api/analytics` route to the source-scoped aggregates in `server/lib/scoped-stats.js` only when a scope is present, leaving the unscoped fast paths unchanged. `GET /api/sessions/facets` additionally returns a `sources` facet enumerating the known source ids.
|
||||
**Data scope (`?sources=`, `?tool=`).** `GET /api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics` all accept an optional `sources` query param — a comma-separated list of source ids (`local` plus any remote source id, see [Remote Data Sources](#remote-data-sources)) — that narrows the result to sessions with a matching `sessions.source`. It is parsed by `server/lib/source-filter.js` into SQL predicates; `/api/stats` and `/api/analytics` route to the source-scoped aggregates in `server/lib/scoped-stats.js` only when a scope is present, leaving the unscoped fast paths unchanged. `GET /api/sessions/facets` additionally returns a `sources` facet enumerating the known source ids. `?tool=<id>` (`sessions.tool`) narrows `/api/stats`, `/api/analytics`, `/api/workflows` and `/api/pricing/cost` the same way — `parseScope` folds both into one predicate over `sessions`; `/api/analytics`'s `total_cost` is computed exactly like `/api/pricing/cost`'s. `?from=&to=` (local `YYYY-MM-DD`, inclusive, with `tz_offset`; `parseRange`) narrows `/api/analytics` (sessions/agents by `started_at`, events by `created_at`, tokens and cost from the days in the range; the daily series keep 365 days), `/api/pricing/cost` (everything but `daily_costs`) and `/api/workflows` (sessions started in the range); a bad date is `400 INVALID_INPUT`. `computeAnalytics` / `computeCost` are the same computations as functions, used by scheduled reports.
|
||||
|
||||
**Session names** are kept in sync with the transcript title: on every hook event (and in the 15 s watchdog) the ingestor reads the latest `custom-title` (`/rename`, `claude -n`, picker `Ctrl+R`) or `ai-title` (auto) from the JSONL and updates `sessions.name` — `custom-title` always wins, `ai-title` only fills a placeholder/auto name — broadcasting `session_updated` so the UI reflects renames in real time. When neither title exists, the session's first user prompt (tool-result / meta / slash-command plumbing entries skipped, 60-char label) fills the placeholder session name plus the main agent's placeholder name and empty task; a later `ai-title` can still replace a descriptor-filled name, and the agent fill passes the in-flight `current_tool` through so it is never wiped mid-turn.
|
||||
|
||||
@@ -476,6 +485,7 @@ The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`),
|
||||
| Method | Path | Description |
|
||||
| ------ | ------------------ | ---------------------------------------------- |
|
||||
| `POST` | `/api/hooks/event` | Ingest one Claude Code hook event envelope |
|
||||
| `POST` | `/api/hooks/ingest-batch` | Hooks PUSHED from another machine (one SSH cannot reach). `Authorization: Bearer <token>` from Settings → Data → **Push ingest** — 503 `EPUSHDISABLED` until a token exists, 401 on a wrong one. Body `{source, events: [{hook_type, data, tool?}]}`: `source` 1-64 of `[A-Za-z0-9._-]` (sessions are tagged `push:<source>`), 1-500 events, 1 MB per request. Each event goes through the same path as `/event`, with the sender's `transcript_path`s dropped, no lane/pane binding, and a session another source owns refused (`EFOREIGN`, per item). Answers `{ok, source, stored, results[]}` |
|
||||
|
||||
Request body shape:
|
||||
|
||||
@@ -496,8 +506,10 @@ Request body shape:
|
||||
| `GET` | `/api/pricing` | List pricing rules |
|
||||
| `PUT` | `/api/pricing` | Create/update a pricing rule |
|
||||
| `DELETE` | `/api/pricing/:pattern` | Delete pricing rule |
|
||||
| `GET` | `/api/pricing/cost` | Total cost across all sessions |
|
||||
| `GET` | `/api/pricing/cost` | Total cost across all sessions (`?lane=`, `?tool=`, `?from=&to=`, `by_tool`) |
|
||||
| `GET` | `/api/pricing/cost/:id` | Cost breakdown for one session |
|
||||
| `GET` | `/api/budgets/status` | Enabled `budget` alert rules with current-period spend, %, projection |
|
||||
| `POST` | `/api/alerts/rules/preview` | Dry-run an alert rule over recorded history (read-only) |
|
||||
|
||||
`PUT /api/pricing` also accepts optional **time-limited introductory rates** (`intro_*_per_mtok` + an `intro_until` `YYYY-MM-DD` cutoff): usage on/before the cutoff is priced at the intro rate, after it at the standard rate. Intro columns are written only when the caller sends them, so a standard-rate edit never disturbs a promo. Every rate field present must be a non-negative finite number — `NaN`/negative values are rejected with `400 INVALID_INPUT` before anything is written. The agent-list endpoints (`GET /api/agents`, `GET /api/sessions/:id/agents`) attach a per-agent `cost` — each subagent's OWN cost, computed from its `metadata.tokens` at current rates (0 for main agents, whose cost is the session total).
|
||||
|
||||
@@ -619,6 +631,11 @@ An `antigravity` workspace carries no pipeline either.
|
||||
every `DASHBOARD_ANTIGRAVITY_POLL_MS` ms (default 15000, `0` disables). They
|
||||
become `sessions` rows (`tool = 'antigravity'`) and `token_usage` rows, and
|
||||
`antigravity_conversations` holds the mtime read for each file.
|
||||
- **Cursor sessions:** `server/lib/cursor-usage.js` reads Cursor's agent
|
||||
transcripts (`~/.cursor/projects/*/agent-transcripts/<id>/<id>.jsonl`) every
|
||||
`DASHBOARD_CURSOR_POLL_MS` ms (default 15000, `0` disables) into `sessions`
|
||||
rows (`tool = 'cursor'`) — no `token_usage`, the transcripts carry none —
|
||||
and `cursor_transcripts` holds the mtime read for each file.
|
||||
- **Quiet sessions:** agy fires no exit hook, so a session quiet for ten
|
||||
minutes (with hook events) or two (without) is settled to `completed`, its
|
||||
agents too, and sets its workspace idle. That workspace must be one the hooks left
|
||||
@@ -632,7 +649,7 @@ An `antigravity` workspace carries no pipeline either.
|
||||
|
||||
### Remote Data Sources
|
||||
|
||||
Live remote/multi-machine data collection over SSH. The dashboard pulls Claude Code history from other machines: `server/lib/remote-sync.js` uses recursive **`scp` over SSH** (built into OpenSSH — no `rsync` or extra packages on the remote) to mirror each remote's `~/.claude/projects` into a sandboxed per-source staging dir under the data dir, feeds it through the **same** importer used for local history (`scripts/import-history.js` `importFromDirectory`), and tags every imported session with the source id (`sessions.source`). Authentication defers entirely to the host SSH stack (ssh-agent / `~/.ssh/config` / identity file) — **no secrets are stored**; every command runs via `execFile`/`spawn` argument arrays (never a shell string) and `StrictHostKeyChecking` is left at its SSH default.
|
||||
Live remote/multi-machine data collection over SSH. The dashboard pulls Claude Code history — and, per source, opt-in Codex history (`sync_codex`, from `remote_codex_home`, default `~/.codex`: its `sessions/` mirrored with mtimes kept and read by the same `server/lib/codex-usage.js` pass as local Codex journals, under the source's tag; a sync where only one of the two CLIs is found is `ok` with the other's failure in `last_error`) — from other machines: `server/lib/remote-sync.js` uses recursive **`scp` over SSH** (built into OpenSSH — no `rsync` or extra packages on the remote) to mirror each remote's `~/.claude/projects` into a sandboxed per-source staging dir under the data dir, feeds it through the **same** importer used for local history (`scripts/import-history.js` `importFromDirectory`), and tags every imported session with the source id (`sessions.source`). Authentication defers entirely to the host SSH stack (ssh-agent / `~/.ssh/config` / identity file) — **no secrets are stored**; every command runs via `execFile`/`spawn` argument arrays (never a shell string) and `StrictHostKeyChecking` is left at its SSH default.
|
||||
|
||||
> **Cursor on remotes (informational):** The same note applies on synced machines — if Cursor on a remote host writes to `~/.claude`, those sessions are imported too. AgentHub reads the paths, not the app name.
|
||||
|
||||
@@ -677,7 +694,7 @@ Because sync runs non-interactively (`ssh -o BatchMode=yes`), the connection mus
|
||||
| Method | Path | Description |
|
||||
| ------ | ------------------------------ | ------------------------------------------------ |
|
||||
| `GET` | `/api/settings/info` | System info, DB stats, hooks status, cache stats. Also powers the Dashboard Health tab (server uptime, memory, CPU, DB record counts, WAL/journal mode, transcript cache hit/miss rates) |
|
||||
| `POST` | `/api/settings/clear-data` | Delete all sessions/agents/events/token usage, and empty `codex_rollouts`/`antigravity_conversations` so those sessions import again |
|
||||
| `POST` | `/api/settings/clear-data` | Delete all sessions/agents/events/token usage, and empty `codex_rollouts`/`antigravity_conversations`/`cursor_transcripts` so those sessions import again. Alert rules, webhook targets, pricing and `session_snapshots` survive |
|
||||
| `POST` | `/api/settings/reimport` | Re-import legacy sessions from `~/.claude/` |
|
||||
| `POST` | `/api/settings/reinstall-hooks`| Reinstall Claude Code hooks |
|
||||
| `POST` | `/api/settings/reset-pricing` | Reset pricing table to defaults, then run the automatic pricing sync at once (waiting up to 3 s). Body `{scope: "claude"}` (the Claude Code tab's **Reset Defaults**) deletes and reseeds only `claude-…` rows; no body resets every row |
|
||||
@@ -694,6 +711,11 @@ Because sync runs non-interactively (`ssh -o BatchMode=yes`), the connection mus
|
||||
| `GET` | `/api/settings/export` | Export all data (sessions, agents, events, token_usage, workflows, dashboard_runs, alert_rules, model_pricing) as one versioned JSON attachment |
|
||||
| `POST` | `/api/settings/import` | Restore a bundle from `/export`. Multipart `file` only, same-origin, ≤ 512 MB. Idempotent + non-destructive: sessions already present are skipped whole; restored payloads trimmed like every event insert |
|
||||
| `POST` | `/api/settings/cleanup` | Abandon stale sessions and purge old data (`purge_days` whole ≥ 1, else `400 INVALID_PURGE_DAYS`; `dry_run: true` counts only) |
|
||||
| `GET` | `/api/settings/privacy` | `{redact_secrets, ignore_cwd[], env_owned: {redact_secrets, ignore_cwd}}` (`server/lib/privacy.js`) |
|
||||
| `PUT` | `/api/settings/privacy` | `{redact_secrets?: boolean, ignore_cwd?: string[]}` → persisted to the settings file as `AGENTHUB_REDACT_SECRETS` / `AGENTHUB_IGNORE_CWD` (`sameOriginGuard`). 400 `EBADPATH` (relative, a root, a control char or the list separator), 409 `EENVOWNED`, 500 `ESAVE` |
|
||||
| `GET` | `/api/settings/push-token` | `{enabled, env_owned}` — never the token |
|
||||
| `POST` | `/api/settings/push-token` | Make a new push-ingest token (the old one stops working) and return it this ONCE as `token`; only its SHA-256 is stored (`AGENTHUB_PUSH_TOKEN_SHA256`). `sameOriginGuard`; 409 `EENVOWNED` |
|
||||
| `DELETE` | `/api/settings/push-token` | Revoke it: push ingest off (`sameOriginGuard`) |
|
||||
|
||||
### Claude Config Explorer (`/api/cc-config`)
|
||||
|
||||
@@ -1575,6 +1597,7 @@ DASHBOARD_LANE_SYNC_MS=2000 # re-broadcast lanes another dashboard on the
|
||||
DASHBOARD_SESSION_SYNC_MS=30000 # Continuous project-sync poll interval (ms); 0 disables the poll (watcher stays)
|
||||
DASHBOARD_LIVENESS_PROBE=1 # 0 disables the dead-session liveness reap (use when hooks arrive from another machine)
|
||||
DASHBOARD_LIVENESS_IDLE_SECONDS=60 # Idle gate before the liveness reap may complete a process-less session
|
||||
DASHBOARD_REPORTS_MS=60000 # Scheduled-reports beat (ms); 0 disables the scheduler
|
||||
|
||||
# Remote Data Sources (SSH pull; see the Remote Data Sources section)
|
||||
DASHBOARD_REMOTE_SYNC_MS=15000 # Remote-source sync poll interval (ms); 0 disables the poller
|
||||
|
||||
Reference in New Issue
Block a user