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
+319
View File
@@ -0,0 +1,319 @@
/**
* @file Scheduled reports. A definition (`report_definitions`) names a window
* of days, a frequency (daily / weekly on a weekday), a local hour at the
* browser's `tz_offset`, and the formats to write (html, json). A run builds
* the same numbers as GET /api/analytics and GET /api/pricing/cost over the
* window — sessions, agents, events, tokens, cost by model and by AI tool, top
* tools — plus the current budget status, writes them to
* `<data dir>/reports/<report id>/<stamp>.<format>` and logs the run in
* `report_runs`. File paths are built from the report id and a server-made
* stamp only. The scheduler is an unref'd interval started by the server's
* background services (`DASHBOARD_REPORTS_MS`, default 60 s, 0 = off).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { db } = require("../db");
const { getDataDir } = require("./claude-home");
const { parseRange } = require("./source-filter");
const { budgetStatus } = require("./budgets");
const { computeAnalytics } = require("../routes/analytics");
const { computeCost } = require("../routes/pricing");
const DAY_MS = 86_400_000;
const FORMATS = ["html", "json"];
const FREQUENCIES = ["daily", "weekly"];
const reportDir = (id) => path.join(getDataDir(), "reports", String(Number(id)));
const ymd = (ms) => new Date(ms).toISOString().slice(0, 10);
/** A stored row as the API shows it. */
function view(row) {
if (!row) return null;
let formats;
try {
formats = JSON.parse(row.formats);
} catch {
formats = ["html"];
}
return { ...row, formats, enabled: !!row.enabled };
}
function viewRun(row) {
let files;
try {
files = JSON.parse(row.files);
} catch {
files = [];
}
return { ...row, files };
}
const int = (v, lo, hi) => Number.isInteger(v) && v >= lo && v <= hi;
/**
* Validate a definition body. Returns `{value}` or `{error}`.
* @param {object} b
*/
function validate(b) {
const name = typeof b.name === "string" ? b.name.trim() : "";
if (!name || name.length > 200) return { error: "name is required (≤ 200 characters)" };
if (!int(b.window_days, 1, 366)) return { error: "window_days must be an integer 1–366" };
if (!FREQUENCIES.includes(b.frequency)) return { error: "frequency must be daily or weekly" };
if (!int(b.hour, 0, 23)) return { error: "hour must be an integer 0–23" };
const weekday = b.weekday === undefined ? 1 : b.weekday;
if (!int(weekday, 0, 6)) return { error: "weekday must be an integer 0–6 (0 = Sunday)" };
const tz = b.tz_offset === undefined ? 0 : b.tz_offset;
if (!int(tz, -840, 840)) return { error: "tz_offset must be minutes, -840–840" };
const formats = Array.isArray(b.formats) ? [...new Set(b.formats)] : [];
if (formats.length === 0 || !formats.every((f) => FORMATS.includes(f))) {
return { error: "formats must be a non-empty list of html, json" };
}
if (b.enabled !== undefined && typeof b.enabled !== "boolean") {
return { error: "enabled must be a boolean" };
}
return {
value: {
name,
window_days: b.window_days,
frequency: b.frequency,
hour: b.hour,
weekday,
tz_offset: tz,
formats,
enabled: b.enabled !== false,
},
};
}
/**
* The most recent scheduled instant at or before `now` (epoch ms): today's
* `hour` in the definition's local time (daily), or the latest `weekday` at
* that hour (weekly).
*/
function lastSlot(def, now) {
const tz = def.tz_offset * 60_000;
const local = new Date(now - tz); // read with getUTC*
let slot = Date.UTC(local.getUTCFullYear(), local.getUTCMonth(), local.getUTCDate(), def.hour);
const weekly = def.frequency === "weekly";
if (weekly) slot -= ((local.getUTCDay() - def.weekday + 7) % 7) * DAY_MS;
if (slot > local.getTime()) slot -= (weekly ? 7 : 1) * DAY_MS;
return slot + tz;
}
/** The report window: the `window_days` full local days before `now`'s day. */
function reportWindow(def, now) {
const local = new Date(now - def.tz_offset * 60_000);
const today = Date.UTC(local.getUTCFullYear(), local.getUTCMonth(), local.getUTCDate());
return { from: ymd(today - def.window_days * DAY_MS), to: ymd(today - DAY_MS) };
}
/** The report content for a definition at `now`. */
function buildReport(def, now = Date.now()) {
const win = reportWindow(def, now);
const range = parseRange({ query: { ...win, tz_offset: String(def.tz_offset) } });
const tzModifier = `${-def.tz_offset} minutes`;
const a = computeAnalytics({ range, tzModifier });
const c = computeCost({}, range, tzModifier);
const byModel = new Map();
for (const b of c.breakdown) byModel.set(b.model, (byModel.get(b.model) || 0) + b.cost);
const round = (n) => Math.round(n * 10000) / 10000;
return {
report_id: def.id,
name: def.name,
generated_at: new Date(now).toISOString(),
window: { ...win, days: def.window_days, tz_offset: def.tz_offset },
sessions: { total: a.overview.total_sessions, by_status: a.sessions_by_status },
agents: { total: a.overview.total_agents, subagents: a.total_subagents },
events: { total: a.overview.total_events, avg_per_session: a.avg_events_per_session },
tokens: a.tokens,
cost: {
total: c.total_cost,
by_model: [...byModel.entries()]
.map(([model, cost]) => ({ model, cost: round(cost) }))
.sort((x, y) => y.cost - x.cost),
by_tool: c.by_tool,
unpriced_models: c.unpriced_models.map((u) => u.model),
},
top_tools: a.tool_usage.slice(0, 10),
// The budgets' CURRENT period, whatever the window.
budgets: budgetStatus({ now }).map((b) => ({
name: b.name,
period: b.config.period,
period_start: b.period_start,
period_end: b.period_end,
limit_usd: b.config.limit_usd,
spend: b.spend,
pct: b.pct,
projected: b.projected,
})),
};
}
const ESC = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (ch) => ESC[ch]);
const usd = (n) => `$${Number(n || 0).toFixed(2)}`;
function table(head, rows) {
if (rows.length === 0) return "<p class=muted>—</p>";
const th = head.map((h) => `<th>${esc(h)}</th>`).join("");
const tr = rows.map((r) => `<tr>${r.map((v) => `<td>${esc(v)}</td>`).join("")}</tr>`).join("");
return `<table><thead><tr>${th}</tr></thead><tbody>${tr}</tbody></table>`;
}
/** The report as a self-contained HTML page; every value is escaped. */
function renderHtml(r) {
const t = r.tokens;
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>${esc(r.name)}</title>
<style>body{font:14px system-ui,sans-serif;max-width:860px;margin:2rem auto;padding:0 1rem;color:#222}
table{border-collapse:collapse;width:100%;margin:.5rem 0 1.5rem}td,th{border-bottom:1px solid #ddd;padding:4px 8px;text-align:left}
th{color:#555;font-weight:600}.muted{color:#777}h2{margin-top:2rem;font-size:16px}</style></head><body>
<h1>${esc(r.name)}</h1>
<p class=muted>${esc(r.window.from)} → ${esc(r.window.to)} (${esc(r.window.days)} days) · generated ${esc(r.generated_at)}</p>
<h2>Summary</h2>
${table(
["Sessions", "Agents", "Events", "Total cost"],
[[r.sessions.total, r.agents.total, r.events.total, usd(r.cost.total)]]
)}
<h2>Tokens</h2>
${table(
["Input", "Output", "Cache read", "Cache write"],
[[t.total_input, t.total_output, t.total_cache_read, t.total_cache_write]]
)}
<h2>Cost by model</h2>
${table(
["Model", "Cost"],
r.cost.by_model.map((m) => [m.model, usd(m.cost)])
)}
<h2>Cost by AI tool</h2>
${table(
["Tool", "Cost"],
r.cost.by_tool.map((m) => [m.tool, usd(m.cost)])
)}
<h2>Top tools</h2>
${table(
["Tool", "Calls"],
r.top_tools.map((x) => [x.tool_name, x.count])
)}
<h2>Budgets (current period)</h2>
${table(
["Budget", "Period", "Spend", "Limit", "%", "Projected"],
r.budgets.map((b) => [
b.name,
`${b.period_start} → ${b.period_end}`,
usd(b.spend),
usd(b.limit_usd),
b.pct,
usd(b.projected),
])
)}
${r.cost.unpriced_models.length ? `<p class=muted>Unpriced models (not counted): ${esc(r.cost.unpriced_models.join(", "))}</p>` : ""}
</body></html>
`;
}
/**
* Run a report now: build it, write each format, log the run. Never throws —
* a failure is a run with `status = 'error'`.
* @returns {object} the run row
*/
function runReport(def, trigger, now = Date.now()) {
const stamp = new Date(now).toISOString().replace(/[:.]/g, "-");
const win = reportWindow(def, now);
const files = [];
let error = null;
try {
const content = buildReport(def, now);
const dir = reportDir(def.id);
fs.mkdirSync(dir, { recursive: true });
for (const f of def.formats) {
const body = f === "json" ? JSON.stringify(content, null, 2) : renderHtml(content);
fs.writeFileSync(path.join(dir, `${stamp}.${f}`), body);
files.push(f);
}
} catch (err) {
error = err.message || String(err);
}
const info = db
.prepare(
`INSERT INTO report_runs (report_id, trigger, status, window_from, window_to, stamp, files, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
def.id,
trigger,
error ? "error" : "ok",
win.from,
win.to,
stamp,
JSON.stringify(files),
error
);
return viewRun(db.prepare("SELECT * FROM report_runs WHERE id = ?").get(info.lastInsertRowid));
}
/** Absolute path of a run's file, or null. Built from ids and the stored stamp. */
function runFile(reportId, runId, format) {
if (!FORMATS.includes(format)) return null;
const run = db
.prepare("SELECT * FROM report_runs WHERE id = ? AND report_id = ?")
.get(Number(runId), Number(reportId));
if (!run || !run.stamp || !viewRun(run).files.includes(format)) return null;
const file = path.join(reportDir(reportId), `${run.stamp}.${format}`);
return fs.existsSync(file) ? file : null;
}
/**
* Run every enabled report whose latest slot is newer than the one it last
* claimed. The claim is one conditional UPDATE, so of two dashboards sharing
* the database only one runs a slot.
* @returns {number} reports run
*/
function tick(now = Date.now()) {
let ran = 0;
for (const row of db.prepare("SELECT * FROM report_definitions WHERE enabled = 1").all()) {
const def = view(row);
const slot = new Date(lastSlot(def, now)).toISOString();
const claimed = db
.prepare(
"UPDATE report_definitions SET last_slot = ? WHERE id = ? AND (last_slot IS NULL OR last_slot < ?)"
)
.run(slot, def.id, slot).changes;
if (claimed) {
runReport(def, "schedule", now);
ran++;
}
}
return ran;
}
function startReportScheduler() {
const every = Number(process.env.DASHBOARD_REPORTS_MS ?? 60_000);
if (!(every > 0)) return;
const t = setInterval(() => {
try {
tick();
} catch (err) {
console.warn("scheduled reports failed:", err?.message || err);
}
}, every);
if (t.unref) t.unref();
}
module.exports = {
FORMATS,
reportDir,
view,
viewRun,
validate,
lastSlot,
reportWindow,
buildReport,
renderHtml,
runReport,
runFile,
tick,
startReportScheduler,
};