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 -7
View File
@@ -159,6 +159,10 @@ db.function(
{ deterministic: true },
require("./lib/event-payload").trimEventData
);
// ...and its summary through `redact_text(?)`, which is the identity unless the
// user turned on secret redaction (`server/lib/privacy.js`; the payload's own
// redaction is chained inside `trim_event_data`).
db.function("redact_text", { deterministic: true }, require("./lib/privacy").redactSummary);
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
@@ -344,12 +348,13 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_dashboard_runs_session ON dashboard_runs(session_id);
-- Rules-based alerting engine. Rules are evaluated server-side: event-driven
-- types (event_pattern, token_threshold) on hook ingest, time-based types
-- (inactivity, status_duration) on a periodic sweep in server/lib/alerts.js.
-- types (event_pattern, token_threshold, cost_threshold) on hook ingest,
-- time-based types (inactivity, status_duration, budget) on a periodic
-- sweep in server/lib/alerts.js.
CREATE TABLE IF NOT EXISTS alert_rules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
rule_type TEXT NOT NULL CHECK(rule_type IN ('event_pattern','inactivity','status_duration','token_threshold')),
rule_type TEXT NOT NULL, -- validated by validateRuleConfig (server/lib/alerts.js)
config TEXT NOT NULL DEFAULT '{}',
enabled INTEGER NOT NULL DEFAULT 1,
cooldown_seconds INTEGER NOT NULL DEFAULT 300,
@@ -431,6 +436,55 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_target ON webhook_deliveries(target_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_created ON webhook_deliveries(created_at DESC);
-- Frozen copies of a session (server/routes/snapshots.js): {session, agents,
-- events, stats, cost} as one JSON payload, read back by an unguessable
-- token. Immutable once written (trigger below). session_id has no FK on
-- purpose: a snapshot outlives its session and survives Clear Data, like
-- alert_rules.
CREATE TABLE IF NOT EXISTS session_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
session_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
payload TEXT NOT NULL
);
CREATE TRIGGER IF NOT EXISTS session_snapshots_immutable
BEFORE UPDATE ON session_snapshots
BEGIN SELECT RAISE(ABORT, 'session snapshots are immutable'); END;
-- Scheduled reports (server/lib/reports.js): a definition says what window
-- of days to summarise and when (local hour, and weekday for weekly, at the
-- browser's tz_offset); last_slot is the scheduled instant last claimed,
-- so two dashboards on one DB never both run it. Each run is logged in
-- report_runs; its files live under <data dir>/reports/<report id>/<stamp>.
-- Both survive Clear Data, like alert_rules and session_snapshots.
CREATE TABLE IF NOT EXISTS report_definitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
window_days INTEGER NOT NULL DEFAULT 7,
frequency TEXT NOT NULL DEFAULT 'weekly' CHECK(frequency IN ('daily','weekly')),
hour INTEGER NOT NULL DEFAULT 8,
weekday INTEGER NOT NULL DEFAULT 1,
tz_offset INTEGER NOT NULL DEFAULT 0,
formats TEXT NOT NULL DEFAULT '["html"]',
enabled INTEGER NOT NULL DEFAULT 1,
last_slot TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE IF NOT EXISTS report_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
report_id INTEGER NOT NULL REFERENCES report_definitions(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
trigger TEXT NOT NULL,
status TEXT NOT NULL,
window_from TEXT,
window_to TEXT,
stamp TEXT,
files TEXT NOT NULL DEFAULT '[]',
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_report_runs_report ON report_runs(report_id, id);
-- Workflow-tool runs: fleets of sub-agents spawned by the Claude Code
-- "Workflow" tool (and self-paced /loop). These emit NO hooks; the source of
-- truth is the on-disk run journal (~/.claude/projects/<enc-cwd>/<sessionId>/
@@ -1267,6 +1321,17 @@ db.exec(`
);
`);
// Which Cursor agent transcripts have been read, at what mtime
// (server/lib/cursor-usage.js) — the same "deleted stays deleted" rule.
db.exec(`
CREATE TABLE IF NOT EXISTS cursor_transcripts (
path TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
mtime_ms INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
`);
// Remote data sources: other machines whose Claude Code history this dashboard
// pulls in over SSH. Config only — NO secrets are stored here: authentication
// always defers to the host's own SSH stack (~/.ssh/config, ssh-agent, keys,
@@ -1290,6 +1355,19 @@ db.exec(`
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
`);
// Migrate: a source may also pull Codex history (`sync_codex`, opt-in) from
// `remote_codex_home` (NULL = ~/.codex). Additive; existing sources stay
// Claude-only.
try {
db.prepare("SELECT sync_codex FROM remote_sources LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE remote_sources ADD COLUMN sync_codex INTEGER NOT NULL DEFAULT 0").run();
}
try {
db.prepare("SELECT remote_codex_home FROM remote_sources LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE remote_sources ADD COLUMN remote_codex_home TEXT").run();
}
// Migrate webhook_targets for first-class providers. Earlier installs created
// the table with a 4-value `type` CHECK (slack/discord/teams/generic) and no
@@ -1330,6 +1408,38 @@ db.exec(`
}
}
// Migrate alert_rules off its 4-type `rule_type` CHECK (cost_threshold and
// budget came later; `validateRuleConfig` is the one list now). Rebuilt with
// foreign keys off, so alert_events — which references it with ON DELETE
// CASCADE — keeps every row through the DROP.
{
const meta = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='alert_rules'")
.get();
if (meta?.sql?.includes("CHECK(rule_type IN")) {
db.exec(`
PRAGMA foreign_keys = OFF;
BEGIN;
CREATE TABLE alert_rules_new (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
rule_type TEXT NOT NULL,
config TEXT NOT NULL DEFAULT '{}',
enabled INTEGER NOT NULL DEFAULT 1,
cooldown_seconds INTEGER NOT NULL DEFAULT 300,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
INSERT INTO alert_rules_new (id, name, rule_type, config, enabled, cooldown_seconds, created_at, updated_at)
SELECT id, name, rule_type, config, enabled, cooldown_seconds, created_at, updated_at FROM alert_rules;
DROP TABLE alert_rules;
ALTER TABLE alert_rules_new RENAME TO alert_rules;
COMMIT;
PRAGMA foreign_keys = ON;
`);
}
}
// Migrate: replace legacy idle/connected agent statuses with waiting/working
// and update the CHECK constraint to the 4-status model.
// SQLite doesn't support ALTER CHECK, so we detect the old constraint and
@@ -1584,8 +1694,8 @@ const stmts = {
),
getRemoteSource: db.prepare("SELECT * FROM remote_sources WHERE id = ?"),
insertRemoteSource: db.prepare(
`INSERT INTO remote_sources (id, label, host, ssh_port, identity_file, remote_home, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?)`
`INSERT INTO remote_sources (id, label, host, ssh_port, identity_file, remote_home, enabled, sync_codex, remote_codex_home)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
),
updateRemoteSource: db.prepare(
`UPDATE remote_sources SET
@@ -1595,6 +1705,8 @@ const stmts = {
identity_file = ?,
remote_home = ?,
enabled = COALESCE(?, enabled),
sync_codex = COALESCE(?, sync_codex),
remote_codex_home = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?`
),
@@ -1702,7 +1814,7 @@ const stmts = {
AND created_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-10 seconds')`
),
insertEvent: db.prepare(
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, ?, trim_event_data(?), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, redact_text(?), trim_event_data(?), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"
),
// Same insert with an EXPLICIT created_at, for events recovered from a
// transcript: their real time is in the file, not the moment we happened to
@@ -1711,7 +1823,7 @@ const stmts = {
// re-parse re-insert the whole turn history (587k rows across this DB, 75%
// of the events table, one session holding 68,747 copies of the same turns).
insertEventAt: db.prepare(
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, ?, trim_event_data(?), ?)"
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, redact_text(?), trim_event_data(?), ?)"
),
listEvents: db.prepare("SELECT * FROM events ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?"),
listEventsBySession: db.prepare(