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:
+146
-3
@@ -387,6 +387,8 @@ import type {
|
||||
AlertEvent,
|
||||
AlertRule,
|
||||
Analytics,
|
||||
BudgetStatus,
|
||||
RulePreview,
|
||||
CodexHooksStatus,
|
||||
AntigravityHooksStatus,
|
||||
CostResult,
|
||||
@@ -424,10 +426,17 @@ import type {
|
||||
WorkflowRun,
|
||||
WorkflowRunsResponse,
|
||||
WorkflowRunDetail,
|
||||
SessionSnapshotInfo,
|
||||
SessionSnapshotPayload,
|
||||
QueryPage,
|
||||
ReportDefinition,
|
||||
ReportInput,
|
||||
ReportRun,
|
||||
} from "./types";
|
||||
|
||||
import { activeSourcesParam } from "./dataScope";
|
||||
import i18n from "i18next";
|
||||
import { applyRange, type DateRange } from "./dateRange";
|
||||
|
||||
// Root path all endpoint paths are appended to. Kept relative (no host) so the
|
||||
// same client bundle works behind the Vite dev proxy and in same-origin prod.
|
||||
@@ -979,10 +988,15 @@ export const api = {
|
||||
* day, the viewer's timezone offset is sent so the daily buckets align to
|
||||
* the user's local midnight.
|
||||
*
|
||||
* @param opts.tool Only sessions of this AI tool (`sessions.tool`).
|
||||
* @param opts.range Local `from`/`to` days: counts, tokens and cost follow
|
||||
* it; the daily series keep their own window.
|
||||
* @returns {@link Analytics} — the chart-ready analytics payload.
|
||||
*/
|
||||
get: () => {
|
||||
get: (opts: { tool?: string; range?: DateRange } = {}) => {
|
||||
const qs = new URLSearchParams({ tz_offset: String(new Date().getTimezoneOffset()) });
|
||||
if (opts.tool) qs.set("tool", opts.tool);
|
||||
applyRange(qs, opts.range);
|
||||
applyScope(qs);
|
||||
return request<Analytics>(`/analytics?${qs.toString()}`);
|
||||
},
|
||||
@@ -1100,6 +1114,29 @@ export const api = {
|
||||
/** DELETE /api/settings/codex-hooks — AgentHub's own hooks only */
|
||||
remove: () => request<CodexHooksStatus>("/settings/codex-hooks", { method: "DELETE" }),
|
||||
},
|
||||
/** Opt-in secret redaction of stored payloads and the folders whose hooks
|
||||
* are dropped (`server/lib/privacy.js`). */
|
||||
privacy: {
|
||||
/** GET /api/settings/privacy */
|
||||
get: () => request<PrivacySettings>("/settings/privacy"),
|
||||
/** PUT /api/settings/privacy — any subset of the two fields */
|
||||
save: (data: { redact_secrets?: boolean; ignore_cwd?: string[] }) =>
|
||||
request<PrivacySettings & { ok: boolean }>("/settings/privacy", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
},
|
||||
/** The bearer token for `POST /api/hooks/ingest-batch`. Only its hash is
|
||||
* stored: `rotate` returns the new token this once, `get` never does. */
|
||||
pushToken: {
|
||||
/** GET /api/settings/push-token */
|
||||
get: () => request<PushTokenStatus>("/settings/push-token"),
|
||||
/** POST /api/settings/push-token — a new token; the old one stops working */
|
||||
rotate: () =>
|
||||
request<PushTokenStatus & { token: string }>("/settings/push-token", { method: "POST" }),
|
||||
/** DELETE /api/settings/push-token — push ingest off */
|
||||
revoke: () => request<PushTokenStatus>("/settings/push-token", { method: "DELETE" }),
|
||||
},
|
||||
/** AgentHub's named hook in `~/.gemini/config/hooks.json` for agy — changed
|
||||
* only on a click; agy runs it with no trust step. */
|
||||
antigravityHooks: {
|
||||
@@ -1256,6 +1293,8 @@ export const api = {
|
||||
* "all" (which means "no filter"), keeping the default URL param-free.
|
||||
*
|
||||
* @param status Optional lifecycle filter; "all" (or omitted) means no filter.
|
||||
* @param tool Optional AI tool id (`sessions.tool`).
|
||||
* @param range Optional local date range: sessions started in it.
|
||||
* @param sections Optional subset of the bundle to compute (`?sections=`);
|
||||
* the server skips the rest, so a page reading a few cards asks for those.
|
||||
* @returns {@link WorkflowData} — the aggregated workflow-intelligence panel,
|
||||
@@ -1263,10 +1302,14 @@ export const api = {
|
||||
*/
|
||||
get: <K extends keyof WorkflowData = keyof WorkflowData>(
|
||||
status?: string,
|
||||
tool?: string,
|
||||
range?: DateRange,
|
||||
sections?: readonly K[]
|
||||
) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (status && status !== "all") qs.set("status", status);
|
||||
if (tool) qs.set("tool", tool);
|
||||
applyRange(qs, range);
|
||||
if (sections?.length) qs.set("sections", sections.join(","));
|
||||
const q = qs.toString();
|
||||
return request<Pick<WorkflowData, K>>(`/workflows${q ? `?${q}` : ""}`);
|
||||
@@ -1381,15 +1424,18 @@ export const api = {
|
||||
*
|
||||
* @returns {@link CostResult} — the aggregate cost breakdown across sessions.
|
||||
*/
|
||||
totalCost: (opts: { lane?: number; date?: string } = {}) => {
|
||||
totalCost: (opts: { lane?: number; date?: string; tool?: string; range?: DateRange } = {}) => {
|
||||
// Scope the aggregate to the active data-scope, exactly like the sessions /
|
||||
// stats / analytics endpoints — otherwise switching the Data scope selector
|
||||
// left the Dashboard "total cost" showing the un-narrowed global total.
|
||||
// `lane`: only the sessions that workspace owns.
|
||||
// `lane`: only the sessions that workspace owns; `tool`: one AI tool's.
|
||||
const qs = new URLSearchParams({ tz_offset: String(new Date().getTimezoneOffset()) });
|
||||
if (opts.lane !== undefined) qs.set("lane", String(opts.lane));
|
||||
// `date` (local YYYY-MM-DD): only that day's usage, breakdown included.
|
||||
if (opts.date) qs.set("date", opts.date);
|
||||
if (opts.tool) qs.set("tool", opts.tool);
|
||||
// `range`: total, breakdown and by_tool for those local days only.
|
||||
applyRange(qs, opts.range);
|
||||
applyScope(qs);
|
||||
return request<CostResult>(`/pricing/cost?${qs.toString()}`);
|
||||
},
|
||||
@@ -2066,6 +2112,24 @@ export const api = {
|
||||
{ method: "DELETE" }
|
||||
),
|
||||
},
|
||||
/** Frozen session copies (`/api/snapshots`, `server/routes/snapshots.js`). */
|
||||
snapshots: {
|
||||
list: () => request<{ snapshots: SessionSnapshotInfo[] }>("/snapshots"),
|
||||
/** Freeze a session; `filePaths` redacts paths before anything is stored. */
|
||||
create: (sessionId: string, opts?: { filePaths?: boolean }) =>
|
||||
request<{ snapshot: SessionSnapshotInfo }>("/snapshots", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
redact: { file_paths: opts?.filePaths === true },
|
||||
}),
|
||||
}),
|
||||
get: (token: string) =>
|
||||
request<{ snapshot: SessionSnapshotInfo; payload: SessionSnapshotPayload }>(
|
||||
`/snapshots/${encodeURIComponent(token)}`
|
||||
),
|
||||
remove: (id: number) => request<{ ok: true }>(`/snapshots/${id}`, { method: "DELETE" }),
|
||||
},
|
||||
/** The mascot's custom pets (`/api/pets`, `server/lib/pets.js`). */
|
||||
pets: {
|
||||
list: () => request<PetList>("/pets"),
|
||||
@@ -2329,9 +2393,65 @@ export const api = {
|
||||
*/
|
||||
remove: (id: string) =>
|
||||
request<{ ok: true }>(`/alerts/rules/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
/**
|
||||
* POST /api/alerts/rules/preview - what a rule WOULD have fired over the
|
||||
* last `days` (default 7) of recorded history. Saves and fires nothing.
|
||||
*/
|
||||
preview: (body: {
|
||||
rule_type: AlertRule["rule_type"];
|
||||
config: AlertRule["config"];
|
||||
cooldown_seconds?: number;
|
||||
days?: number;
|
||||
}) =>
|
||||
request<RulePreview>("/alerts/rules/preview", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
/** Spend budgets — `budget` alert rules — with their current-period spend. */
|
||||
budgets: {
|
||||
/** GET /api/budgets/status */
|
||||
status: () => request<{ budgets: BudgetStatus[] }>("/budgets/status"),
|
||||
},
|
||||
|
||||
/** Query Explorer (`GET /api/query`, `server/routes/query.js`). */
|
||||
query: {
|
||||
/** One page of the filtered rows; `params` are the page's filters. */
|
||||
page: (params: URLSearchParams) =>
|
||||
request<QueryPage>(`/query?${applyScope(new URLSearchParams(params)).toString()}`),
|
||||
/** A download link for the whole filtered set (≤ `export_cap` rows). */
|
||||
exportUrl: (params: URLSearchParams, format: "csv" | "json") => {
|
||||
const qs = applyScope(new URLSearchParams(params));
|
||||
qs.delete("limit");
|
||||
qs.delete("offset");
|
||||
qs.set("format", format);
|
||||
return `${BASE}/query?${qs.toString()}`;
|
||||
},
|
||||
},
|
||||
|
||||
/** Scheduled reports (`/api/reports`, `server/lib/reports.js`). */
|
||||
reports: {
|
||||
list: () => request<{ reports: ReportDefinition[] }>("/reports"),
|
||||
create: (body: ReportInput) =>
|
||||
request<{ report: ReportDefinition }>("/reports", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
update: (id: number, body: Partial<ReportInput>) =>
|
||||
request<{ report: ReportDefinition }>(`/reports/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
remove: (id: number) => request<{ ok: true }>(`/reports/${id}`, { method: "DELETE" }),
|
||||
run: (id: number) => request<{ run: ReportRun }>(`/reports/${id}/run`, { method: "POST" }),
|
||||
runs: (id: number) => request<{ runs: ReportRun[] }>(`/reports/${id}/runs`),
|
||||
/** A run's file, as a download link. */
|
||||
fileUrl: (id: number, runId: number, format: string) =>
|
||||
`${BASE}/reports/${id}/runs/${runId}/${encodeURIComponent(format)}`,
|
||||
},
|
||||
|
||||
// ───────────────────────────────── Webhooks API ─────────────────────────────
|
||||
/** Outbound webhook target CRUD, provider metadata, test sends, and the
|
||||
* per-target delivery log. */
|
||||
@@ -4172,6 +4292,10 @@ export interface RemoteSource {
|
||||
identity_file: string | null;
|
||||
/** Optional remote CLAUDE_HOME (default `~/.claude`). */
|
||||
remote_home: string | null;
|
||||
/** Whether Codex history (`<remote_codex_home>/sessions`) is pulled too. */
|
||||
sync_codex?: boolean;
|
||||
/** Optional remote CODEX_HOME (default `~/.codex`). */
|
||||
remote_codex_home?: string | null;
|
||||
/** Whether the background poller pulls this source. */
|
||||
enabled: boolean;
|
||||
/** Last known sync state. */
|
||||
@@ -4188,6 +4312,9 @@ export interface RemoteSource {
|
||||
errors?: number;
|
||||
sessions_seen?: number;
|
||||
sessions_tagged?: number;
|
||||
/** Codex journals read / sessions written, when Codex is pulled. */
|
||||
codex_files?: number;
|
||||
codex_sessions?: number;
|
||||
} | null;
|
||||
/** Live number of sessions currently attributed to this source. */
|
||||
session_count?: number;
|
||||
@@ -4202,9 +4329,25 @@ export interface RemoteSourceInput {
|
||||
ssh_port?: number | null;
|
||||
identity_file?: string | null;
|
||||
remote_home?: string | null;
|
||||
sync_codex?: boolean;
|
||||
remote_codex_home?: string | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/** GET /api/settings/privacy. A field the environment set is `env_owned`
|
||||
* and cannot be saved from the dashboard. */
|
||||
export interface PrivacySettings {
|
||||
redact_secrets: boolean;
|
||||
ignore_cwd: string[];
|
||||
env_owned: { redact_secrets: boolean; ignore_cwd: boolean };
|
||||
}
|
||||
|
||||
/** GET /api/settings/push-token — whether push ingest is on; never the token. */
|
||||
export interface PushTokenStatus {
|
||||
enabled: boolean;
|
||||
env_owned: boolean;
|
||||
}
|
||||
|
||||
/** Result of a connectivity probe (POST /:id/test). */
|
||||
export interface RemoteSourceTestResult {
|
||||
ok: boolean;
|
||||
|
||||
Reference in New Issue
Block a user