/** * @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 * `/reports//.` 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ĩ */ 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 "

—

"; const th = head.map((h) => `${esc(h)}`).join(""); const tr = rows.map((r) => `${r.map((v) => `${esc(v)}`).join("")}`).join(""); return `${th}${tr}
`; } /** The report as a self-contained HTML page; every value is escaped. */ function renderHtml(r) { const t = r.tokens; return ` ${esc(r.name)}

${esc(r.name)}

${esc(r.window.from)} → ${esc(r.window.to)} (${esc(r.window.days)} days) · generated ${esc(r.generated_at)}

Summary

${table( ["Sessions", "Agents", "Events", "Total cost"], [[r.sessions.total, r.agents.total, r.events.total, usd(r.cost.total)]] )}

Tokens

${table( ["Input", "Output", "Cache read", "Cache write"], [[t.total_input, t.total_output, t.total_cache_read, t.total_cache_write]] )}

Cost by model

${table( ["Model", "Cost"], r.cost.by_model.map((m) => [m.model, usd(m.cost)]) )}

Cost by AI tool

${table( ["Tool", "Cost"], r.cost.by_tool.map((m) => [m.tool, usd(m.cost)]) )}

Top tools

${table( ["Tool", "Calls"], r.top_tools.map((x) => [x.tool_name, x.count]) )}

Budgets (current period)

${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 ? `

Unpriced models (not counted): ${esc(r.cost.unpriced_models.join(", "))}

` : ""} `; } /** * 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, };