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
+146 -3
View File
@@ -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;