Files
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

238 lines
8.1 KiB
JavaScript

/**
* @file Tests for spend budgets and dollar alerts: period bounds in local time,
* the `budget` and `cost_threshold` rule types (validation, firing once per
* threshold per period), GET /api/budgets/status and the read-only rule
* preview POST /api/alerts/rules/preview.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
"use strict";
const { describe, it, before, after, beforeEach } = 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(), "budgets-")));
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 { periodBounds, budgetSpend } = require("../lib/budgets");
const alerts = require("../lib/alerts");
const M = 1_000_000;
let server;
let base;
function call(method, url, body) {
return new Promise((resolve, reject) => {
const req = http.request(
`${base}${url}`,
{ method, headers: body ? { "content-type": "application/json" } : {} },
(res) => {
let text = "";
res.on("data", (c) => (text += c));
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(text) }));
}
);
req.on("error", reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}
const iso = (ms) => new Date(ms).toISOString();
function session(id, tool, startedAt, updatedAt = startedAt) {
db.prepare(
"INSERT INTO sessions (id, name, status, started_at, updated_at, tool) VALUES (?, ?, 'completed', ?, ?, ?)"
).run(id, id, startedAt, updatedAt, tool);
}
const spend = (id, input) =>
stmts.setTokenUsage.run(
id,
"budget-model",
"standard",
"global",
"standard",
input,
0,
0,
0,
0,
0,
0,
0
);
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 ('budget-model%', 'B', 1, 0, 0, 0)"
).run();
});
after(() => {
server.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
beforeEach(() => {
db.exec(
"DELETE FROM alert_events; DELETE FROM alert_rules; DELETE FROM token_usage; DELETE FROM sessions;"
);
alerts.invalidateRuleCache();
});
describe("periodBounds", () => {
// 2026-10-05 20:00 UTC = 2026-10-06 03:00 in UTC+7 (tz_offset -420), a Tuesday.
const now = Date.parse("2026-10-05T20:00:00Z");
it("starts a day at local midnight", () => {
const b = periodBounds("day", now, -420);
assert.equal(b.start, "2026-10-06");
assert.equal(b.end, "2026-10-06");
assert.equal(b.startUtc, "2026-10-05T17:00:00.000Z");
assert.equal(b.days, 1);
assert.ok(Math.abs(b.elapsed - 3 / 24) < 1e-9);
});
it("starts a week on Monday and a month on the 1st", () => {
assert.equal(periodBounds("week", now, -420).start, "2026-10-05");
assert.equal(periodBounds("week", now, -420).end, "2026-10-11");
const m = periodBounds("month", now, -420);
assert.equal(m.start, "2026-10-01");
assert.equal(m.end, "2026-10-31");
assert.equal(m.days, 31);
});
it("walks back whole periods, which count as fully elapsed", () => {
const m = periodBounds("month", now, 0, 1);
assert.equal(m.start, "2026-09-01");
assert.equal(m.end, "2026-09-30");
assert.equal(m.elapsed, 1);
});
});
describe("budget rules", () => {
it("validates the config", async () => {
const bad = await call("POST", "/api/alerts/rules", {
name: "b",
rule_type: "budget",
config: { period: "year", limit_usd: 5 },
});
assert.equal(bad.status, 400);
const ok = await call("POST", "/api/alerts/rules", {
name: "b",
rule_type: "budget",
config: { period: "day", limit_usd: 5, tool: "codex", tz_offset: -420 },
});
assert.equal(ok.status, 201);
assert.deepEqual(ok.body.rule.config, {
period: "day",
limit_usd: 5,
thresholds: [80, 100],
tool: "codex",
tz_offset: -420,
});
});
it("measures the period's spend in scope and projects it", () => {
const now = Date.now();
session("today", "codex", iso(now - 60_000));
spend("today", 2 * M); // $2
session("other-tool", "claude", iso(now - 60_000));
spend("other-tool", 9 * M);
session("yesterday", "codex", iso(now - 3 * 86_400_000));
spend("yesterday", 7 * M);
const s = budgetSpend(
{ period: "day", limit_usd: 4, thresholds: [50], tool: "codex" },
{ now }
);
assert.equal(s.spend, 2);
assert.equal(s.pct, 50);
assert.ok(s.projected >= 2);
});
it("fires each crossed threshold once per period, and the status lists it", async () => {
const now = Date.now();
session("s1", "claude", iso(now - 60_000));
spend("s1", 9 * M); // $9 of $10
const created = await call("POST", "/api/alerts/rules", {
name: "Daily",
rule_type: "budget",
config: { period: "day", limit_usd: 10, thresholds: [50, 80, 100] },
});
alerts.sweepTimeRules();
alerts.sweepTimeRules();
const fired = db.prepare("SELECT details FROM alert_events ORDER BY id").all();
assert.deepEqual(
fired.map((f) => JSON.parse(f.details).threshold),
[50, 80]
);
const status = await call("GET", "/api/budgets/status");
const b = status.body.budgets.find((x) => x.id === created.body.rule.id);
assert.equal(b.spend, 9);
assert.equal(b.pct, 90);
assert.deepEqual(b.crossed, [50, 80]);
});
});
describe("cost_threshold", () => {
it("fires when a session's cost reaches the threshold", async () => {
session("pricey", "claude", iso(Date.now()));
spend("pricey", 3 * M);
await call("POST", "/api/alerts/rules", {
name: "Expensive",
rule_type: "cost_threshold",
config: { usd: 2.5 },
});
alerts.evaluateEvent({ session_id: "pricey", event_type: "Stop" });
const a = db.prepare("SELECT message, details FROM alert_events").get();
assert.match(a.message, /\$3\.00/);
assert.equal(JSON.parse(a.details).cost_usd, 3);
});
});
describe("rule preview", () => {
it("replays an event pattern with its count window and cooldown, firing nothing", async () => {
session("p1", "claude", iso(Date.now() - 3_600_000));
const t0 = Date.now() - 30 * 60_000;
for (const [i, m] of [0, 1, 2, 20].entries()) {
db.prepare(
"INSERT INTO events (session_id, event_type, tool_name, summary, created_at) VALUES ('p1', 'PostToolUse', 'Bash', ?, ?)"
).run(`e${i}`, iso(t0 + m * 60_000));
}
const r = await call("POST", "/api/alerts/rules/preview", {
rule_type: "event_pattern",
config: { tool_name: "Bash", count: 2, window_minutes: 5 },
cooldown_seconds: 300,
});
assert.equal(r.status, 200);
// e1 (2 in window) fires; e2 is inside the cooldown; e20 is alone in its window.
assert.equal(r.body.fires, 1);
assert.equal(r.body.sessions, 1);
assert.equal(db.prepare("SELECT COUNT(*) c FROM alert_events").get().c, 0);
});
it("lists past budget periods and refuses what cannot be replayed", async () => {
const b = await call("POST", "/api/alerts/rules/preview", {
rule_type: "budget",
config: { period: "week", limit_usd: 1 },
});
assert.equal(b.body.periods.length, 6);
const s = await call("POST", "/api/alerts/rules/preview", {
rule_type: "status_duration",
config: { status: "working", minutes: 5 },
});
assert.equal(s.body.supported, false);
const bad = await call("POST", "/api/alerts/rules/preview", {
rule_type: "budget",
config: { period: "week", limit_usd: 1 },
days: 0,
});
assert.equal(bad.status, 400);
});
});