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.
216 lines
8.0 KiB
JavaScript
216 lines
8.0 KiB
JavaScript
/**
|
|
* @file Scheduled reports: definition validation and CRUD behind the
|
|
* same-origin guard, `lastSlot`/`reportWindow` in local time, a run writing
|
|
* escaped HTML and JSON under <data dir>/reports/<id>/, the download route
|
|
* reading only what a run recorded, and the scheduler claiming each slot once.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
"use strict";
|
|
|
|
const { describe, it, before, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const http = require("node:http");
|
|
|
|
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "reports-")));
|
|
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
|
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
|
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
|
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
|
|
|
const { createApp } = require("../index");
|
|
const { db, stmts } = require("../db");
|
|
const reports = require("../lib/reports");
|
|
|
|
let server;
|
|
let base;
|
|
|
|
async function call(method, url, body, headers = {}) {
|
|
const res = await fetch(`${base}${url}`, {
|
|
method,
|
|
headers: { "content-type": "application/json", ...headers },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
const text = await res.text();
|
|
let parsed = text;
|
|
try {
|
|
parsed = JSON.parse(text);
|
|
} catch {
|
|
/* a file */
|
|
}
|
|
return { status: res.status, headers: res.headers, body: parsed };
|
|
}
|
|
|
|
const DEF = {
|
|
name: "Weekly <b>&</b>",
|
|
window_days: 7,
|
|
frequency: "weekly",
|
|
hour: 8,
|
|
weekday: 1,
|
|
tz_offset: -420,
|
|
formats: ["html", "json"],
|
|
};
|
|
|
|
before(async () => {
|
|
server = http.createServer(createApp());
|
|
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
base = `http://127.0.0.1:${server.address().port}`;
|
|
db.prepare(
|
|
"INSERT OR REPLACE INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok) VALUES ('rep-model%', 'R', 1, 0, 0, 0)"
|
|
).run();
|
|
const day = new Date(Date.now() - 2 * 86_400_000).toISOString();
|
|
db.prepare(
|
|
"INSERT INTO sessions (id, name, status, started_at, updated_at, tool) VALUES ('rs', 'rs', 'completed', ?, ?, 'claude')"
|
|
).run(day, day);
|
|
stmts.setTokenUsage.run(
|
|
"rs",
|
|
"rep-model<x>",
|
|
"standard",
|
|
"global",
|
|
"standard",
|
|
2e6,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0
|
|
);
|
|
db.prepare(
|
|
"INSERT INTO events (session_id, event_type, tool_name, created_at) VALUES ('rs', 'PreToolUse', '<script>', ?)"
|
|
).run(day);
|
|
});
|
|
|
|
after(() => {
|
|
server.close();
|
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("schedule math", () => {
|
|
const at = (iso) => Date.parse(iso);
|
|
it("finds the latest daily and weekly slot in local time", () => {
|
|
const daily = { frequency: "daily", hour: 8, weekday: 1, tz_offset: -420 };
|
|
// 2026-10-05 is a Monday. 08:00 at UTC+7 = 01:00Z.
|
|
assert.equal(
|
|
new Date(reports.lastSlot(daily, at("2026-10-05T02:00:00Z"))).toISOString(),
|
|
"2026-10-05T01:00:00.000Z"
|
|
);
|
|
assert.equal(
|
|
new Date(reports.lastSlot(daily, at("2026-10-05T00:30:00Z"))).toISOString(),
|
|
"2026-10-04T01:00:00.000Z"
|
|
);
|
|
const weekly = { ...daily, frequency: "weekly", weekday: 5 }; // Friday
|
|
assert.equal(
|
|
new Date(reports.lastSlot(weekly, at("2026-10-05T02:00:00Z"))).toISOString(),
|
|
"2026-10-02T01:00:00.000Z"
|
|
);
|
|
});
|
|
it("covers the window_days full local days before the run day", () => {
|
|
const w = reports.reportWindow({ window_days: 7, tz_offset: -420 }, at("2026-10-04T18:00:00Z"));
|
|
assert.deepEqual(w, { from: "2026-09-28", to: "2026-10-04" }); // 01:00 on the 5th locally
|
|
});
|
|
});
|
|
|
|
describe("/api/reports", () => {
|
|
let id;
|
|
it("validates and creates a definition", async () => {
|
|
for (const bad of [
|
|
{ ...DEF, name: "" },
|
|
{ ...DEF, window_days: 0 },
|
|
{ ...DEF, frequency: "hourly" },
|
|
{ ...DEF, hour: 24 },
|
|
{ ...DEF, formats: ["pdf"] },
|
|
{ ...DEF, formats: [] },
|
|
{ ...DEF, tz_offset: 9999 },
|
|
]) {
|
|
assert.equal((await call("POST", "/api/reports", bad)).status, 400, JSON.stringify(bad));
|
|
}
|
|
const r = await call("POST", "/api/reports", DEF);
|
|
assert.equal(r.status, 201);
|
|
id = r.body.report.id;
|
|
assert.deepEqual(r.body.report.formats, ["html", "json"]);
|
|
assert.equal(r.body.report.enabled, true);
|
|
assert.ok(r.body.report.last_slot, "the slot already past is claimed");
|
|
});
|
|
|
|
it("refuses a cross-origin write", async () => {
|
|
const r = await call("POST", "/api/reports", DEF, { origin: "https://evil.example" });
|
|
assert.equal(r.status, 403);
|
|
});
|
|
|
|
it("updates part of a definition", async () => {
|
|
const r = await call("PUT", `/api/reports/${id}`, { enabled: false });
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.report.enabled, false);
|
|
assert.equal(r.body.report.name, DEF.name);
|
|
assert.equal((await call("PUT", `/api/reports/${id}`, { hour: -1 })).status, 400);
|
|
assert.equal((await call("PUT", "/api/reports/999", { enabled: true })).status, 404);
|
|
});
|
|
|
|
it("runs now, writing escaped HTML and JSON, and serves them by id", async () => {
|
|
const r = await call("POST", `/api/reports/${id}/run`);
|
|
assert.equal(r.status, 200);
|
|
const run = r.body.run;
|
|
assert.equal(run.status, "ok", run.error);
|
|
assert.deepEqual(run.files, ["html", "json"]);
|
|
assert.equal(run.trigger, "manual");
|
|
|
|
const json = await call("GET", `/api/reports/${id}/runs/${run.id}/json`);
|
|
assert.equal(json.status, 200);
|
|
assert.equal(json.body.sessions.total, 1);
|
|
assert.equal(json.body.cost.total, 2);
|
|
assert.deepEqual(json.body.cost.by_model, [{ model: "rep-model<x>", cost: 2 }]);
|
|
assert.deepEqual(json.body.top_tools, [{ tool_name: "<script>", count: 1 }]);
|
|
|
|
const html = await call("GET", `/api/reports/${id}/runs/${run.id}/html`);
|
|
assert.equal(html.status, 200);
|
|
assert.match(html.headers.get("content-disposition"), /attachment/);
|
|
assert.ok(!html.body.includes("<script>"));
|
|
assert.ok(html.body.includes("<script>"));
|
|
assert.ok(html.body.includes("Weekly <b>&</b>"));
|
|
assert.ok(fs.existsSync(path.join(ROOT, "data", "reports", String(id))));
|
|
|
|
assert.equal((await call("GET", `/api/reports/${id}/runs/${run.id}/pdf`)).status, 404);
|
|
assert.equal((await call("GET", `/api/reports/${id}/runs/${run.id + 9}/json`)).status, 404);
|
|
assert.equal((await call("GET", `/api/reports/${id}/runs/..%2F..%2Fx/json`)).status, 404);
|
|
|
|
const list = await call("GET", "/api/reports");
|
|
assert.equal(list.body.reports[0].last_run.id, run.id);
|
|
const runs = await call("GET", `/api/reports/${id}/runs`);
|
|
assert.equal(runs.body.runs.length, 1);
|
|
});
|
|
|
|
it("deletes a definition with its runs and files", async () => {
|
|
const r = await call("DELETE", `/api/reports/${id}`);
|
|
assert.equal(r.status, 200);
|
|
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM report_runs").get().n, 0);
|
|
assert.ok(!fs.existsSync(path.join(ROOT, "data", "reports", String(id))));
|
|
assert.equal((await call("DELETE", `/api/reports/${id}`)).status, 404);
|
|
});
|
|
});
|
|
|
|
describe("scheduler", () => {
|
|
it("runs a due report once per slot", async () => {
|
|
const r = await call("POST", "/api/reports", { ...DEF, frequency: "daily", formats: ["json"] });
|
|
const id = r.body.report.id;
|
|
const now = Date.now();
|
|
assert.equal(reports.tick(now), 0, "the slot claimed at creation is not run");
|
|
const tomorrow = now + 86_400_000;
|
|
assert.equal(reports.tick(tomorrow), 1);
|
|
assert.equal(reports.tick(tomorrow), 0);
|
|
const runs = db.prepare("SELECT * FROM report_runs WHERE report_id = ?").all(id);
|
|
assert.equal(runs.length, 1);
|
|
assert.equal(runs[0].trigger, "schedule");
|
|
});
|
|
|
|
it("survives Clear Data", async () => {
|
|
await call("POST", "/api/settings/clear-data");
|
|
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM report_definitions").get().n, 1);
|
|
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM report_runs").get().n, 1);
|
|
});
|
|
});
|