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:
+119
-7
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user