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.
150 lines
5.8 KiB
JavaScript
150 lines
5.8 KiB
JavaScript
/**
|
|
* @file The Query Explorer, GET /api/query: entity switch, filters (text, tool,
|
|
* status, date range, cwd), the sort ALLOWLIST (a column or direction outside
|
|
* it is a 400, never SQL), pagination, and the CSV/JSON exports — RFC 4180
|
|
* quoting and the formula-injection guard.
|
|
* @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(), "query-explorer-")));
|
|
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 } = require("../db");
|
|
const { csvCell } = require("../routes/query");
|
|
|
|
let server;
|
|
let base;
|
|
|
|
async function get(url) {
|
|
const res = await fetch(`${base}${url}`);
|
|
const text = await res.text();
|
|
let body = text;
|
|
try {
|
|
body = JSON.parse(text);
|
|
} catch {
|
|
/* CSV */
|
|
}
|
|
return { status: res.status, headers: res.headers, body };
|
|
}
|
|
|
|
function session(id, name, status, tool, cwd, startedAt) {
|
|
db.prepare(
|
|
"INSERT INTO sessions (id, name, status, tool, cwd, started_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
).run(id, name, status, tool, cwd, startedAt, startedAt);
|
|
}
|
|
|
|
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}`;
|
|
session("q1", "=HYPERLINK(evil)", "completed", "claude", "/w/app", "2026-09-01T10:00:00.000Z");
|
|
session("q2", 'says "hi", ok', "active", "codex", "/w/app/sub", "2026-09-05T10:00:00.000Z");
|
|
session("q3", "100%_done", "error", "claude", "/w/apple", "2026-09-10T10:00:00.000Z");
|
|
db.prepare(
|
|
"INSERT INTO agents (id, session_id, name, type, status, started_at) VALUES ('ag1', 'q1', 'main', 'main', 'completed', '2026-09-01T10:00:00.000Z')"
|
|
).run();
|
|
db.prepare(
|
|
"INSERT INTO events (session_id, event_type, tool_name, summary, created_at) VALUES ('q2', 'PreToolUse', 'Bash', 'line1\nline2', '2026-09-05T11:00:00.000Z')"
|
|
).run();
|
|
});
|
|
|
|
after(() => {
|
|
server.close();
|
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
|
});
|
|
|
|
const ids = (r) => r.body.rows.map((x) => x.id);
|
|
|
|
describe("GET /api/query", () => {
|
|
it("lists sessions newest first by default, paged", async () => {
|
|
const r = await get("/api/query");
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.entity, "sessions");
|
|
assert.deepEqual(ids(r), ["q3", "q2", "q1"]);
|
|
assert.equal(r.body.total, 3);
|
|
const p = await get("/api/query?limit=1&offset=1");
|
|
assert.deepEqual(ids(p), ["q2"]);
|
|
assert.equal(p.body.total, 3);
|
|
});
|
|
|
|
it("filters by text (wildcards literal), tool, status, range and cwd subtree", async () => {
|
|
assert.deepEqual(ids(await get("/api/query?q=%25_")), ["q3"]);
|
|
assert.deepEqual(ids(await get("/api/query?tool=codex")), ["q2"]);
|
|
assert.deepEqual(ids(await get("/api/query?status=error")), ["q3"]);
|
|
assert.deepEqual(ids(await get("/api/query?from=2026-09-02&to=2026-09-06")), ["q2"]);
|
|
assert.deepEqual(ids(await get("/api/query?cwd=/w/app&sort=started_at&dir=asc")), ["q1", "q2"]);
|
|
});
|
|
|
|
it("sorts only by an allowlisted column and direction", async () => {
|
|
assert.deepEqual(ids(await get("/api/query?sort=name&dir=asc")), ["q3", "q1", "q2"]);
|
|
for (const bad of [
|
|
"sort=id;DROP TABLE sessions",
|
|
"sort=summary",
|
|
"dir=sideways",
|
|
"entity=lanes",
|
|
"status=bogus",
|
|
"tool=../x",
|
|
"from=2026-02-31",
|
|
"format=xml",
|
|
]) {
|
|
const r = await get(`/api/query?${bad}`);
|
|
assert.equal(r.status, 400, bad);
|
|
}
|
|
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM sessions").get().n, 3);
|
|
});
|
|
|
|
it("queries events and agents with their own columns", async () => {
|
|
const e = await get("/api/query?entity=events&q=Bash");
|
|
assert.equal(e.body.total, 1);
|
|
assert.equal(e.body.rows[0].tool, "codex");
|
|
assert.equal((await get("/api/query?entity=events&status=active")).status, 400);
|
|
const a = await get("/api/query?entity=agents&status=completed");
|
|
assert.deepEqual(ids(a), ["ag1"]);
|
|
});
|
|
|
|
it("exports CSV with quoting and the formula guard", async () => {
|
|
const r = await get("/api/query?format=csv&sort=started_at&dir=asc");
|
|
assert.equal(r.status, 200);
|
|
assert.match(r.headers.get("content-type"), /text\/csv/);
|
|
assert.match(r.headers.get("content-disposition"), /attachment; filename="agenthub-sessions-/);
|
|
assert.equal(r.headers.get("x-truncated"), "0");
|
|
const lines = r.body.replace(/^\uFEFF/, "").split("\r\n");
|
|
assert.equal(lines[0], "id,name,status,tool,model,cwd,started_at,ended_at");
|
|
assert.ok(lines[1].startsWith("q1,'=HYPERLINK(evil),completed"));
|
|
assert.ok(lines[2].startsWith('q2,"says ""hi"", ok",active'));
|
|
const ev = await get("/api/query?entity=events&format=csv");
|
|
assert.ok(ev.body.includes('"line1\nline2"'));
|
|
});
|
|
|
|
it("exports JSON with the total and the cap", async () => {
|
|
const r = await get("/api/query?format=json&tool=claude");
|
|
assert.equal(r.body.total, 2);
|
|
assert.equal(r.body.truncated, false);
|
|
assert.equal(r.body.cap, 50_000);
|
|
assert.equal(r.body.rows.length, 2);
|
|
});
|
|
});
|
|
|
|
describe("csvCell", () => {
|
|
it("guards every formula lead and leaves numbers and plain text alone", () => {
|
|
for (const lead of ["=", "+", "-", "@", "\t"]) assert.equal(csvCell(`${lead}1`)[0], "'");
|
|
assert.equal(csvCell(-5), "-5");
|
|
assert.equal(csvCell(null), "");
|
|
assert.equal(csvCell("plain"), "plain");
|
|
assert.equal(csvCell("-1,2"), '"\'-1,2"');
|
|
});
|
|
});
|