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:
@@ -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()));
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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) -
|
||||
|
||||
Reference in New Issue
Block a user