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
+26
View File
@@ -0,0 +1,26 @@
/**
* @file Tests for spendPace — the 7-day burn rate, month to date and the
* month-end forecast built from the daily cost series.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { spendPace } from "../burnRate";
describe("spendPace", () => {
it("averages the last 7 days and carries the pace to month end", () => {
const daily = [
{ date: "2026-09-28", cost: 70 }, // before the 7-day window (09-29..10-05)
{ date: "2026-09-30", cost: 7 }, // inside the window, last month
{ date: "2026-10-01", cost: 7 },
{ date: "2026-10-05", cost: 14 },
{ date: "2026-10-06", cost: 99 }, // after today: ignored
];
const p = spendPace(daily, "2026-10-05");
expect(p.perDay7).toBe(4); // (7 + 7 + 14) / 7
expect(p.monthToDate).toBe(21);
expect(p.forecastMonth).toBe(21 + 4 * 26); // 26 days left in October
});
it("is all zero with no spend", () => {
expect(spendPace([], "2026-02-28")).toEqual({ perDay7: 0, monthToDate: 0, forecastMonth: 0 });
});
});
@@ -0,0 +1,47 @@
/**
* @file dateRange.test.ts
* @description The Analytics range presets: `?range=` parsing, a preset's
* local bounds (week from Monday, month from the 1st), a custom range, and the query params `applyRange` sets.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { applyRange, parsePreset, rangeDates } from "../dateRange";
describe("dateRange", () => {
const today = new Date(2026, 9, 5, 12); // local 2026-10-05
it("parses presets, defaulting to all", () => {
expect(parsePreset("30d")).toBe("30d");
expect(parsePreset("1y")).toBe("all");
expect(parsePreset(null)).toBe("all");
});
it("gives a preset's local bounds, today included", () => {
expect(rangeDates("7d", {}, today)).toEqual({ from: "2026-09-29", to: "2026-10-05" });
expect(rangeDates("90d", {}, today).from).toBe("2026-07-08");
expect(rangeDates("all", {}, today)).toEqual({});
});
it("starts the week on Monday and the month on the 1st", () => {
const wed = new Date(2026, 9, 7, 12);
const sun = new Date(2026, 9, 11, 12);
expect(rangeDates("today", {}, wed)).toEqual({ from: "2026-10-07", to: "2026-10-07" });
expect(rangeDates("week", {}, wed)).toEqual({ from: "2026-10-05", to: "2026-10-07" });
expect(rangeDates("week", {}, sun)).toEqual({ from: "2026-10-05", to: "2026-10-11" });
expect(rangeDates("week", {}, today)).toEqual({ from: "2026-10-05", to: "2026-10-05" });
expect(rangeDates("month", {}, wed)).toEqual({ from: "2026-10-01", to: "2026-10-07" });
expect(rangeDates("custom", { from: "2026-01-01", to: "" }, today)).toEqual({
from: "2026-01-01",
to: undefined,
});
});
it("sets from/to and tz_offset only when there is a bound", () => {
expect(applyRange(new URLSearchParams(), {}).toString()).toBe("");
const qs = applyRange(new URLSearchParams(), { from: "2026-01-01" });
expect(qs.get("from")).toBe("2026-01-01");
expect(qs.has("to")).toBe(false);
expect(qs.get("tz_offset")).toBe(String(new Date().getTimezoneOffset()));
});
});
+96
View File
@@ -0,0 +1,96 @@
/**
* @file replay.test.ts
* @description Unit tests for the Replay tab's pure derivations: playback
* order, lifecycle markers and the session state at a cursor.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { chronological, lifecycleMarkers, replayState } from "../replay";
import type { DashboardEvent } from "../types";
let nextId = 1;
function ev(event_type: string, sec: number, tool_name: string | null = null, agent = "main") {
return {
id: nextId++,
session_id: "s",
agent_id: agent,
event_type,
tool_name,
summary: null,
data: null,
created_at: new Date(Date.UTC(2026, 9, 1, 10, 0, sec)).toISOString(),
} as unknown as DashboardEvent;
}
const events = [
ev("SessionStart", 0),
ev("UserPromptSubmit", 1),
ev("PreToolUse", 2, "Read"),
ev("PostToolUse", 3, "Read"),
ev("PreToolUse", 4, "Bash"),
ev("PreToolUse", 5, "Read", "sub"),
ev("APIError", 6),
ev("PostToolUse", 7, "Bash"),
ev("Stop", 8),
];
describe("chronological", () => {
it("orders by time, then id", () => {
const tie = [ev("Stop", 9), ev("Stop", 9)];
const out = chronological([tie[1]!, events[2]!, tie[0]!, events[0]!]);
expect(out.map((e) => e.id)).toEqual([events[0]!.id, events[2]!.id, tie[0]!.id, tie[1]!.id]);
});
});
describe("lifecycleMarkers", () => {
it("marks lifecycle events at their sequence position", () => {
const m = lifecycleMarkers(events);
expect(m.map((x) => x.type)).toEqual(["SessionStart", "UserPromptSubmit", "APIError", "Stop"]);
expect(m[0]!.pct).toBe(0);
expect(m[3]!.pct).toBe(100);
expect(m[2]!.index).toBe(6);
});
it("handles a single event", () => {
expect(lifecycleMarkers([events[0]!])).toEqual([{ index: 0, type: "SessionStart", pct: 0 }]);
});
});
describe("replayState", () => {
it("is empty for no events", () => {
const s = replayState([], 0);
expect(s.status).toBeNull();
expect(s.currentTool).toBeNull();
expect(s.toolCounts).toEqual([]);
});
it("tracks the tool in flight and counts up to the cursor", () => {
const at4 = replayState(events, 4);
expect(at4.currentTool).toBe("Bash");
expect(at4.status).toBe("working");
expect(at4.prompts).toBe(1);
expect(at4.toolCounts).toEqual([
{ tool: "Bash", count: 1 },
{ tool: "Read", count: 1 },
]);
expect(at4.elapsedMs).toBe(4000);
const at6 = replayState(events, 6);
expect(at6.currentTool).toBe("Read"); // the subagent's, newest still open
expect(at6.errors).toBe(1);
expect(at6.status).toBe("error");
expect(at6.toolCounts[0]).toEqual({ tool: "Read", count: 2 });
});
it("closes a finished tool and an ended turn", () => {
expect(replayState(events, 3).currentTool).toBeNull();
// Stop on main leaves only the subagent's Read open.
expect(replayState(events, 8).currentTool).toBe("Read");
expect(replayState(events, 8).status).toBe("waiting");
});
it("clamps a cursor past the end", () => {
expect(replayState(events, 99).elapsedMs).toBe(8000);
});
});
+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;
+40
View File
@@ -0,0 +1,40 @@
/**
* @file Spend pace from the daily cost series (`daily_costs` of GET
* /api/pricing/cost, already in local dates): the average cost per day over
* the last 7 days, the month to date, and a month-end forecast that carries
* that pace over the days left in the month.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export interface SpendPace {
/** Average cost per day over the 7 days ending today (today included). */
perDay7: number;
/** Spend since the 1st of this month. */
monthToDate: number;
/** monthToDate + perDay7 × the days left after today. */
forecastMonth: number;
}
/**
* @param daily `{date: "YYYY-MM-DD", cost}` per local day, any order.
* @param today Today's local date, `YYYY-MM-DD`.
*/
export function spendPace(daily: Array<{ date: string; cost: number }>, today: string): SpendPace {
const [y, m, d] = today.split("-").map(Number) as [number, number, number];
const day = (offset: number) => {
const t = new Date(Date.UTC(y, m - 1, d + offset));
return t.toISOString().slice(0, 10);
};
const weekStart = day(-6);
const monthStart = today.slice(0, 8) + "01";
let week = 0;
let mtd = 0;
for (const r of daily) {
if (r.date > today) continue;
if (r.date >= weekStart) week += r.cost;
if (r.date >= monthStart) mtd += r.cost;
}
const daysInMonth = new Date(Date.UTC(y, m, 0)).getUTCDate();
const perDay7 = week / 7;
return { perDay7, monthToDate: mtd, forecastMonth: mtd + perDay7 * (daysInMonth - d) };
}
+64
View File
@@ -0,0 +1,64 @@
/**
* @file dateRange — the Analytics date range: the preset ids kept in `?range=`
* (today / this week from Monday / this month from the 1st / 7d / 30d / 90d /
* all / custom), and `rangeDates`, which turns a preset (or
* a custom from/to) into the local `YYYY-MM-DD` bounds sent as `from`/`to`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export const RANGE_PRESETS = [
"today",
"week",
"month",
"7d",
"30d",
"90d",
"all",
"custom",
] as const;
export type RangePreset = (typeof RANGE_PRESETS)[number];
/** Local, inclusive bounds; an absent end is open. */
export interface DateRange {
from?: string;
to?: string;
}
/** A `?range=` value, or `all` for anything else. */
export function parsePreset(v: string | null): RangePreset {
return (RANGE_PRESETS as readonly string[]).includes(v ?? "") ? (v as RangePreset) : "all";
}
/** `d` as a local `YYYY-MM-DD`. */
export function localDate(d: Date): string {
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
/** The bounds of a preset: `7d` = today and the 6 days before it; `week`
* starts on Monday, `month` on the 1st, both ending today. */
export function rangeDates(
preset: RangePreset,
custom: DateRange = {},
today: Date = new Date()
): DateRange {
if (preset === "all") return {};
if (preset === "custom") {
return { from: custom.from || undefined, to: custom.to || undefined };
}
const start = new Date(today);
if (preset === "week") start.setDate(today.getDate() - ((today.getDay() + 6) % 7));
else if (preset === "month") start.setDate(1);
else if (preset !== "today") start.setDate(today.getDate() - (parseInt(preset, 10) - 1));
return { from: localDate(start), to: localDate(today) };
}
/** Set `from`/`to` (and `tz_offset`, which reads them) on a query string. */
export function applyRange(qs: URLSearchParams, range?: DateRange): URLSearchParams {
if (range?.from) qs.set("from", range.from);
if (range?.to) qs.set("to", range.to);
if ((range?.from || range?.to) && !qs.has("tz_offset")) {
qs.set("tz_offset", String(new Date().getTimezoneOffset()));
}
return qs;
}
+124
View File
@@ -0,0 +1,124 @@
/**
* @file replay.ts
* @description Pure derivations behind the session Replay tab: events in
* playback order, the lifecycle markers on its mini-map, and the state a
* session was in at a cursor (status, tool in flight, tool counts).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { AgentStatus, DashboardEvent } from "./types";
import { statusFromEventType } from "./event-grouping";
export const REPLAY_SPEEDS = [0.5, 1, 2, 5] as const;
/** One event per this many ms at 1× — playback walks the sequence, not the
* wall clock, so an hour-long pause does not stall the replay. */
export const REPLAY_STEP_MS = 500;
/** Event types drawn on the mini-map, in legend order. */
export const LIFECYCLE_TYPES = [
"SessionStart",
"UserPromptSubmit",
"Compaction",
"SubagentStop",
"APIError",
"Stop",
] as const;
/** Oldest first; `id` breaks timestamp ties. */
export function chronological(events: DashboardEvent[]): DashboardEvent[] {
return [...events].sort(
(a, b) => a.created_at.localeCompare(b.created_at) || (a.id ?? 0) - (b.id ?? 0)
);
}
export interface ReplayMarker {
index: number;
type: string;
/** Position on the mini-map, 0–100, matching the slider's scale. */
pct: number;
}
export function lifecycleMarkers(events: DashboardEvent[]): ReplayMarker[] {
const kinds = new Set<string>(LIFECYCLE_TYPES);
const last = Math.max(1, events.length - 1);
const out: ReplayMarker[] = [];
events.forEach((e, index) => {
if (kinds.has(e.event_type)) out.push({ index, type: e.event_type, pct: (index / last) * 100 });
});
return out;
}
export interface ReplayState {
status: AgentStatus | null;
/** The newest tool started and not yet finished at the cursor. */
currentTool: string | null;
/** Tool calls (PreToolUse) so far, most used first. */
toolCounts: { tool: string; count: number }[];
prompts: number;
errors: number;
compactions: number;
/** From the first event to the cursor's. */
elapsedMs: number;
}
/** The session as it stood after `events[cursor]` (events in playback order). */
export function replayState(events: DashboardEvent[], cursor: number): ReplayState {
const end = Math.min(cursor, events.length - 1);
const open: { agent: string; tool: string }[] = [];
const counts = new Map<string, number>();
let prompts = 0;
let errors = 0;
let compactions = 0;
for (let i = 0; i <= end; i++) {
const e = events[i]!;
const agent = e.agent_id ?? "";
switch (e.event_type) {
case "PreToolUse":
if (e.tool_name) {
open.push({ agent, tool: e.tool_name });
counts.set(e.tool_name, (counts.get(e.tool_name) ?? 0) + 1);
}
break;
case "PostToolUse":
case "PostToolUseFailure": {
for (let j = open.length - 1; j >= 0; j--) {
if (open[j]!.agent === agent && open[j]!.tool === e.tool_name) {
open.splice(j, 1);
break;
}
}
if (e.event_type === "PostToolUseFailure") errors++;
break;
}
case "Stop":
case "SubagentStop":
case "SessionEnd":
// A turn that ended leaves nothing of that agent in flight.
for (let j = open.length - 1; j >= 0; j--) if (open[j]!.agent === agent) open.splice(j, 1);
break;
case "UserPromptSubmit":
prompts++;
break;
case "APIError":
errors++;
break;
case "Compaction":
compactions++;
break;
}
}
const first = events[0];
const at = end >= 0 ? events[end] : undefined;
return {
status: at ? statusFromEventType(at.event_type) : null,
currentTool: open[open.length - 1]?.tool ?? null,
toolCounts: [...counts]
.map(([tool, count]) => ({ tool, count }))
.sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)),
prompts,
errors,
compactions,
elapsedMs:
first && at ? Math.max(0, Date.parse(at.created_at) - Date.parse(first.created_at)) : 0,
};
}
+26
View File
@@ -39,6 +39,20 @@ export const SETTINGS_INDEX: {
to: "#language",
keywords: ["english", "tiếng việt", "vietnamese", "ngôn ngữ"],
},
{
section: "data",
titleKey: "pushIngest.title",
descKey: "pushIngest.description",
keywords: ["token", "push", "remote", "batch"],
to: "#push-ingest",
},
{
section: "data",
titleKey: "privacy.title",
descKey: "privacy.description",
keywords: ["redact", "secrets", "ignore", "folder", "cwd"],
to: "#privacy",
},
{
section: "data",
titleKey: "lanesSection.title",
@@ -105,6 +119,18 @@ export const SETTINGS_INDEX: {
keywords: ["codex-pets", "upload", "import"],
to: "#pet",
},
{
section: "snapshots",
titleKey: "snapshots.title",
descKey: "snapshots.description",
keywords: ["share", "freeze", "link", "redact"],
},
{
section: "reports",
titleKey: "reports.title",
descKey: "reports.description",
keywords: ["schedule", "report", "email", "summary", "html", "json", "export"],
},
{ section: "appearance", titleKey: "appearance.mode", keywords: ["dark", "light", "system"] },
{ section: "appearance", titleKey: "appearance.darkThemes", keywords: ["theme", "colors"] },
{ section: "appearance", titleKey: "appearance.lightThemes" },
+147 -3
View File
@@ -731,8 +731,8 @@ export interface Session {
source?: string;
/** The AI coding CLI that produced the session: `"claude"` (the default —
* Claude Code's hooks and imported transcripts), `"codex"` (Codex's rollout
* journals and hooks) or `"antigravity"` (agy's conversation files and
* hooks). Attach/resume offer `claude` rows only. Maps to `sessions.tool`. */
* journals and hooks), `"antigravity"` (agy's conversation files and
* hooks) or `"cursor"` (Cursor's agent transcripts, no tokens). Attach/resume offer `claude` rows only. Maps to `sessions.tool`. */
tool?: string;
/** The workspace (lane id) that owns the session — the one whose pane held
* it; null for a session no workspace started.
@@ -1204,6 +1204,9 @@ export interface CostResult {
feature_costs?: CostFeatureCosts;
/** Present only when at least one model had usage but no pricing rule. */
unpriced_models?: UnpricedModel[];
/** Cost per AI tool (`sessions.tool`), most expensive first. Only
* GET /api/pricing/cost sends it, not the per-session route. */
by_tool?: Array<{ tool: string; cost: number }>;
}
/** GET /api/pricing/quota: plan quota spent per native tool (server/lib/quota.js). */
@@ -1533,7 +1536,13 @@ export interface CodexConfigChangedPayload {
/** Kind of condition an {@link AlertRule} evaluates. "event_pattern" and
* "token_threshold" are checked on every hook ingest; "inactivity" and
* "status_duration" are checked on a periodic server-side sweep. */
export type AlertRuleType = "event_pattern" | "inactivity" | "status_duration" | "token_threshold";
export type AlertRuleType =
| "event_pattern"
| "inactivity"
| "status_duration"
| "token_threshold"
| "cost_threshold"
| "budget";
/**
* Rule-type-specific settings for an {@link AlertRule}. Which fields apply
@@ -1544,6 +1553,9 @@ export type AlertRuleType = "event_pattern" | "inactivity" | "status_duration" |
* - inactivity: `minutes` of session silence before firing.
* - status_duration: `status` held continuously for `minutes`.
* - token_threshold: cumulative `total_tokens` for a session.
* - cost_threshold: a session's cost reaching `usd`.
* - budget: spend per `period` against `limit_usd`, firing each of
* `thresholds` (percent) once per period, optionally for one `tool`/`lane`.
*/
export interface AlertRuleConfig {
/** event_pattern: exact `DashboardEvent.event_type` to match. */
@@ -1565,6 +1577,55 @@ export interface AlertRuleConfig {
status?: "working" | "waiting";
/** token_threshold: cumulative token count that triggers the alert. */
total_tokens?: number;
/** cost_threshold: a session's cost in USD that triggers the alert. */
usd?: number;
/** budget: the period spend is measured over (local time). */
period?: "day" | "week" | "month";
/** budget: the period's limit in USD. */
limit_usd?: number;
/** budget: percents of the limit that fire, each once per period. */
thresholds?: number[];
/** budget: only this AI tool's sessions. */
tool?: string;
/** budget: only the sessions this workspace owns. */
lane?: number;
/** budget: the browser's getTimezoneOffset() when the rule was saved. */
tz_offset?: number;
}
/** One budget from GET /api/budgets/status: its rule plus current-period spend. */
export interface BudgetStatus {
id: string;
name: string;
config: AlertRuleConfig;
/** Local dates (inclusive) of the current period. */
period_start: string;
period_end: string;
spend: number;
/** The spend at the current pace, carried to the period's end. */
projected: number;
/** Spend as a percent of `limit_usd`. */
pct: number;
/** Thresholds already reached this period, lowest first. */
crossed: number[];
}
/** POST /api/alerts/rules/preview — what a rule would have fired, read-only. */
export interface RulePreview {
/** False for a type history cannot replay (status_duration). */
supported: boolean;
window_days: number;
fires: number;
sessions: number;
samples: Array<{ session_id: string; at: string } & Record<string, unknown>>;
/** budget only: the current period and the five before it. */
periods?: Array<{
period_start: string;
period_end: string;
spend: number;
pct: number;
crossed: number[];
}>;
}
/** A user-defined alert rule from GET/POST/PATCH /api/alerts/rules. Couples a
@@ -1927,6 +1988,89 @@ export interface SessionStats {
};
}
/** A row of `GET /api/snapshots` — a frozen session, listed without its payload. */
export interface SessionSnapshotInfo {
id: number;
/** The unguessable read token: `/snapshot/<token>`. */
token: string;
session_id: string;
created_at: string;
session_name?: string | null;
/** File paths were replaced at capture. */
redacted?: boolean;
event_count?: number;
/** Size of the stored payload, bytes. */
bytes?: number;
}
export type QueryEntity = "sessions" | "events" | "agents";
/** `GET /api/query` — one page of an entity's filtered rows. */
export interface QueryPage {
entity: QueryEntity;
/** Column names, in order (also the export's header). */
columns: string[];
/** The columns `sort` accepts. */
sortable: string[];
/** The values `status` accepts (none for events). */
statuses: string[];
rows: Record<string, string | number | null>[];
total: number;
limit: number;
offset: number;
/** An export holds at most this many rows. */
export_cap: number;
}
/** A scheduled report definition (`/api/reports`). */
export interface ReportInput {
name: string;
/** Full local days before the run day. */
window_days: number;
frequency: "daily" | "weekly";
/** Local hour, 0–23. */
hour: number;
/** 0 = Sunday; weekly only. */
weekday: number;
/** getTimezoneOffset() of the browser that saved it. */
tz_offset: number;
formats: ("html" | "json")[];
enabled: boolean;
}
export interface ReportRun {
id: number;
report_id: number;
created_at: string;
trigger: "schedule" | "manual";
status: "ok" | "error";
window_from: string | null;
window_to: string | null;
/** The formats written. */
files: string[];
error: string | null;
}
export interface ReportDefinition extends ReportInput {
id: number;
created_at: string;
last_slot: string | null;
last_run?: ReportRun | null;
}
/** What `POST /api/snapshots` froze: the session as it stood at `captured_at`. */
export interface SessionSnapshotPayload {
version: number;
captured_at: string;
redacted: { file_paths: boolean };
session: Session;
agents: Agent[];
/** Oldest first. */
events: DashboardEvent[];
stats: SessionStats;
cost: CostResult;
}
// ───── Workflow intelligence (events-derived) ─────
// The analytics panels on the Workflows page. Every type below is COMPUTED by
// the server from `DashboardEvent`/`Agent` rows (server/routes/workflows.js) -