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