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.
This commit is contained in:
2026-10-06 17:12:28 +07:00
parent 837da6e5de
commit 4aae61297f
146 changed files with 13565 additions and 537 deletions
+30 -1
View File
@@ -9,7 +9,12 @@ const { Router } = require("express");
const { v4: uuidv4 } = require("uuid");
const { stmts } = require("../db");
const { broadcast } = require("../websocket");
const { RULE_TYPES, validateRuleConfig, invalidateRuleCache } = require("../lib/alerts");
const {
RULE_TYPES,
validateRuleConfig,
invalidateRuleCache,
previewRule,
} = require("../lib/alerts");
const router = Router();
@@ -61,6 +66,30 @@ router.post("/rules", (req, res) => {
res.status(201).json({ rule: serializeRule(stmts.getAlertRule.get(id)) });
});
// POST /api/alerts/rules/preview - What a rule WOULD have fired over the last
// `days` (1–90, default 7) of recorded history. Read-only: nothing is saved,
// fired or broadcast.
router.post("/rules/preview", (req, res) => {
const { rule_type, config, days, cooldown_seconds } = req.body || {};
const validated = validateRuleConfig(rule_type, config);
if (!validated.ok) {
return res.status(400).json({ error: { code: "INVALID_INPUT", message: validated.error } });
}
const window = days == null ? 7 : Number(days);
if (!Number.isInteger(window) || window < 1 || window > 90) {
return res.status(400).json({
error: { code: "INVALID_INPUT", message: "days must be an integer in 1..90" },
});
}
const cooldown = cooldown_seconds == null ? 300 : cooldown_seconds;
if (!Number.isInteger(cooldown) || cooldown < 0) {
return res.status(400).json({
error: { code: "INVALID_INPUT", message: "cooldown_seconds must be a non-negative integer" },
});
}
res.json(previewRule(rule_type, validated.config, { days: window, cooldownSeconds: cooldown }));
});
// PATCH /api/alerts/rules/:id - Update an alert rule (partial)
router.patch("/rules/:id", (req, res) => {
const existing = stmts.getAlertRule.get(req.params.id);
+79 -53
View File
@@ -1,77 +1,88 @@
/**
* @file Express router for analytics endpoints, providing aggregated statistics on token usage, tool usage, daily events/sessions, agent types, and more. It queries the database for various metrics and returns them in a structured JSON format for frontend consumption.
* @file Express router for analytics endpoints, providing aggregated statistics on token usage, tool usage, daily events/sessions, agent types, and more, narrowed by data scope, AI tool and an optional `from`/`to` date range. `computeAnalytics` is the same bundle for scheduled reports.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const { stmts, db } = require("../db");
const { parseSources } = require("../lib/source-filter");
const {
parseScope,
parseTool,
BAD_TOOL,
parseRange,
inRange,
BAD_RANGE,
} = require("../lib/source-filter");
const scoped = require("../lib/scoped-stats");
const { calculateCost } = require("./pricing");
const { calculateCost, dailyTokenRows, costWhere } = require("./pricing");
const router = Router();
router.get("/", (req, res) => {
// Client sends tz_offset (minutes from getTimezoneOffset(), e.g. 420 for PDT)
// Negate it to get the SQLite modifier: 420 → '-420 minutes'
const rawOffset = parseInt(req.query.tz_offset, 10);
const tzModifier = Number.isFinite(rawOffset) ? `${-rawOffset} minutes` : "+0 minutes";
// Every session, for the scoped path when only a date range narrows.
const ALL = { where: "1=1", params: [] };
const USAGE_TOKEN_COLS = {
total_input: "input_tokens",
total_output: "output_tokens",
total_cache_read: "cache_read_tokens",
total_cache_write: "cache_write_tokens",
};
// Data-scope: restrict every metric to a subset of source machines when the
// user has chosen one; otherwise use the cached prepared statements.
const sources = parseSources(req);
const tokenTotals = sources ? scoped.tokenTotals(db, sources) : stmts.getTokenTotals.get();
const toolUsage = sources ? scoped.toolUsageCounts(db, sources) : stmts.toolUsageCounts.all();
const dailyEvents = sources
? scoped.dailyEventCounts(db, sources, tzModifier)
/**
* The analytics bundle for a data scope (`parseScope`'s predicate or null) and
* a date range (`parseRange`'s result or null). Also used by scheduled reports.
* With a range, counts follow it and tokens/cost come from the cost rows of the
* days in it (`token_usage` has no date); the daily charts keep 365 days.
*/
function computeAnalytics({ scope = null, range = null, tzModifier = "+0 minutes" } = {}) {
const sc = scope || (range ? ALL : null);
const toolUsage = sc ? scoped.toolUsageCounts(db, sc, range) : stmts.toolUsageCounts.all();
const dailyEvents = sc
? scoped.dailyEventCounts(db, sc, tzModifier)
: stmts.dailyEventCounts.all(tzModifier);
const dailySessions = sources
? scoped.dailySessionCounts(db, sources, tzModifier)
const dailySessions = sc
? scoped.dailySessionCounts(db, sc, tzModifier)
: stmts.dailySessionCounts.all(tzModifier);
const agentTypes = sources
? scoped.agentTypeDistribution(db, sources)
const agentTypes = sc
? scoped.agentTypeDistribution(db, sc, range)
: stmts.agentTypeDistribution.all();
const overview = sources ? scoped.statsOverview(db, sources) : stmts.stats.get();
const agentsByStatus = sources
? scoped.agentStatusCounts(db, sources)
const overview = sc ? scoped.statsOverview(db, sc, range) : stmts.stats.get();
const agentsByStatus = sc
? scoped.agentStatusCounts(db, sc, range)
: stmts.agentStatusCounts.all();
const sessionsByStatus = sources
? scoped.sessionStatusCounts(db, sources)
const sessionsByStatus = sc
? scoped.sessionStatusCounts(db, sc, range)
: stmts.sessionStatusCounts.all();
const totalSubagents = sources
? scoped.totalSubagentCount(db, sources)
const totalSubagents = sc
? scoped.totalSubagentCount(db, sc, range)
: stmts.totalSubagentCount.get();
const eventTypes = sources ? scoped.eventTypeCounts(db, sources) : stmts.eventTypeCounts.all();
const avgEvents = sources
? scoped.avgEventsPerSession(db, sources)
const eventTypes = sc ? scoped.eventTypeCounts(db, sc, range) : stmts.eventTypeCounts.all();
const avgEvents = sc
? scoped.avgEventsPerSession(db, sc, range)
: stmts.avgEventsPerSession.get();
// Calculate total cost across all sessions
const pricingRules = stmts.listPricing.all();
// Join the owning session's start date so each bucket is priced at the rate
// effective when it was used (date-effective promo rates, e.g. Sonnet 5 intro).
const allTokenUsage = sources
? scoped.scopedTokenUsageWithDate(db, sources)
: db
.prepare(
"SELECT tu.*, DATE(s.started_at) as date FROM token_usage tu JOIN sessions s ON s.id = tu.session_id"
)
.all();
let totalCost = 0;
for (const usage of allTokenUsage) {
const { total_cost } = calculateCost([usage], pricingRules);
totalCost += total_cost;
// Total cost exactly as GET /api/pricing/cost computes it (baselines, the
// per-day rate, the same scope and range), so the two numbers always agree.
// A session that started after the range cannot have spent in it.
const extra = range?.end ? [{ clause: "s.started_at < ?", params: [range.end] }] : [];
const where = costWhere({ scope, extra });
const rows = dailyTokenRows(tzModifier, where.whereClause, where.params).filter((r) =>
inRange(range, r.date)
);
const totalCost = calculateCost(rows, stmts.listPricing.all()).total_cost;
let tokens;
if (range) {
tokens = {};
for (const [k, c] of Object.entries(USAGE_TOKEN_COLS)) {
tokens[k] = Math.round(rows.reduce((n, r) => n + r[c], 0));
}
} else {
const t = scope ? scoped.tokenTotals(db, scope) : stmts.getTokenTotals.get();
tokens = Object.fromEntries(Object.keys(USAGE_TOKEN_COLS).map((k) => [k, t?.[k] ?? 0]));
}
res.json({
tokens: {
total_input: tokenTotals?.total_input ?? 0,
total_output: tokenTotals?.total_output ?? 0,
total_cache_read: tokenTotals?.total_cache_read ?? 0,
total_cache_write: tokenTotals?.total_cache_write ?? 0,
},
return {
tokens,
total_cost: totalCost,
tool_usage: toolUsage,
daily_events: dailyEvents,
@@ -83,7 +94,22 @@ router.get("/", (req, res) => {
overview,
agents_by_status: Object.fromEntries(agentsByStatus.map((r) => [r.status, r.count])),
sessions_by_status: Object.fromEntries(sessionsByStatus.map((r) => [r.status, r.count])),
});
};
}
router.get("/", (req, res) => {
// Client sends tz_offset (minutes from getTimezoneOffset(), e.g. 420 for PDT)
// Negate it to get the SQLite modifier: 420 → '-420 minutes'
const rawOffset = parseInt(req.query.tz_offset, 10);
const tzModifier = Number.isFinite(rawOffset) ? `${-rawOffset} minutes` : "+0 minutes";
// Data-scope: restrict every metric to a subset of source machines and/or one
// AI tool when the user has chosen one, and to `?from=&to=` (local dates).
if (parseTool(req) === false) return res.status(400).json(BAD_TOOL);
const range = parseRange(req);
if (range === false) return res.status(400).json(BAD_RANGE);
res.json(computeAnalytics({ scope: parseScope(req), range, tzModifier }));
});
module.exports = router;
module.exports.computeAnalytics = computeAnalytics;
+20
View File
@@ -0,0 +1,20 @@
/**
* @file Express router for spend budgets. A budget is an alert rule of type
* `budget` (created, edited and deleted through /api/alerts/rules); this router
* only reads: `GET /api/budgets/status` lists every enabled budget with its
* current-period spend, percent of the limit, projected end-of-period spend and
* the thresholds reached (server/lib/budgets.js).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const { budgetStatus } = require("../lib/budgets");
const router = Router();
// GET /api/budgets/status
router.get("/status", (_req, res) => {
res.json({ budgets: budgetStatus() });
});
module.exports = router;
+142 -17
View File
@@ -1,5 +1,5 @@
/**
* @file Express router for handling incoming hook events from Claude CLI — and from Codex (`tool: "codex"`, sent through server/lib/codex-hooks.js: such a session is created as a Codex one, its rollout is never parsed as a Claude transcript, a PermissionRequest becomes the permission Notification, and a sub-agent thread's hooks land on the root session). It processes various hook types (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, Notification), updates session and agent states accordingly in the database, extracts token usage from transcripts, detects compaction events, and broadcasts updates to connected clients via WebSocket.
* @file Express router for handling incoming hook events from Claude CLI — and from Codex (`tool: "codex"`, sent through server/lib/codex-hooks.js: such a session is created as a Codex one, its rollout is never parsed as a Claude transcript, a PermissionRequest becomes the permission Notification, and a sub-agent thread's hooks land on the root session). It processes various hook types (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, Notification), updates session and agent states accordingly in the database, extracts token usage from transcripts, detects compaction events, and broadcasts updates to connected clients via WebSocket. `POST /ingest-batch` takes the same hooks pushed from another machine behind a bearer token (server/lib/push-token.js), tagged with that machine as the session source; a hook from a folder the user ignores is dropped whole (server/lib/privacy.js).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -24,6 +24,8 @@ const { getPipeline } = require("../lib/pipelines");
const path = require("path");
const { threadOf } = require("../lib/codex-usage");
const { agyCliDirs } = require("../lib/antigravity-home");
const { isIgnoredCwd } = require("../lib/privacy");
const { checkPushToken, pushTokenStatus } = require("../lib/push-token");
const router = Router();
@@ -1335,23 +1337,60 @@ function bindRunSession(runId, sessionId, hookType, source, tool) {
if (row.lane_id != null) stmts.claimSessionLane.run(row.lane_id, sessionId);
}
router.post("/event", (req, res) => {
let { hook_type, data } = req.body;
// A pushed hook's transcript paths name files on the SENDER's machine; read
// here they would be this machine's files, so they never get that far.
const PUSH_STRIPPED = ["transcript_path", "agent_transcript_path", "transcriptPath"];
/**
* One hook through ingestion, shared by `POST /event` (this machine's CLIs)
* and `POST /ingest-batch` (another machine's, `pushSource` = its
* `sessions.source` tag). Answers `{status, body}` for the response, plus the
* stored event and the (mapped) hook when one was stored. A pushed hook never
* touches a session another source owns, a lane or a dashboard pane.
*/
function ingestHook(body, pushSource = null) {
let { hook_type, data } = body || {};
if (!hook_type || !data) {
return res.status(400).json({
error: { code: "INVALID_INPUT", message: "hook_type and data are required" },
});
return {
status: 400,
body: { error: { code: "INVALID_INPUT", message: "hook_type and data are required" } },
};
}
if (pushSource) {
data = { ...data };
for (const k of PUSH_STRIPPED) delete data[k];
}
const ignored = { status: 200, body: { ok: true, ignored: true } };
// Which CLI sent it: `codex` from Codex's hooks (codex-hooks.js),
// `antigravity` from agy's (antigravity-hooks.js), anything else is Claude
// Code — an allowlist, never a value stored as sent.
const tool = Object.hasOwn(HOOK_TOOLS, req.body.tool) ? req.body.tool : "claude";
const tool = Object.hasOwn(HOOK_TOOLS, body.tool) ? body.tool : "claude";
if (tool !== "claude") {
if (!data || typeof data !== "object") return res.json({ ok: true, ignored: true });
if (!data || typeof data !== "object") return ignored;
const mapped = HOOK_TOOLS[tool](hook_type, data);
if (!mapped) return res.json({ ok: true, ignored: true });
if (!mapped) return ignored;
({ hookType: hook_type, data } = mapped);
}
// A folder the user asked AgentHub to ignore (Settings → Privacy): dropped
// whole, before anything is stored.
if (isIgnoredCwd(data.cwd)) return ignored;
// A pushed session id is the sender's to choose: it may only extend a
// session its own source created, never one of this machine's or another's.
let pushedNew = false;
if (pushSource) {
const owner =
typeof data.session_id === "string" && data.session_id
? stmts.getSession.get(data.session_id)
: null;
if (owner && owner.source !== pushSource) {
return {
status: 409,
body: { error: { code: "EFOREIGN", message: "session belongs to another source" } },
};
}
pushedNew = !owner;
}
// The same hook delivered twice — AgentHub's hooks registered twice (plugin
// + a leftover checkout install, two marketplaces), or two servers on one DB
@@ -1364,7 +1403,7 @@ router.post("/event", (req, res) => {
data.session_id &&
stmts.findRecentDuplicateEvent.get(data.session_id, hook_type, JSON.stringify(data))
) {
return res.json({ ok: true, duplicate: true });
return { status: 200, body: { ok: true, duplicate: true } };
}
// Parse the transcript BEFORE the write transaction: `processEvent` takes the
@@ -1377,16 +1416,32 @@ router.post("/event", (req, res) => {
const result = processEvent(hook_type, data, tool);
if (!result) {
return res.status(400).json({
error: { code: "MISSING_SESSION", message: "session_id is required in data" },
});
return {
status: 400,
body: { error: { code: "MISSING_SESSION", message: "session_id is required in data" } },
};
}
// ponytail: full lane scan per hook — fine at dozens of lanes; index by cwd prefix if lane counts grow.
touchLaneFromHook(hook_type, data, tool);
bindRunSession(req.body.run_id, data.session_id, hook_type, data.source, tool);
if (pushSource) {
// Tagged in the same tick processEvent created it, so no local liveness
// sweep ever sees it as this machine's.
if (pushedNew) {
stmts.setSessionSource.run(pushSource, data.session_id);
broadcast("session_updated", stmts.getSession.get(data.session_id));
}
} else {
// ponytail: full lane scan per hook — fine at dozens of lanes; index by cwd prefix if lane counts grow.
touchLaneFromHook(hook_type, data, tool);
bindRunSession(body.run_id, data.session_id, hook_type, data.source, tool);
}
return { status: 200, body: { ok: true, event: result }, result, hook_type, data, tool };
}
res.json({ ok: true, event: result });
router.post("/event", (req, res) => {
const out = ingestHook(req.body);
res.status(out.status).json(out.body);
if (!out.result) return;
const { result, hook_type, data, tool } = out;
// Evaluate event-driven alert rules after the ingest transaction committed
// and the response is on its way — alerting must never slow down or fail
@@ -1483,6 +1538,76 @@ router.post("/event", (req, res) => {
}
});
// ── POST /api/hooks/ingest-batch ────────────────────────────────────────────
// Hooks from a machine this dashboard cannot reach over SSH, pushed with the
// bearer token Settings → Data → Push ingest makes (server/lib/push-token.js).
// Off — 503 — until a token exists. Body: {source, events: [{hook_type, data,
// tool?}]}; each event goes through ingestHook exactly as POST /event would,
// with its session tagged `push:<source>`. Under /hooks, so DASHBOARD_TOKEN
// does not gate it: this token does. The global 1 MB JSON limit bounds a batch.
const MAX_PUSH_EVENTS = 500;
const PUSH_SOURCE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
const HOOK_TYPE_RE = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
router.post("/ingest-batch", (req, res) => {
const auth = req.headers.authorization;
const given = typeof auth === "string" && auth.startsWith("Bearer ") ? auth.slice(7) : null;
if (!checkPushToken(given)) {
if (!pushTokenStatus().enabled) {
return res.status(503).json({
error: { code: "EPUSHDISABLED", message: "push ingest is off: create a token in Settings" },
});
}
return res
.status(401)
.json({ error: { code: "EUNAUTHORIZED", message: "missing or invalid push token" } });
}
const { source, events } = req.body || {};
if (typeof source !== "string" || !PUSH_SOURCE_RE.test(source)) {
return res.status(400).json({
error: { code: "EBADSOURCE", message: "source must be 1-64 letters, digits, . _ -" },
});
}
if (!Array.isArray(events) || events.length === 0 || events.length > MAX_PUSH_EVENTS) {
return res.status(400).json({
error: { code: "EBADBATCH", message: `events must be 1-${MAX_PUSH_EVENTS} hook payloads` },
});
}
const tag = `push:${source}`;
const stored = [];
const results = events.map((ev) => {
if (
!ev ||
typeof ev.hook_type !== "string" ||
!HOOK_TYPE_RE.test(ev.hook_type) ||
!ev.data ||
typeof ev.data !== "object" ||
Array.isArray(ev.data)
) {
return { ok: false, error: "INVALID_INPUT" };
}
try {
const out = ingestHook({ hook_type: ev.hook_type, data: ev.data, tool: ev.tool }, tag);
if (out.result) {
stored.push(out.result);
return { ok: true };
}
return out.status === 200 ? out.body : { ok: false, error: out.body.error.code };
} catch (err) {
console.warn("[hooks] ingest-batch event failed:", err.message);
return { ok: false, error: "EINGEST" };
}
});
res.json({ ok: true, source: tag, stored: stored.length, results });
for (const result of stored) {
try {
evaluateEvent(result);
} catch {
/* non-fatal, as on POST /event */
}
}
});
// ── Watchdog: detect API errors in active sessions ─────────────────────────
// Claude CLI doesn't fire a hook after API errors (401, rate limit, etc.) —
// the session just sits there with the error in the transcript but no Stop
+29
View File
@@ -25,6 +25,7 @@
const { Router } = require("express");
const { stmts, db } = require("../db");
const { getConnectionCount } = require("../websocket");
const { calculateCost, dailyTokenRows } = require("./pricing");
const router = Router();
@@ -192,6 +193,34 @@ router.get("/", (_req, res) => {
]
);
// Cumulative cost in USD by AI tool and model, priced exactly as
// GET /api/pricing/cost prices it (the per-day rate). The code-execution
// container time, billed once per org, has no model and is not included.
const rules = stmts.listPricing.all();
const rowsByTool = new Map();
for (const r of dailyTokenRows("+0 minutes", "", [])) {
const tool = r.tool || "claude";
if (!rowsByTool.has(tool)) rowsByTool.set(tool, []);
rowsByTool.get(tool).push(r);
}
const costSamples = [];
for (const [tool, rows] of rowsByTool) {
const byModel = new Map();
for (const b of calculateCost(rows, rules).breakdown) {
byModel.set(b.model, (byModel.get(b.model) || 0) + b.cost);
}
for (const [model, value] of byModel) {
costSamples.push({ labels: { tool, model }, value: Math.round(value * 10000) / 10000 });
}
}
appendMetric(
out,
"agenthub_cost_usd_total",
"Cumulative token cost in USD across all sessions, by AI tool and model.",
"counter",
costSamples
);
res.set("Content-Type", "text/plain; version=0.0.4; charset=utf-8");
res.send(out.join("\n") + "\n");
});
+116 -40
View File
@@ -5,7 +5,14 @@
const { Router } = require("express");
const { stmts, db } = require("../db");
const { parseSources, sourceColumnClause } = require("../lib/source-filter");
const {
parseScope,
parseTool,
BAD_TOOL,
parseRange,
inRange,
BAD_RANGE,
} = require("../lib/source-filter");
const { quotas } = require("../lib/quota");
const {
WEB_SEARCH_PER_1K_SEARCHES,
@@ -26,7 +33,7 @@ const round4 = (n) => Math.round(n * 10000) / 10000;
* - Fast mode: premium input/output rates; cache rates scale with the fast
* input base (the standard caching multipliers ride on top of fast pricing).
* - Data residency "us": 1.1x across every category.
* - Batch tier: 50% off across every category.
* - Batch tier (and OpenAI's flex tier): 50% off across every category.
* Older buckets default to speed=standard / geo=global / tier=standard, so they
* resolve to exactly the standard rates — historical sessions price unchanged.
*/
@@ -65,7 +72,7 @@ function ratesForBucket(rule, row, asOf) {
r5m *= m;
r1h *= m;
}
if (row.service_tier === "batch") {
if (row.service_tier === "batch" || row.service_tier === "flex") {
const m = BATCH_DISCOUNT_MULTIPLIER;
rIn *= m;
rOut *= m;
@@ -241,12 +248,13 @@ const BUCKET_COLS = ["model", "speed", "inference_geo", "service_tier"];
* rows fall on; whatever the total holds beyond them (sessions recorded before
* that table, sub-agent files, Codex/Antigravity) goes to the session's start
* date, as it always did. Hours above the total (never expected) are scaled
* down to it, so the days always add up to the total.
* down to it, so the days always add up to the total. Each row also carries
* its session's `tool`, for the per-tool split.
*/
function dailyTokenRows(tzModifier, whereClause, params) {
const totals = db
.prepare(
`SELECT tu.session_id, DATE(s.started_at, ?) as date,
`SELECT tu.session_id, DATE(s.started_at, ?) as date, s.tool,
${BUCKET_COLS.map((c) => `tu.${c}`).join(", ")},
tu.input_tokens + tu.baseline_input as input_tokens,
tu.output_tokens + tu.baseline_output as output_tokens,
@@ -283,10 +291,14 @@ function dailyTokenRows(tzModifier, whereClause, params) {
const out = new Map();
const add = (date, bucket, values) => {
const k = [date, ...BUCKET_COLS.map((c) => bucket[c])].join("\u0001");
const k = [date, bucket.tool, ...BUCKET_COLS.map((c) => bucket[c])].join("\u0001");
let row = out.get(k);
if (!row) {
row = { date, ...Object.fromEntries(BUCKET_COLS.map((c) => [c, bucket[c]])) };
row = {
date,
tool: bucket.tool,
...Object.fromEntries(BUCKET_COLS.map((c) => [c, bucket[c]])),
};
for (const c of USAGE_COLS) row[c] = 0;
out.set(k, row);
}
@@ -464,49 +476,110 @@ router.delete("/:pattern", (req, res) => {
res.json({ ok: true });
});
// GET /api/pricing/cost - Get total cost across all sessions (`?lane=<id>`: the
// sessions that workspace owns).
// Honors the global data-scope: `?sources=<csv>` narrows the aggregate to those
// origin machines (see server/lib/source-filter.js), matching the sessions /
// stats / analytics endpoints so the Dashboard "total cost" tracks the selector.
/**
* The `WHERE` over `token_usage tu JOIN sessions s` for a session predicate
* (`parseScope`'s `{where, params}`, or null), a workspace id (the sessions it
* owns — the rule of GET /api/sessions?lane=) and `extra` clauses on `s`.
*/
function costWhere({ scope = null, lane, extra = [] } = {}) {
const params = scope ? [...scope.params] : [];
const where = scope ? [`s.id IN (SELECT id FROM sessions WHERE ${scope.where})`] : [];
if (lane !== undefined && lane !== null) {
where.push(
"(s.lane_id = ? OR (s.lane_id IS NULL AND s.id IN (SELECT session_id FROM dashboard_runs WHERE lane_id = ?)))"
);
params.push(Number(lane), Number(lane));
}
for (const e of extra) {
where.push(e.clause);
params.push(...e.params);
}
return { whereClause: where.length ? `WHERE ${where.join(" AND ")}` : "", params };
}
/**
* `costWhere` for a cost request (`?sources=`, `?tool=`, `?lane=`). Null when
* the request is malformed (the 400 has already been sent).
*/
function costScope(req, res) {
const lane = req.query.lane;
if (lane !== undefined && !/^\d+$/.test(String(lane))) {
res.status(400).json({ error: { code: "INVALID_INPUT", message: "lane must be a lane id" } });
return null;
}
if (parseTool(req) === false) {
res.status(400).json(BAD_TOOL);
return null;
}
return costWhere({ scope: parseScope(req), lane });
}
/**
* Cost per AI tool (`sessions.tool`), most expensive first. Each tool is priced
* on its own rows, so the code-execution free allowance applies per tool and
* the parts can sum to slightly less than `total_cost` when it is exceeded.
*/
function costByTool(dailyTokens, rules) {
const byTool = new Map();
for (const r of dailyTokens) {
const t = r.tool || "claude";
if (!byTool.has(t)) byTool.set(t, []);
byTool.get(t).push(r);
}
return [...byTool.entries()]
.map(([tool, rows]) => ({ tool, cost: calculateCost(rows, rules).total_cost }))
.sort((a, b) => b.cost - a.cost);
}
/**
* The cost response for a `costWhere` result and a date range (`parseRange`,
* or null). The range narrows the total, breakdown and `by_tool` to the local
* days in it; `daily_costs` stays the whole series, so the trend charts and
* the spend pace keep their own windows. Also used by scheduled reports.
*/
function computeCost(
{ whereClause = "", params = [] } = {},
range = null,
tzModifier = "+0 minutes"
) {
const dailyTokens = dailyTokenRows(tzModifier, whereClause, params);
const inside = range ? dailyTokens.filter((r) => inRange(range, r.date)) : dailyTokens;
const rules = stmts.listPricing.all();
// Price the date-split rows so each day's usage bills at the rate effective on
// that date (e.g. Sonnet 5's intro discount before 2026-08-31, standard after).
// Coverage equals the undated aggregate — token_usage cascades with sessions,
// so the INNER JOIN drops nothing — and the breakdown re-collapses per model.
const result = calculateCost(inside, rules);
const daily_costs = calculateDailyCosts(dailyTokens, rules);
return { ...result, daily_costs, by_tool: costByTool(inside, rules) };
}
// GET /api/pricing/cost - Get total cost across all sessions.
// Honors the global data-scope — `?sources=<csv>` (origin machines, see
// server/lib/source-filter.js) and `?tool=<id>` — matching the sessions / stats
// / analytics endpoints so the Dashboard "total cost" tracks the selector, plus
// `?lane=<id>` (the sessions that workspace owns), `?from=&to=` (local
// dates, see computeCost) and `?date=` (one local day, its `daily_costs` too —
// the status bar's Today). `by_tool` splits the total.
router.get("/cost", (req, res) => {
const rawOffset = parseInt(req.query.tz_offset, 10);
const tzModifier = Number.isFinite(rawOffset) ? `${-rawOffset} minutes` : "+0 minutes";
const lane = req.query.lane;
if (lane !== undefined && !/^\d+$/.test(String(lane))) {
return res
.status(400)
.json({ error: { code: "INVALID_INPUT", message: "lane must be a lane id" } });
}
const date = req.query.date;
if (date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(date))) {
return res
.status(400)
.json({ error: { code: "INVALID_INPUT", message: "date must be YYYY-MM-DD" } });
}
const scope = sourceColumnClause(parseSources(req), "s.source");
const params = [...scope.params];
const where = scope.clause ? [scope.clause] : [];
if (lane !== undefined) {
// The sessions the workspace owns — the same rule as GET /api/sessions?lane=.
where.push(
"(s.lane_id = ? OR (s.lane_id IS NULL AND s.id IN (SELECT session_id FROM dashboard_runs WHERE lane_id = ?)))"
);
params.push(Number(lane), Number(lane));
}
const whereClause = where.length ? `WHERE ${where.join(" AND ")}` : "";
let dailyTokens = dailyTokenRows(tzModifier, whereClause, params);
const range = parseRange(req);
if (range === false) return res.status(400).json(BAD_RANGE);
const scope = costScope(req, res);
if (!scope) return;
// `date=`: only that local day (the status bar's Today and its per-model list).
if (date !== undefined) dailyTokens = dailyTokens.filter((r) => r.date === date);
const rules = stmts.listPricing.all();
// Price the date-split rows so each day's usage bills at the rate effective on
// that date (e.g. Sonnet 5's intro discount before 2026-08-31, standard after).
// Coverage equals the undated aggregate — token_usage cascades with sessions,
// so the INNER JOIN drops nothing — and the breakdown re-collapses per model.
const result = calculateCost(dailyTokens, rules);
const daily_costs = calculateDailyCosts(dailyTokens, rules);
res.json({ ...result, daily_costs });
if (date !== undefined) {
const day = computeCost(scope, { from: date, to: date }, tzModifier);
return res.json({ ...day, daily_costs: day.daily_costs.filter((d) => d.date === date) });
}
res.json(computeCost(scope, range, tzModifier));
});
// GET /api/pricing/quota - plan quota spent per native tool (Claude, Codex):
@@ -568,5 +641,8 @@ function attachAgentCosts(agents) {
module.exports = router;
module.exports.calculateCost = calculateCost;
module.exports.dailyTokenRows = dailyTokenRows;
module.exports.costWhere = costWhere;
module.exports.computeCost = computeCost;
module.exports.agentOwnCost = agentOwnCost;
module.exports.attachAgentCosts = attachAgentCosts;
+232
View File
@@ -0,0 +1,232 @@
/**
* @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;
+13 -2
View File
@@ -11,6 +11,9 @@
* POST /api/remote-sources/:id/test — probe SSH connectivity
* POST /api/remote-sources/:id/sync — sync now
*
* A source may also pull Codex history (`sync_codex`, opt-in; from
* `remote_codex_home`, default ~/.codex) — see syncCodex in remote-sync.js.
*
* No secrets are stored or accepted here — authentication defers entirely to the
* host's SSH stack. All inputs are validated in remote-sync.validateSourceInput
* before touching the DB or any command.
@@ -31,6 +34,7 @@ const {
syncSource,
syncAllEnabled,
stagingDir,
codexStagingDir,
} = require("../lib/remote-sync");
const router = Router();
@@ -54,6 +58,8 @@ function serialize(row, sessionCount = 0) {
ssh_port: row.ssh_port,
identity_file: row.identity_file,
remote_home: row.remote_home,
sync_codex: !!row.sync_codex,
remote_codex_home: row.remote_codex_home ?? null,
enabled: !!row.enabled,
status: row.status,
last_error: row.last_error,
@@ -106,7 +112,9 @@ router.post("/", (req, res) => {
v.sshPort ?? null,
v.identityFile ?? null,
v.remoteHome ?? null,
enabled
enabled,
v.syncCodex ?? 0,
v.remoteCodexHome ?? null
);
const row = stmts.getRemoteSource.get(id);
broadcast("remote_source.status", { id, status: row.status });
@@ -147,6 +155,8 @@ router.patch("/:id", (req, res) => {
v.identityFile !== undefined ? v.identityFile : existing.identity_file,
v.remoteHome !== undefined ? v.remoteHome : existing.remote_home,
v.enabled === undefined ? null : v.enabled,
v.syncCodex === undefined ? null : v.syncCodex,
v.remoteCodexHome !== undefined ? v.remoteCodexHome : existing.remote_codex_home,
req.params.id
);
const row = stmts.getRemoteSource.get(req.params.id);
@@ -176,9 +186,10 @@ router.delete("/:id", (req, res) => {
db.prepare("UPDATE sessions SET source = 'local' WHERE source = ?").run(req.params.id);
}
stmts.deleteRemoteSource.run(req.params.id);
// Reclaim the mirrored staging dir.
// Reclaim the mirrored staging dirs (Claude projects + Codex sessions).
try {
fs.rmSync(stagingDir(req.params.id), { recursive: true, force: true });
fs.rmSync(codexStagingDir(req.params.id), { recursive: true, force: true });
} catch {
/* non-fatal */
}
+131
View File
@@ -0,0 +1,131 @@
/**
* @file Express router for scheduled reports (`server/lib/reports.js`): CRUD of
* report definitions, `POST /:id/run` to run one now, the run log, and the
* download of a run's file — whose path is built from the report and run ids
* only, never from the request. Every write is same-origin only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const { Router } = require("express");
const { db } = require("../db");
const { sameOriginGuard } = require("../lib/same-origin");
const reports = require("../lib/reports");
const router = Router();
const ID_RE = /^\d+$/;
const bad = (res, message) => res.status(400).json({ error: { code: "INVALID_INPUT", message } });
const notFound = (res) =>
res.status(404).json({ error: { code: "NOT_FOUND", message: "Report not found" } });
const getRow = (id) =>
ID_RE.test(String(id))
? db.prepare("SELECT * FROM report_definitions WHERE id = ?").get(Number(id))
: undefined;
const lastRun = db.prepare(
"SELECT * FROM report_runs WHERE report_id = ? ORDER BY id DESC LIMIT 1"
);
// The slot a definition is due at now counts as claimed, so creating, editing
// or re-enabling a report never runs it for an hour already past.
const claimedSlot = (v) => new Date(reports.lastSlot(v, Date.now())).toISOString();
// GET /api/reports — every definition with its latest run.
router.get("/", (_req, res) => {
const rows = db.prepare("SELECT * FROM report_definitions ORDER BY id").all();
res.json({
reports: rows.map((r) => {
const run = lastRun.get(r.id);
return { ...reports.view(r), last_run: run ? reports.viewRun(run) : null };
}),
});
});
// POST /api/reports — create.
router.post("/", sameOriginGuard, (req, res) => {
const { value, error } = reports.validate(req.body || {});
if (error) return bad(res, error);
const info = db
.prepare(
`INSERT INTO report_definitions
(name, window_days, frequency, hour, weekday, tz_offset, formats, enabled, last_slot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
value.name,
value.window_days,
value.frequency,
value.hour,
value.weekday,
value.tz_offset,
JSON.stringify(value.formats),
value.enabled ? 1 : 0,
claimedSlot(value)
);
res.status(201).json({ report: reports.view(getRow(info.lastInsertRowid)) });
});
// PUT /api/reports/:id — update; omitted fields keep their value.
router.put("/:id", sameOriginGuard, (req, res) => {
const row = getRow(req.params.id);
if (!row) return notFound(res);
const { value, error } = reports.validate({ ...reports.view(row), ...(req.body || {}) });
if (error) return bad(res, error);
db.prepare(
`UPDATE report_definitions SET name = ?, window_days = ?, frequency = ?, hour = ?,
weekday = ?, tz_offset = ?, formats = ?, enabled = ?, last_slot = ? WHERE id = ?`
).run(
value.name,
value.window_days,
value.frequency,
value.hour,
value.weekday,
value.tz_offset,
JSON.stringify(value.formats),
value.enabled ? 1 : 0,
claimedSlot(value),
row.id
);
res.json({ report: reports.view(getRow(row.id)) });
});
// DELETE /api/reports/:id — the definition, its runs and their files.
router.delete("/:id", sameOriginGuard, (req, res) => {
const row = getRow(req.params.id);
if (!row) return notFound(res);
db.prepare("DELETE FROM report_definitions WHERE id = ?").run(row.id);
fs.rmSync(reports.reportDir(row.id), { recursive: true, force: true });
res.json({ ok: true });
});
// POST /api/reports/:id/run — run now (also when disabled).
router.post("/:id/run", sameOriginGuard, (req, res) => {
const row = getRow(req.params.id);
if (!row) return notFound(res);
res.json({ run: reports.runReport(reports.view(row), "manual") });
});
// GET /api/reports/:id/runs — the run log, newest first (last 100).
router.get("/:id/runs", (req, res) => {
const row = getRow(req.params.id);
if (!row) return notFound(res);
const runs = db
.prepare("SELECT * FROM report_runs WHERE report_id = ? ORDER BY id DESC LIMIT 100")
.all(row.id);
res.json({ runs: runs.map(reports.viewRun) });
});
// GET /api/reports/:id/runs/:runId/:format — download one file of a run.
router.get("/:id/runs/:runId/:format", (req, res) => {
const { id, runId, format } = req.params;
if (!ID_RE.test(id) || !ID_RE.test(runId)) return notFound(res);
const file = reports.runFile(id, runId, format);
if (!file) {
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Report file not found" } });
}
res.download(file, `report-${id}-${runId}.${format}`);
});
module.exports = router;
+9 -5
View File
@@ -338,12 +338,15 @@ router.get("/:id", (req, res) => {
* All aggregation runs in SQL so we don't ship 14k+ event rows to the client.
*/
router.get("/:id/stats", (req, res) => {
const sessionId = req.params.id;
const session = stmts.getSession.get(sessionId);
const session = stmts.getSession.get(req.params.id);
if (!session) {
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
}
res.json(sessionStats(req.params.id));
});
/** The `/:id/stats` body — also frozen into a session snapshot. */
function sessionStats(sessionId) {
const totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0;
const eventsByType = stmts.sessionEventTypeCounts.all(sessionId);
const tools = stmts.sessionToolUsageCounts.all(sessionId);
@@ -385,7 +388,7 @@ router.get("/:id/stats", (req, res) => {
else if (row.type === "subagent") agentCounts.subagent = row.count;
}
res.json({
return {
session_id: sessionId,
total_events: totalEvents,
events_by_type: eventsByType,
@@ -398,8 +401,8 @@ router.get("/:id/stats", (req, res) => {
agents: agentCounts,
subagent_types: subagentTypes.filter((r) => r.subagent_type !== "compaction"),
tokens,
});
});
};
}
router.post("/", (req, res) => {
const { id, name, cwd, model, metadata } = req.body;
@@ -1096,5 +1099,6 @@ function truncateObj(obj, maxLen) {
}
module.exports = router;
module.exports.sessionStats = sessionStats;
// Exported for unit tests — sender attribution is correctness-critical.
module.exports.classifyTranscriptSender = classifyTranscriptSender;
+74 -4
View File
@@ -1,5 +1,5 @@
/**
* @file Express router for settings-related endpoints, providing system info, database statistics, hook status, and operations to clear data, re-import sessions, reinstall hooks, reset pricing, export data, and perform cleanup of stale sessions. This allows the frontend to manage and maintain the agent monitoring system effectively.
* @file Express router for settings-related endpoints, providing system info, database statistics, hook status, and operations to clear data, re-import sessions, reinstall hooks, reset pricing, export data, and perform cleanup of stale sessions, plus the privacy controls (secret redaction, ignored folders) and the push-ingest token. This allows the frontend to manage and maintain the agent monitoring system effectively.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -24,7 +24,9 @@ const APP_VERSION = (() => {
const { getSettingsPath, getClaudeHome, setClaudeHome } = require("../lib/claude-home");
const { getCodexHome, setCodexHome } = require("../lib/codex-home");
const { envOwned } = require("../lib/settings-env");
const { envOwned, applySetting } = require("../lib/settings-env");
const privacy = require("../lib/privacy");
const pushToken = require("../lib/push-token");
const pricingSync = require("../lib/pricing-sync");
const { EXPORT_FORMAT, EXPORT_VERSION, importExportBundle } = require("../lib/data-transfer");
@@ -171,10 +173,11 @@ function clearAll() {
db.prepare("DELETE FROM events").run();
db.prepare("DELETE FROM agents").run();
db.prepare("DELETE FROM sessions").run();
// What the Codex/Antigravity polls have read: kept, it would stop them ever
// importing those sessions again (and bring one back half, on its next write).
// What the Codex/Antigravity/Cursor polls have read: kept, it would stop them
// ever importing those sessions again (and bring one back half, on its next write).
db.prepare("DELETE FROM codex_rollouts").run();
db.prepare("DELETE FROM antigravity_conversations").run();
db.prepare("DELETE FROM cursor_transcripts").run();
// Fired alerts reference the cleared sessions — wipe the feed too. Alert
// *rules* survive: they're user configuration, like model_pricing.
db.prepare("DELETE FROM alert_events").run();
@@ -184,6 +187,11 @@ function clearAll() {
// Workflow runs belong to the sessions; with foreign keys off they would
// not cascade and stay as orphans.
db.prepare("DELETE FROM workflows").run();
// session_snapshots survive: a snapshot is a frozen copy the user chose to
// keep, like alert rules — deleted only from Settings → Snapshots.
// report_definitions and report_runs survive too: a definition is user
// configuration and a run's files are reports already delivered — removed
// only from Settings → Reports.
}
// POST /api/settings/reimport — re-import legacy sessions from ~/.claude/
@@ -438,6 +446,68 @@ homeRoutes("/claude-home", "CLAUDE_HOME", "claude_home", getClaudeHome, setClaud
// into the current home at its spawn, so a move needs no repair here.
homeRoutes("/codex-home", "CODEX_HOME", "codex_home", getCodexHome, setCodexHome);
// GET/PUT /api/settings/privacy — opt-in secret redaction of stored payloads
// and the folders whose hooks are dropped (server/lib/privacy.js). Both persist
// to the settings file; one the environment set is `env_owned` and refused.
function privacyState() {
return {
redact_secrets: privacy.redactionEnabled(),
ignore_cwd: privacy.ignoredCwds(),
env_owned: {
redact_secrets: envOwned.has(privacy.REDACT_KEY),
ignore_cwd: envOwned.has(privacy.IGNORE_KEY),
},
};
}
router.get("/privacy", (_req, res) => res.json(privacyState()));
router.put("/privacy", sameOriginGuard, (req, res) => {
const body = req.body || {};
const fail = (status, code, message) => res.status(status).json({ error: { code, message } });
const writes = [];
if (body.redact_secrets !== undefined) {
if (typeof body.redact_secrets !== "boolean")
return fail(400, "INVALID_INPUT", "redact_secrets must be a boolean");
writes.push([privacy.REDACT_KEY, body.redact_secrets ? "1" : "0"]);
}
if (body.ignore_cwd !== undefined) {
let list;
try {
list = privacy.validateIgnoreList(body.ignore_cwd);
} catch (err) {
return fail(400, err.code || "EBADPATH", err.message);
}
writes.push([privacy.IGNORE_KEY, list.join(path.delimiter)]);
}
const owned = writes.find(([k]) => envOwned.has(k));
if (owned) return fail(409, "EENVOWNED", `${owned[0]} is set by the environment`);
try {
for (const [k, v] of writes) applySetting(k, v);
} catch (err) {
return fail(500, "ESAVE", err.message);
}
res.json({ ok: true, ...privacyState() });
});
// GET/POST/DELETE /api/settings/push-token — the token for POST
// /api/hooks/ingest-batch (server/lib/push-token.js). GET never returns it;
// POST makes a new one (the old one stops working) and returns it this once.
router.get("/push-token", (_req, res) => res.json(pushToken.pushTokenStatus()));
function pushTokenWrite(fn) {
return (_req, res) => {
try {
const token = fn();
res.json({ ok: true, ...(token && { token }), ...pushToken.pushTokenStatus() });
} catch (err) {
const owned = err.code === "EENVOWNED";
res
.status(owned ? 409 : 500)
.json({ error: { code: owned ? err.code : "ESAVE", message: err.message } });
}
};
}
router.post("/push-token", sameOriginGuard, pushTokenWrite(pushToken.rotatePushToken));
router.delete("/push-token", sameOriginGuard, pushTokenWrite(pushToken.revokePushToken));
// POST /api/settings/cleanup — abandon stale sessions, purge old data.
// `purge_days` is a whole number ≥ 1 (0.1 days would purge almost everything);
// `dry_run: true` answers the same counts and changes nothing.
+177
View File
@@ -0,0 +1,177 @@
/**
* @file Session snapshots: `POST /api/snapshots` freezes a session — its row,
* agents, events (oldest first), the SessionOverview stats and the token cost —
* into one immutable `session_snapshots` row read back by an unguessable token
* (`GET /api/snapshots/:token`). File paths can be redacted at capture, so
* nothing redacted is ever stored. Writes are same-origin only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const crypto = require("node:crypto");
const os = require("node:os");
const { Router } = require("express");
const { db, stmts } = require("../db");
const { sameOriginGuard } = require("../lib/same-origin");
const { calculateCost, attachAgentCosts } = require("./pricing");
const { sessionStats } = require("./sessions");
const router = Router();
/** Keys whose string value is a path on this machine — replaced whole. */
const PATH_KEYS = new Set([
"cwd",
"transcript_path",
"agent_transcript_path",
"file_path",
"filePath",
"notebook_path",
"path",
]);
const REDACTED = "[redacted]";
/** 24 random bytes, base64url. */
const TOKEN_RE = /^[A-Za-z0-9_-]{32}$/;
function escapeRe(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* A redactor for one session: path-valued keys become `[redacted]`, and the
* session's cwd / the home dir inside any other string (a Bash command, a
* summary) become `<cwd>` / `~`.
*/
function pathRedactor(cwd) {
const swaps = [
[cwd, "<cwd>"],
[os.homedir(), "~"],
]
.filter(([p]) => typeof p === "string" && p.length > 1)
.map(([p, to]) => [new RegExp(`${escapeRe(p)}(?![\\w.-])`, "g"), to]);
const str = (s) => swaps.reduce((acc, [re, to]) => acc.replace(re, to), s);
const walk = (v, key) => {
if (typeof v === "string") return PATH_KEYS.has(key) ? REDACTED : str(v);
if (Array.isArray(v)) return v.map((x) => walk(x));
if (v && typeof v === "object") {
const out = {};
for (const [k, x] of Object.entries(v)) out[k] = walk(x, k);
return out;
}
return v;
};
// A column holding JSON text (events.data, *.metadata) is redacted as JSON.
const json = (text) => {
if (typeof text !== "string") return text;
try {
return JSON.stringify(walk(JSON.parse(text)));
} catch {
return str(text);
}
};
return { walk, json };
}
function capture(sessionId, redactPaths) {
const session = stmts.getSession.get(sessionId);
if (!session) return null;
const agents = attachAgentCosts(stmts.listAgentsBySession.all(sessionId));
const events = stmts.listEventsBySession.all(sessionId).reverse();
const cost = calculateCost(
stmts.getTokensBySession.all(sessionId),
stmts.listPricing.all(),
session.started_at ? String(session.started_at).slice(0, 10) : undefined
);
const payload = {
version: 1,
captured_at: new Date().toISOString(),
redacted: { file_paths: redactPaths },
session,
agents,
events,
stats: sessionStats(sessionId),
cost,
};
if (!redactPaths) return payload;
const r = pathRedactor(session.cwd);
const row = (x) => {
const out = r.walk(x);
if ("data" in x) out.data = r.json(x.data);
if ("metadata" in x) out.metadata = r.json(x.metadata);
return out;
};
return {
...payload,
session: row(session),
agents: agents.map(row),
events: events.map(row),
};
}
// GET /api/snapshots — every snapshot, newest first, without its payload.
router.get("/", (_req, res) => {
const snapshots = db
.prepare(
`SELECT id, token, session_id, created_at,
json_extract(payload, '$.session.name') AS session_name,
json_extract(payload, '$.redacted.file_paths') AS redacted,
json_array_length(payload, '$.events') AS event_count,
length(payload) AS bytes
FROM session_snapshots ORDER BY id DESC`
)
.all()
.map((s) => ({ ...s, redacted: s.redacted === 1 }));
res.json({ snapshots });
});
// POST /api/snapshots {session_id, redact?: {file_paths?: boolean}}
router.post("/", sameOriginGuard, (req, res) => {
const sessionId = req.body?.session_id;
if (typeof sessionId !== "string" || !sessionId) {
return res
.status(400)
.json({ error: { code: "INVALID_INPUT", message: "session_id is required" } });
}
const payload = capture(sessionId, req.body?.redact?.file_paths === true);
if (!payload) {
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
}
const token = crypto.randomBytes(24).toString("base64url");
const { lastInsertRowid } = db
.prepare("INSERT INTO session_snapshots (token, session_id, payload) VALUES (?, ?, ?)")
.run(token, sessionId, JSON.stringify(payload));
const row = db
.prepare("SELECT id, token, session_id, created_at FROM session_snapshots WHERE id = ?")
.get(lastInsertRowid);
res.status(201).json({ snapshot: row });
});
// GET /api/snapshots/:token — the frozen payload, sent as stored.
router.get("/:token", (req, res) => {
const token = req.params.token;
const row = TOKEN_RE.test(token)
? db
.prepare(
"SELECT id, token, session_id, created_at, payload FROM session_snapshots WHERE token = ?"
)
.get(token)
: null;
if (!row) {
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Snapshot not found" } });
}
const { payload, ...meta } = row;
res.type("application/json").send(`{"snapshot":${JSON.stringify(meta)},"payload":${payload}}`);
});
// DELETE /api/snapshots/:id
router.delete("/:id", sameOriginGuard, (req, res) => {
const id = Number(req.params.id);
const { changes } = Number.isInteger(id)
? db.prepare("DELETE FROM session_snapshots WHERE id = ?").run(id)
: { changes: 0 };
if (!changes) {
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Snapshot not found" } });
}
res.json({ ok: true });
});
module.exports = router;
module.exports.pathRedactor = pathRedactor;
+13 -11
View File
@@ -6,7 +6,7 @@
const { Router } = require("express");
const { stmts, db } = require("../db");
const { getConnectionCount } = require("../websocket");
const { parseSources } = require("../lib/source-filter");
const { parseScope, parseTool, BAD_TOOL } = require("../lib/source-filter");
const scoped = require("../lib/scoped-stats");
const router = Router();
@@ -18,19 +18,21 @@ router.get("/", (req, res) => {
const toLocal = `${-offsetMin} minutes`; // shift UTC → local
const toUTC = `${offsetMin} minutes`; // shift local → UTC
// Data-scope: when the user restricts to a subset of source machines, compute
// every count against that subset; otherwise use the cached prepared stmts.
const sources = parseSources(req);
const overview = sources ? scoped.statsOverview(db, sources) : stmts.stats.get();
const agentsByStatus = sources
? scoped.agentStatusCounts(db, sources)
// Data-scope: when the user restricts to a subset of source machines and/or
// one AI tool, compute every count against that subset; otherwise use the
// cached prepared stmts.
if (parseTool(req) === false) return res.status(400).json(BAD_TOOL);
const scope = parseScope(req);
const overview = scope ? scoped.statsOverview(db, scope) : stmts.stats.get();
const agentsByStatus = scope
? scoped.agentStatusCounts(db, scope)
: stmts.agentStatusCounts.all();
const sessionsByStatus = sources
? scoped.sessionStatusCounts(db, sources)
const sessionsByStatus = scope
? scoped.sessionStatusCounts(db, scope)
: stmts.sessionStatusCounts.all();
const eventsToday = sources
? scoped.countEventsToday(db, sources, toLocal, toUTC)
const eventsToday = scope
? scoped.countEventsToday(db, scope, toLocal, toUTC)
: stmts.countEventsToday.get(toLocal, toUTC);
res.json({
+14 -3
View File
@@ -7,6 +7,7 @@ const { Router } = require("express");
const { db, stmts } = require("../db");
const { runRead } = require("../lib/read-pool");
const { WORKFLOW_SECTIONS, sessionIdFilter } = require("../lib/workflow-stats");
const { parseTool, BAD_TOOL, parseRange, BAD_RANGE } = require("../lib/source-filter");
const router = Router();
@@ -15,8 +16,14 @@ const router = Router();
// large database, which inline would stall every other request meanwhile.
// `?sections=a,b` computes only those parts of it (System Health needs seven).
router.get("/", async (req, res) => {
// Optional status filter: "active", "completed", or omit for all
// Optional filters: status ("active", "completed", or omit for all), the AI
// tool (`?tool=`, `sessions.tool`) and `?from=&to=` (sessions started in
// those local days).
const statusFilter = req.query.status || null;
const tool = parseTool(req);
if (tool === false) return res.status(400).json(BAD_TOOL);
const range = parseRange(req);
if (range === false) return res.status(400).json(BAD_RANGE);
let sections = null;
if (typeof req.query.sections === "string") {
sections = req.query.sections.split(",").filter((k) => WORKFLOW_SECTIONS.includes(k));
@@ -30,7 +37,7 @@ router.get("/", async (req, res) => {
}
}
try {
res.json(await runRead("workflows", { status: statusFilter, sections }));
res.json(await runRead("workflows", { status: statusFilter, tool, range, sections }));
} catch (err) {
if (err.code === "EREADTIMEOUT") {
return res.status(503).json({ error: { code: err.code, message: err.message } });
@@ -54,7 +61,11 @@ router.get("/", async (req, res) => {
*/
router.get("/skill-usage", (req, res) => {
try {
const sf = sessionIdFilter(req.query.status || null);
const tool = parseTool(req);
if (tool === false) return res.status(400).json(BAD_TOOL);
const range = parseRange(req);
if (range === false) return res.status(400).json(BAD_RANGE);
const sf = sessionIdFilter({ status: req.query.status || null, tool, range });
const skills = db
.prepare(