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.
233 lines
7.8 KiB
JavaScript
233 lines
7.8 KiB
JavaScript
/**
|
|
* @file Query Explorer: `GET /api/query?entity=sessions|events|agents` filters
|
|
* one table (free text, AI tool, status, local date range, cwd, data scope),
|
|
* sorts it by a column from a per-entity ALLOWLIST, and answers one page as
|
|
* JSON — or, with `format=csv|json`, the whole filtered set (capped at
|
|
* `EXPORT_CAP` rows) as a download. No column, direction or table name is ever
|
|
* taken from the request into SQL; every value is a bound parameter.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { Router } = require("express");
|
|
const { db } = require("../db");
|
|
const { parseScope, parseTool, BAD_TOOL, parseRange, BAD_RANGE } = require("../lib/source-filter");
|
|
|
|
const router = Router();
|
|
|
|
const EXPORT_CAP = 50_000;
|
|
const PAGE_MAX = 500;
|
|
|
|
/**
|
|
* Per entity: the FROM (always joined to its session as `s`), the selected
|
|
* columns (also the CSV header), the sortable ones mapped to SQL, the timestamp
|
|
* a date range bounds, the free-text columns, and whether it has a status.
|
|
*/
|
|
const ENTITIES = {
|
|
sessions: {
|
|
from: "sessions s",
|
|
columns: {
|
|
id: "s.id",
|
|
name: "s.name",
|
|
status: "s.status",
|
|
tool: "s.tool",
|
|
model: "s.model",
|
|
cwd: "s.cwd",
|
|
started_at: "s.started_at",
|
|
ended_at: "s.ended_at",
|
|
},
|
|
sort: ["started_at", "ended_at", "name", "status", "tool", "model", "cwd"],
|
|
time: "s.started_at",
|
|
text: ["s.id", "s.name", "s.cwd"],
|
|
status: "s.status",
|
|
statuses: ["active", "completed", "error", "abandoned"],
|
|
tiebreak: "s.id",
|
|
},
|
|
events: {
|
|
from: "events e JOIN sessions s ON s.id = e.session_id",
|
|
columns: {
|
|
id: "e.id",
|
|
session_id: "e.session_id",
|
|
agent_id: "e.agent_id",
|
|
event_type: "e.event_type",
|
|
tool_name: "e.tool_name",
|
|
summary: "e.summary",
|
|
tool: "s.tool",
|
|
cwd: "s.cwd",
|
|
created_at: "e.created_at",
|
|
},
|
|
sort: ["created_at", "event_type", "tool_name", "session_id", "tool"],
|
|
time: "e.created_at",
|
|
text: ["e.summary", "e.event_type", "e.tool_name", "e.session_id"],
|
|
status: null,
|
|
statuses: [],
|
|
tiebreak: "e.id",
|
|
},
|
|
agents: {
|
|
from: "agents a JOIN sessions s ON s.id = a.session_id",
|
|
columns: {
|
|
id: "a.id",
|
|
session_id: "a.session_id",
|
|
name: "a.name",
|
|
type: "a.type",
|
|
subagent_type: "a.subagent_type",
|
|
status: "a.status",
|
|
task: "a.task",
|
|
tool: "s.tool",
|
|
cwd: "s.cwd",
|
|
started_at: "a.started_at",
|
|
ended_at: "a.ended_at",
|
|
},
|
|
sort: ["started_at", "ended_at", "name", "type", "subagent_type", "status", "tool"],
|
|
time: "a.started_at",
|
|
text: ["a.id", "a.name", "a.task", "a.subagent_type"],
|
|
status: "a.status",
|
|
statuses: ["working", "waiting", "completed", "error"],
|
|
tiebreak: "a.id",
|
|
},
|
|
};
|
|
|
|
const likeEscape = (s) => s.replace(/[\\%_]/g, "\\$&");
|
|
/** Send a 400 and return null, for `parseQuery`'s early exits. */
|
|
function bad(res, message) {
|
|
res.status(400).json({ error: { code: "INVALID_INPUT", message } });
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Validate the request into `{spec, entity, where, params, order}`, or send
|
|
* the 400 and return null.
|
|
*/
|
|
function parseQuery(req, res) {
|
|
const q = req.query;
|
|
const entity = q.entity === undefined ? "sessions" : q.entity;
|
|
const spec = Object.prototype.hasOwnProperty.call(ENTITIES, entity) ? ENTITIES[entity] : null;
|
|
if (!spec) return bad(res, "entity must be sessions, events or agents");
|
|
if (parseTool(req) === false) return bad(res, BAD_TOOL.error.message);
|
|
const range = parseRange(req);
|
|
if (range === false) return bad(res, BAD_RANGE.error.message);
|
|
|
|
const where = [];
|
|
const params = [];
|
|
const scope = parseScope(req);
|
|
if (scope) {
|
|
where.push(`s.id IN (SELECT id FROM sessions WHERE ${scope.where})`);
|
|
params.push(...scope.params);
|
|
}
|
|
if (range?.start) {
|
|
where.push(`${spec.time} >= ?`);
|
|
params.push(range.start);
|
|
}
|
|
if (range?.end) {
|
|
where.push(`${spec.time} < ?`);
|
|
params.push(range.end);
|
|
}
|
|
const text = typeof q.q === "string" ? q.q.trim() : "";
|
|
if (text) {
|
|
const like = `%${likeEscape(text)}%`;
|
|
where.push(`(${spec.text.map((c) => `${c} LIKE ? ESCAPE '\\'`).join(" OR ")})`);
|
|
params.push(...spec.text.map(() => like));
|
|
}
|
|
if (q.status !== undefined && q.status !== "") {
|
|
if (!spec.statuses.includes(q.status)) {
|
|
return bad(res, `status must be one of: ${spec.statuses.join(", ") || "(none)"}`);
|
|
}
|
|
where.push(`${spec.status} = ?`);
|
|
params.push(q.status);
|
|
}
|
|
if (typeof q.cwd === "string" && q.cwd.trim()) {
|
|
// The folder itself or anything under it (path boundary, no wildcards).
|
|
const cwd = q.cwd.trim().replace(/\/+$/, "");
|
|
where.push("(s.cwd = ? OR s.cwd LIKE ? ESCAPE '\\')");
|
|
params.push(cwd, `${likeEscape(cwd)}/%`);
|
|
}
|
|
|
|
const sort = q.sort === undefined || q.sort === "" ? spec.sort[0] : q.sort;
|
|
if (!spec.sort.includes(sort)) return bad(res, `sort must be one of: ${spec.sort.join(", ")}`);
|
|
const dir = q.dir === undefined || q.dir === "" ? "desc" : q.dir;
|
|
if (dir !== "asc" && dir !== "desc") return bad(res, "dir must be asc or desc");
|
|
// Both pieces come from the allowlist above, never from the request text.
|
|
const sqlDir = dir === "asc" ? "ASC" : "DESC";
|
|
const order = `${spec.columns[sort]} ${sqlDir}, ${spec.tiebreak} ${sqlDir}`;
|
|
|
|
return {
|
|
spec,
|
|
entity,
|
|
where: where.length ? `WHERE ${where.join(" AND ")}` : "",
|
|
params,
|
|
order,
|
|
};
|
|
}
|
|
|
|
function select(p, limit, offset) {
|
|
const cols = Object.entries(p.spec.columns)
|
|
.map(([name, sql]) => `${sql} AS ${name}`)
|
|
.join(", ");
|
|
return db
|
|
.prepare(`SELECT ${cols} FROM ${p.spec.from} ${p.where} ORDER BY ${p.order} LIMIT ? OFFSET ?`)
|
|
.all(...p.params, limit, offset);
|
|
}
|
|
|
|
function count(p) {
|
|
return db.prepare(`SELECT COUNT(*) AS n FROM ${p.spec.from} ${p.where}`).get(...p.params).n;
|
|
}
|
|
|
|
/**
|
|
* One CSV cell (RFC 4180): quoted when it holds a quote, comma or line break.
|
|
* A text cell starting with = + - @ (or a tab / CR) gets a leading `'`, so a
|
|
* spreadsheet never runs it as a formula.
|
|
*/
|
|
function csvCell(v) {
|
|
if (v === null || v === undefined) return "";
|
|
let s = String(v);
|
|
if (typeof v === "string" && /^[=+\-@\t\r]/.test(s)) s = `'${s}`;
|
|
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
}
|
|
|
|
function toCsv(columns, rows) {
|
|
const lines = [columns.map(csvCell).join(",")];
|
|
for (const r of rows) lines.push(columns.map((c) => csvCell(r[c])).join(","));
|
|
return lines.join("\r\n") + "\r\n";
|
|
}
|
|
|
|
// GET /api/query — a page, or an export with `format=csv|json`.
|
|
router.get("/", (req, res) => {
|
|
const p = parseQuery(req, res);
|
|
if (!p) return;
|
|
const format = req.query.format;
|
|
const total = count(p);
|
|
|
|
if (format === "csv" || format === "json") {
|
|
const rows = select(p, EXPORT_CAP, 0);
|
|
const truncated = total > rows.length;
|
|
const name = `agenthub-${p.entity}-${new Date().toISOString().slice(0, 10)}.${format}`;
|
|
res.setHeader("Content-Disposition", `attachment; filename="${name}"`);
|
|
res.setHeader("X-Total-Count", String(total));
|
|
res.setHeader("X-Truncated", truncated ? "1" : "0");
|
|
if (format === "json") {
|
|
return res.json({ entity: p.entity, total, truncated, cap: EXPORT_CAP, rows });
|
|
}
|
|
res.type("text/csv; charset=utf-8");
|
|
// The BOM makes spreadsheet apps read the file as UTF-8.
|
|
return res.send("\uFEFF" + toCsv(Object.keys(p.spec.columns), rows));
|
|
}
|
|
if (format !== undefined && format !== "") return bad(res, "format must be csv or json");
|
|
|
|
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), PAGE_MAX);
|
|
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
|
|
res.json({
|
|
entity: p.entity,
|
|
columns: Object.keys(p.spec.columns),
|
|
sortable: p.spec.sort,
|
|
statuses: p.spec.statuses,
|
|
rows: select(p, limit, offset),
|
|
total,
|
|
limit,
|
|
offset,
|
|
export_cap: EXPORT_CAP,
|
|
});
|
|
});
|
|
|
|
module.exports = router;
|
|
module.exports.csvCell = csvCell;
|
|
module.exports.EXPORT_CAP = EXPORT_CAP;
|