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
@@ -0,0 +1,102 @@
/**
* @file Tests for the alert rule form's spend rules: a budget rule is built
* from period / limit / thresholds / AI tool (plus the browser's timezone
* offset), Preview shows what it would have fired without saving it, and
* Create saves the same config.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
vi.mock("../../lib/api", () => ({
api: {
alerts: {
list: vi.fn().mockResolvedValue({ alerts: [], total: 0, unacked: 0, limit: 50, offset: 0 }),
rules: {
list: vi.fn().mockResolvedValue({ rules: [] }),
create: vi.fn().mockResolvedValue({ rule: {} }),
preview: vi.fn().mockResolvedValue({
supported: true,
window_days: 7,
fires: 1,
sessions: 0,
samples: [],
periods: [
{
period_start: "2026-09-01",
period_end: "2026-09-30",
spend: 120,
pct: 120,
crossed: [80, 100],
},
{
period_start: "2026-10-01",
period_end: "2026-10-31",
spend: 10,
pct: 10,
crossed: [],
},
],
}),
},
},
aiTools: {
list: vi.fn().mockResolvedValue({
tools: [{ id: "codex", label: "Codex" }],
default: "claude",
}),
},
},
}));
import { api } from "../../lib/api";
import { AlertsNotifications } from "../AlertsNotifications";
describe("AlertsNotifications — spend rules", () => {
it("previews and creates a budget rule", async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<AlertsNotifications />
</MemoryRouter>
);
await user.click(await screen.findByText("Add rule"));
await user.type(screen.getByPlaceholderText("e.g. Error burst"), "Monthly");
const selects = screen.getAllByRole("combobox");
await user.selectOptions(selects[0]!, "budget");
await user.selectOptions(screen.getByDisplayValue("month"), "week");
const limit = screen.getByDisplayValue("100");
await user.clear(limit);
await user.type(limit, "50");
await screen.findByRole("option", { name: "Codex" });
await user.selectOptions(screen.getByDisplayValue("All AI tools"), "codex");
await user.click(screen.getByRole("button", { name: /Preview/ }));
const config = {
period: "week",
limit_usd: 50,
thresholds: [80, 100],
tz_offset: new Date().getTimezoneOffset(),
tool: "codex",
};
expect(api.alerts.rules.preview).toHaveBeenCalledWith({
rule_type: "budget",
config,
cooldown_seconds: 300,
});
expect(await screen.findByText("2026-09-01")).toBeInTheDocument();
expect(screen.getByText("80% 100%")).toBeInTheDocument();
expect(api.alerts.rules.create).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: /Create rule/ }));
expect(api.alerts.rules.create).toHaveBeenCalledWith({
name: "Monthly",
rule_type: "budget",
config,
cooldown_seconds: 300,
});
});
});
@@ -0,0 +1,93 @@
/**
* @file EventReplay.test.tsx
* @description The Replay tab's controls: step, jump, arrow keys, a lifecycle
* marker jumping the cursor, playback advancing on a timer, and the derived
* state following the cursor.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { EventReplay } from "../EventReplay";
import type { DashboardEvent } from "../../lib/types";
function ev(id: number, event_type: string, tool_name: string | null = null): DashboardEvent {
return {
id,
session_id: "s1",
agent_id: "a1",
event_type,
tool_name,
summary: `${event_type} ${tool_name ?? ""}`.trim(),
data: null,
created_at: new Date(Date.UTC(2026, 9, 1, 10, 0, id)).toISOString(),
} as DashboardEvent;
}
// Given newest first, as the events API answers: the replay orders them itself.
const events = [
ev(4, "Stop"),
ev(3, "PostToolUse", "Bash"),
ev(2, "PreToolUse", "Bash"),
ev(1, "SessionStart"),
];
function renderReplay() {
return render(
<MemoryRouter>
<EventReplay events={events} agentInfoById={new Map()} sessionNameById={new Map()} />
</MemoryRouter>
);
}
const position = () => screen.getByText(/^\d+ \/ 4/);
afterEach(() => vi.useRealTimers());
describe("EventReplay", () => {
it("starts at the first event and steps with the buttons", () => {
renderReplay();
expect(position().textContent).toMatch(/^1 \/ 4/);
fireEvent.click(screen.getByTitle("Step forward (→)"));
fireEvent.click(screen.getByTitle("Step forward (→)"));
expect(position().textContent).toMatch(/^3 \/ 4/);
expect(screen.getByText("Bash ×1")).toBeTruthy();
fireEvent.click(screen.getByTitle("Jump to end (End)"));
expect(position().textContent).toMatch(/^4 \/ 4/);
fireEvent.click(screen.getByTitle("Jump to start (Home)"));
expect(position().textContent).toMatch(/^1 \/ 4/);
});
it("moves on arrow keys and jumps from a lifecycle marker", () => {
renderReplay();
const root = screen.getByTestId("event-replay");
fireEvent.keyDown(root, { key: "ArrowRight" });
expect(position().textContent).toMatch(/^2 \/ 4/);
// The in-flight tool at the PreToolUse.
expect(screen.getByText("Running tool").nextSibling?.textContent).toBe("Bash");
fireEvent.keyDown(root, { key: "End" });
expect(position().textContent).toMatch(/^4 \/ 4/);
fireEvent.click(screen.getByRole("button", { name: "Session start · #1" }));
expect(position().textContent).toMatch(/^1 \/ 4/);
});
it("plays to the end at the chosen speed", () => {
vi.useFakeTimers();
renderReplay();
fireEvent.click(screen.getByRole("button", { name: "5×" }));
fireEvent.click(screen.getByTitle("Play (Space)"));
act(() => {
vi.advanceTimersByTime(100);
});
expect(position().textContent).toMatch(/^2 \/ 4/);
// One step per tick: each step schedules the next.
for (let i = 0; i < 3; i++) {
act(() => {
vi.advanceTimersByTime(100);
});
}
expect(position().textContent).toMatch(/^4 \/ 4/);
expect(screen.getByTitle("Play (Space)")).toBeTruthy();
});
});
@@ -3,7 +3,8 @@
* @description Responsive shell: with no stored preference the sidebar starts
* collapsed below `lg`, and below `md` it becomes an off-canvas drawer opened
* from a menu button — a modal dialog that Escape and the backdrop close,
* handing focus back to the button.
* handing focus back to the button. The expanded sidebar's edge resizes it
* within its bounds and remembers the width.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -16,7 +17,7 @@ vi.mock("../UpdateNotifier", () => ({ UpdateNotifier: () => null }));
vi.mock("../StatusBar", () => ({ StatusBar: () => null }));
vi.mock("../WorkspaceTree", () => ({ WorkspaceTree: () => null }));
import { Layout, DRAWER_QUERY } from "../Layout";
import { Layout, DRAWER_QUERY, SIDEBAR_MIN, SIDEBAR_MAX } from "../Layout";
import { loadCollapsed, SIDEBAR_STORAGE_KEY } from "../Sidebar";
function viewport(width: number) {
@@ -34,6 +35,7 @@ function viewport(width: number) {
afterEach(() => {
vi.unstubAllGlobals();
localStorage.removeItem(SIDEBAR_STORAGE_KEY);
localStorage.removeItem("agenthub.sidebar.width");
});
describe("loadCollapsed", () => {
@@ -95,3 +97,47 @@ describe("Layout drawer", () => {
expect(screen.getByRole("main").style.marginLeft).toBe("4.25rem");
});
});
describe("Layout sidebar width", () => {
it("the edge drags within bounds, is remembered, and a double-click resets it", () => {
viewport(1440);
vi.stubGlobal("innerWidth", 2560);
render(
<MemoryRouter>
<Layout wsConnected />
</MemoryRouter>
);
const edge = screen.getByTestId("sidebar-resize");
const main = screen.getByRole("main");
expect(main.style.marginLeft).toBe(`${SIDEBAR_MIN}px`);
fireEvent.pointerDown(edge, { pointerId: 1 });
fireEvent.pointerMove(edge, { clientX: 360 });
fireEvent.pointerUp(edge, { clientX: 360 });
expect(main.style.marginLeft).toBe("360px");
expect(screen.getByTestId("sidebar").style.width).toBe("360px");
expect(localStorage.getItem("agenthub.sidebar.width")).toBe("360");
fireEvent.pointerDown(edge, { pointerId: 1 });
fireEvent.pointerUp(edge, { clientX: 2000 });
expect(main.style.marginLeft).toBe(`${SIDEBAR_MAX}px`);
fireEvent.keyDown(edge, { key: "ArrowLeft" });
expect(main.style.marginLeft).toBe(`${SIDEBAR_MAX - 16}px`);
fireEvent.doubleClick(edge);
expect(main.style.marginLeft).toBe(`${SIDEBAR_MIN}px`);
});
it("never wider than 40% of the window", () => {
viewport(1100);
vi.stubGlobal("innerWidth", 1100);
localStorage.setItem("agenthub.sidebar.width", "480");
render(
<MemoryRouter>
<Layout wsConnected />
</MemoryRouter>
);
expect(screen.getByRole("main").style.marginLeft).toBe("440px");
});
});
@@ -111,6 +111,21 @@ vi.mock("../../lib/api", () => ({
daily_costs: [],
})),
},
budgets: {
status: vi.fn().mockResolvedValue({
budgets: [
{ id: "b1", name: "Quiet", config: { limit_usd: 100 }, spend: 10, pct: 10, crossed: [] },
{
id: "b2",
name: "Monthly",
config: { limit_usd: 100 },
spend: 85,
pct: 85,
crossed: [80],
},
],
}),
},
},
}));
@@ -142,6 +157,14 @@ describe("StatusBar", () => {
expect(screen.getByRole("status")).toHaveTextContent("Live · 1 need you");
});
it("flags the budget furthest past a threshold, linking to the cost analytics", async () => {
at("/", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
const chip = await screen.findByTestId("status-bar-budget");
expect(chip.textContent).toBe("Budget 85%");
expect(chip.getAttribute("href")).toBe("/?tab=analytics&sub=cost");
expect(chip.getAttribute("title")).toBe("Monthly: $85.00 of $100.00 this period");
});
it("says Disconnected when the socket is down", () => {
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument();