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
+74 -4
View File
@@ -1,5 +1,5 @@
/**
* @file Express router for settings-related endpoints, providing system info, database statistics, hook status, and operations to clear data, re-import sessions, reinstall hooks, reset pricing, export data, and perform cleanup of stale sessions. This allows the frontend to manage and maintain the agent monitoring system effectively.
* @file Express router for settings-related endpoints, providing system info, database statistics, hook status, and operations to clear data, re-import sessions, reinstall hooks, reset pricing, export data, and perform cleanup of stale sessions, plus the privacy controls (secret redaction, ignored folders) and the push-ingest token. This allows the frontend to manage and maintain the agent monitoring system effectively.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -24,7 +24,9 @@ const APP_VERSION = (() => {
const { getSettingsPath, getClaudeHome, setClaudeHome } = require("../lib/claude-home");
const { getCodexHome, setCodexHome } = require("../lib/codex-home");
const { envOwned } = require("../lib/settings-env");
const { envOwned, applySetting } = require("../lib/settings-env");
const privacy = require("../lib/privacy");
const pushToken = require("../lib/push-token");
const pricingSync = require("../lib/pricing-sync");
const { EXPORT_FORMAT, EXPORT_VERSION, importExportBundle } = require("../lib/data-transfer");
@@ -171,10 +173,11 @@ function clearAll() {
db.prepare("DELETE FROM events").run();
db.prepare("DELETE FROM agents").run();
db.prepare("DELETE FROM sessions").run();
// What the Codex/Antigravity polls have read: kept, it would stop them ever
// importing those sessions again (and bring one back half, on its next write).
// What the Codex/Antigravity/Cursor polls have read: kept, it would stop them
// ever importing those sessions again (and bring one back half, on its next write).
db.prepare("DELETE FROM codex_rollouts").run();
db.prepare("DELETE FROM antigravity_conversations").run();
db.prepare("DELETE FROM cursor_transcripts").run();
// Fired alerts reference the cleared sessions — wipe the feed too. Alert
// *rules* survive: they're user configuration, like model_pricing.
db.prepare("DELETE FROM alert_events").run();
@@ -184,6 +187,11 @@ function clearAll() {
// Workflow runs belong to the sessions; with foreign keys off they would
// not cascade and stay as orphans.
db.prepare("DELETE FROM workflows").run();
// session_snapshots survive: a snapshot is a frozen copy the user chose to
// keep, like alert rules — deleted only from Settings → Snapshots.
// report_definitions and report_runs survive too: a definition is user
// configuration and a run's files are reports already delivered — removed
// only from Settings → Reports.
}
// POST /api/settings/reimport — re-import legacy sessions from ~/.claude/
@@ -438,6 +446,68 @@ homeRoutes("/claude-home", "CLAUDE_HOME", "claude_home", getClaudeHome, setClaud
// into the current home at its spawn, so a move needs no repair here.
homeRoutes("/codex-home", "CODEX_HOME", "codex_home", getCodexHome, setCodexHome);
// GET/PUT /api/settings/privacy — opt-in secret redaction of stored payloads
// and the folders whose hooks are dropped (server/lib/privacy.js). Both persist
// to the settings file; one the environment set is `env_owned` and refused.
function privacyState() {
return {
redact_secrets: privacy.redactionEnabled(),
ignore_cwd: privacy.ignoredCwds(),
env_owned: {
redact_secrets: envOwned.has(privacy.REDACT_KEY),
ignore_cwd: envOwned.has(privacy.IGNORE_KEY),
},
};
}
router.get("/privacy", (_req, res) => res.json(privacyState()));
router.put("/privacy", sameOriginGuard, (req, res) => {
const body = req.body || {};
const fail = (status, code, message) => res.status(status).json({ error: { code, message } });
const writes = [];
if (body.redact_secrets !== undefined) {
if (typeof body.redact_secrets !== "boolean")
return fail(400, "INVALID_INPUT", "redact_secrets must be a boolean");
writes.push([privacy.REDACT_KEY, body.redact_secrets ? "1" : "0"]);
}
if (body.ignore_cwd !== undefined) {
let list;
try {
list = privacy.validateIgnoreList(body.ignore_cwd);
} catch (err) {
return fail(400, err.code || "EBADPATH", err.message);
}
writes.push([privacy.IGNORE_KEY, list.join(path.delimiter)]);
}
const owned = writes.find(([k]) => envOwned.has(k));
if (owned) return fail(409, "EENVOWNED", `${owned[0]} is set by the environment`);
try {
for (const [k, v] of writes) applySetting(k, v);
} catch (err) {
return fail(500, "ESAVE", err.message);
}
res.json({ ok: true, ...privacyState() });
});
// GET/POST/DELETE /api/settings/push-token — the token for POST
// /api/hooks/ingest-batch (server/lib/push-token.js). GET never returns it;
// POST makes a new one (the old one stops working) and returns it this once.
router.get("/push-token", (_req, res) => res.json(pushToken.pushTokenStatus()));
function pushTokenWrite(fn) {
return (_req, res) => {
try {
const token = fn();
res.json({ ok: true, ...(token && { token }), ...pushToken.pushTokenStatus() });
} catch (err) {
const owned = err.code === "EENVOWNED";
res
.status(owned ? 409 : 500)
.json({ error: { code: owned ? err.code : "ESAVE", message: err.message } });
}
};
}
router.post("/push-token", sameOriginGuard, pushTokenWrite(pushToken.rotatePushToken));
router.delete("/push-token", sameOriginGuard, pushTokenWrite(pushToken.revokePushToken));
// POST /api/settings/cleanup — abandon stale sessions, purge old data.
// `purge_days` is a whole number ≥ 1 (0.1 days would purge almost everything);
// `dry_run: true` answers the same counts and changes nothing.