Files
AgentHub/server/lib/reports.js
T
nntrivi2001 4aae61297f 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.
2026-10-06 17:12:28 +07:00

320 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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,
};