4aae61297f
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.
320 lines
11 KiB
JavaScript
320 lines
11 KiB
JavaScript
/**
|
||
* @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 = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
||
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,
|
||
};
|