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,95 @@
/**
* @file QueryExplorer.test.tsx
* @description The Query Explorer page: it renders a page of rows with session
* links, sorts by an allowed column on a header click (flipping on a second),
* switches entity, and points both export links at the current filter.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
const { query } = vi.hoisted(() => ({
query: {
page: vi.fn(),
exportUrl: (p: URLSearchParams, f: string) => `/api/query?${p.toString()}&format=${f}`,
},
}));
vi.mock("../../lib/api", () => ({ api: { query } }));
vi.mock("../../hooks/useAiTools", () => ({
useAiTools: () => ({ tools: [{ id: "codex", label: "Codex" }] }),
}));
import { QueryExplorer } from "../QueryExplorer";
const page = (entity: string, rows: Record<string, string>[]) => ({
entity,
columns: Object.keys(rows[0] ?? {}),
sortable: entity === "sessions" ? ["started_at", "name"] : ["created_at"],
statuses: [],
rows,
total: rows.length,
limit: 50,
offset: 0,
export_cap: 50000,
});
const lastParams = () => {
const calls = query.page.mock.calls;
return calls[calls.length - 1]![0] as URLSearchParams;
};
beforeEach(() => {
i18n.changeLanguage("en");
vi.clearAllMocks();
query.page.mockImplementation((p: URLSearchParams) =>
Promise.resolve(
p.get("entity") === "events"
? page("events", [{ id: "1", session_id: "sess-abcdef12", created_at: "2026-10-01" }])
: page("sessions", [{ id: "sess-abcdef12", name: "first", started_at: "2026-10-01" }])
)
);
});
describe("QueryExplorer", () => {
it("shows rows, sorts by an allowed column and exports the filter", async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<QueryExplorer />
</MemoryRouter>
);
expect(await screen.findByText("first")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "sess-abc" })).toHaveAttribute(
"href",
"/sessions/sess-abcdef12"
);
await user.click(screen.getByRole("button", { name: "name" }));
await waitFor(() => expect(lastParams().get("sort")).toBe("name"));
expect(lastParams().get("dir")).toBe("desc");
await user.click(screen.getByRole("button", { name: "name" }));
await waitFor(() => expect(lastParams().get("dir")).toBe("asc"));
// A column outside `sortable` has no sort button.
expect(screen.queryByRole("button", { name: "id" })).toBeNull();
const csv = screen.getByRole("link", { name: /Export CSV/ });
expect(csv.getAttribute("href")).toContain("sort=name");
expect(csv.getAttribute("href")).toContain("format=csv");
});
it("switches entity and drops the status filter for events", async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<QueryExplorer />
</MemoryRouter>
);
await screen.findByText("first");
expect(screen.getByRole("button", { name: /Status/ })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /^Data/ }));
await user.click(screen.getByRole("option", { name: "Events" }));
await waitFor(() => expect(lastParams().get("entity")).toBe("events"));
expect(screen.queryByRole("button", { name: /Status/ })).toBeNull();
});
});
+10 -2
View File
@@ -9,7 +9,7 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
@@ -44,6 +44,14 @@ vi.mock("../../lib/api", async (importOriginal) => {
clearData: () => clearData(),
gc: vi.fn(async () => ({ reaped: [], capped: [] })),
exportData: () => "/api/settings/export",
pushToken: { get: vi.fn(async () => ({ enabled: false, env_owned: false })) },
privacy: {
get: vi.fn(async () => ({
redact_secrets: false,
ignore_cwd: [],
env_owned: { redact_secrets: false, ignore_cwd: false },
})),
},
},
},
};
@@ -112,7 +120,7 @@ describe("Settings", () => {
const dialog = screen.getByRole("dialog", { name: /clear all data/i });
const yes = screen.getByRole("button", { name: /yes, clear all/i });
expect(yes).toBeDisabled();
await user.type(screen.getByRole("textbox"), "clear all data");
await user.type(within(dialog).getByRole("textbox"), "clear all data");
expect(yes).toBeEnabled();
await user.click(yes);
await waitFor(() => expect(clearData).toHaveBeenCalled());
@@ -0,0 +1,131 @@
/**
* @file SnapshotView.test.tsx
* @description The read-only `/snapshot/:token` page renders the frozen payload
* without reading live session data, and Settings → Snapshots lists and
* deletes snapshots behind a confirm.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { SnapshotView } from "../SnapshotView";
import { SnapshotSettings } from "../../components/settings/SnapshotSettings";
const stats = {
session_id: "s1",
total_events: 2,
events_by_type: [],
tools_used: [{ tool_name: "Read", count: 2 }],
error_count: 0,
first_event_at: "2026-10-01T10:00:00.000Z",
last_event_at: "2026-10-01T10:00:01.000Z",
agents: { total: 1, main: 1, subagent: 0, compaction: 0, by_status: {} },
subagent_types: [],
tokens: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0 },
};
const sessionStats = vi.fn();
const remove = vi.fn(() => Promise.resolve({ ok: true }));
vi.mock("../../lib/api", () => ({
api: {
sessions: { stats: (...a: unknown[]) => sessionStats(...a) },
snapshots: {
get: vi.fn(() =>
Promise.resolve({
snapshot: { id: 1, token: "tok", session_id: "s1", created_at: "2026-10-02T00:00:00Z" },
payload: {
version: 1,
captured_at: "2026-10-02T00:00:00.000Z",
redacted: { file_paths: true },
session: {
id: "s1",
name: "Frozen session",
status: "completed",
cwd: "[redacted]",
model: null,
started_at: "2026-10-01T10:00:00.000Z",
ended_at: null,
metadata: null,
},
agents: [],
events: [
{
id: 1,
session_id: "s1",
agent_id: null,
event_type: "SessionStart",
tool_name: null,
summary: "start",
data: null,
created_at: "2026-10-01T10:00:00.000Z",
},
{
id: 2,
session_id: "s1",
agent_id: null,
event_type: "Stop",
tool_name: null,
summary: "stop",
data: null,
created_at: "2026-10-01T10:00:01.000Z",
},
],
stats,
cost: { total_cost: 0, breakdown: [] },
},
})
),
list: vi.fn(() =>
Promise.resolve({
snapshots: [
{
id: 7,
token: "tok7",
session_id: "s1",
created_at: "2026-10-02T00:00:00Z",
session_name: "Frozen session",
redacted: true,
event_count: 2,
},
],
})
),
remove: (...a: unknown[]) => remove(...(a as [])),
},
},
}));
describe("SnapshotView", () => {
it("renders the frozen session and its replay, never the live stats", async () => {
render(
<MemoryRouter initialEntries={["/snapshot/tok"]}>
<Routes>
<Route path="/snapshot/:token" element={<SnapshotView />} />
</Routes>
</MemoryRouter>
);
expect(await screen.findByText("Frozen session")).toBeTruthy();
expect(screen.getByText("Paths redacted")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /Replay/ }));
expect(screen.getByTestId("event-replay")).toBeTruthy();
expect(sessionStats).not.toHaveBeenCalled();
});
});
describe("SnapshotSettings", () => {
it("lists snapshots and deletes one after confirming", async () => {
render(
<MemoryRouter>
<SnapshotSettings />
</MemoryRouter>
);
expect(await screen.findByText("Frozen session")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
const buttons = screen.getAllByRole("button", { name: "Delete" });
fireEvent.click(buttons[buttons.length - 1]!);
await waitFor(() => expect(remove).toHaveBeenCalledWith(7));
await waitFor(() => expect(screen.queryByText("Frozen session")).toBeNull());
});
});
@@ -3133,6 +3133,92 @@ exports[`screen snapshots > Dashboard 1`] = `
<div
class="flex items-center gap-2 flex-shrink-0"
>
<div
class="w-40"
>
<div
class="relative"
>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Date range"
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
title="Date range"
type="button"
>
<span
class="flex min-w-0 items-center gap-2"
>
<span
class="truncate"
>
All time
</span>
</span>
<svg
aria-hidden="true"
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 9 6 6 6-6"
/>
</svg>
</button>
</div>
</div>
<div
class="w-44"
>
<div
class="relative"
>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-label="AI tool"
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
title="AI tool"
type="button"
>
<span
class="flex min-w-0 items-center gap-2"
>
<span
class="truncate"
>
All AI tools
</span>
</span>
<svg
aria-hidden="true"
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 9 6 6 6-6"
/>
</svg>
</button>
</div>
</div>
<button
class="btn-ghost"
>
@@ -5296,6 +5382,110 @@ exports[`screen snapshots > Dashboard 1`] = `
No cost data yet.
</p>
</div>
<div
class="card p-5"
>
<h3
class="text-sm font-medium text-fg-secondary mb-5"
>
Spend pace
</h3>
<div
class="space-y-3"
>
<div
class="flex justify-between items-center py-2 border-b border-border last:border-0"
>
<span
class="text-xs text-fg-secondary"
title="Average cost per day over the last 7 days, today included"
>
Burn rate (7-day avg / day)
</span>
<span
class="text-sm font-mono font-medium text-status-success"
>
<span
class="relative inline-block cursor-default"
>
$0.00
</span>
</span>
</div>
<div
class="flex justify-between items-center py-2 border-b border-border last:border-0"
>
<span
class="text-xs text-fg-secondary"
>
Month to date
</span>
<span
class="text-sm font-mono font-medium text-status-success"
>
<span
class="relative inline-block cursor-default"
>
$0.00
</span>
</span>
</div>
<div
class="flex justify-between items-center py-2 border-b border-border last:border-0"
>
<span
class="text-xs text-fg-secondary"
title="Month to date plus the 7-day pace for each day left"
>
Forecast for this month
</span>
<span
class="text-sm font-mono font-medium text-status-success"
>
<span
class="relative inline-block cursor-default"
>
$0.00
</span>
</span>
</div>
</div>
</div>
<div
class="card p-5"
>
<h3
class="text-sm font-medium text-fg-secondary mb-5"
>
Budgets
</h3>
<p
class="text-sm text-fg-muted"
>
No spend budget yet.
<a
class="text-accent hover:underline"
href="/settings/notifications"
>
Add one in Settings → Notifications
</a>
</p>
</div>
<div
class="card p-5"
>
<h3
class="text-sm font-medium text-fg-secondary mb-5"
>
Cost by AI tool
</h3>
<p
class="text-sm text-fg-muted"
>
No cost data yet.
</p>
</div>
<div
class="card p-5"
>
@@ -5795,6 +5985,38 @@ exports[`screen snapshots > Session detail 1`] = `
</button>
</div>
</div>
<div
class="flex flex-wrap items-center justify-end gap-1"
>
<button
class="btn-ghost text-xs"
title="Freeze this session into a read-only copy with a share link"
type="button"
>
<svg
class="lucide lucide-camera h-4 w-4"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"
/>
<circle
cx="12"
cy="13"
r="3"
/>
</svg>
Snapshot
</button>
</div>
<button
aria-label="Refresh"
class="btn-ghost"
@@ -5930,6 +6152,52 @@ exports[`screen snapshots > Session detail 1`] = `
</svg>
Timeline (0/0)
</button>
<button
class="flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors border-transparent text-fg-muted hover:text-fg-secondary"
>
<svg
class="lucide lucide-film w-4 h-4"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<rect
height="18"
rx="2"
width="18"
x="3"
y="3"
/>
<path
d="M7 3v18"
/>
<path
d="M3 7.5h4"
/>
<path
d="M3 12h18"
/>
<path
d="M3 16.5h4"
/>
<path
d="M17 3v18"
/>
<path
d="M17 7.5h4"
/>
<path
d="M17 16.5h4"
/>
</svg>
Replay
</button>
</div>
<div>
<div
@@ -6913,7 +7181,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<p
class="text-[10px] text-status-warning/90 mt-1"
>
UI build v2.3.1 (rebuild client to match)
UI build v2.4.0 (rebuild client to match)
</p>
</div>
<div
@@ -8443,6 +8711,266 @@ exports[`screen snapshots > Settings — data 1`] = `
</div>
</div>
</section>
<div
class="scroll-mt-24"
id="push-ingest"
>
<section
class="card p-5"
>
<h3
class="text-sm font-semibold text-fg-primary"
>
<span
class="flex items-center gap-2"
>
<svg
class="lucide lucide-key-round w-4 h-4 text-fg-muted"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"
/>
<circle
cx="16.5"
cy="7.5"
fill="currentColor"
r=".5"
/>
</svg>
Push ingest
</span>
</h3>
<p
class="mt-0.5 text-xs text-fg-muted"
>
For a machine this dashboard cannot reach over SSH: it pushes its own hooks to POST /api/hooks/ingest-batch with a token made here. Its sessions are tagged with the name it sends (push:&lt;name&gt;) and show live status and events; their token cost needs the transcripts, which only an SSH source brings.
</p>
<div
class="mt-2"
>
<div
class="space-y-3 py-2"
>
<div
class="flex flex-wrap items-center gap-2"
>
<span
class="text-xs text-fg-muted"
>
Off — no token yet
</span>
<button
class="btn-primary text-xs disabled:opacity-40"
type="button"
>
<svg
class="lucide lucide-refresh-cw w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"
/>
<path
d="M21 3v5h-5"
/>
<path
d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"
/>
<path
d="M8 16H3v5"
/>
</svg>
Create token
</button>
</div>
<p
class="text-xs text-fg-muted"
>
The other machine must reach this dashboard: start it with DASHBOARD_HOST (and DASHBOARD_ALLOWED_HOSTS for its name). Over plain http the token crosses the network readable — use it only on a network you trust, or behind TLS. The forwarder below keeps the token in a header file, never on a command line:
</p>
<div
class="relative"
>
<pre
class="text-[11px] font-mono bg-surface-1 border border-border rounded-lg p-3 overflow-x-auto text-fg-secondary"
>
#!/bin/sh
# ~/.claude/agenthub-push.sh &lt;HookEvent&gt; — a Claude Code hook command:
# "command": "sh ~/.claude/agenthub-push.sh PreToolUse"
# ~/.claude/agenthub-push.header (chmod 600) holds one line:
# Authorization: Bearer &lt;token&gt;
jq -c --arg t "$1" --arg s "$(hostname -s)" \\
'{source: $s, events: [{hook_type: $t, data: .}]}' |
curl -s -m 5 -o /dev/null -X POST "http://localhost/api/hooks/ingest-batch" \\
-H "@$HOME/.claude/agenthub-push.header" \\
-H "Content-Type: application/json" --data-binary @-
exit 0
</pre>
<button
class="btn-ghost text-xs absolute top-1.5 right-1.5"
title="Copy"
type="button"
>
<svg
class="lucide lucide-copy w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<rect
height="14"
rx="2"
ry="2"
width="14"
x="8"
y="8"
/>
<path
d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
/>
</svg>
</button>
</div>
<p
class="text-xs text-fg-muted"
>
At most 500 events and 1 MB per request. A hook for a session another source owns is refused; transcript paths are dropped (they name files on the sender). Codex hooks are taken; Antigravity hooks are not.
</p>
</div>
</div>
</section>
</div>
<div
class="scroll-mt-24"
id="privacy"
>
<section
class="card p-5"
>
<h3
class="text-sm font-semibold text-fg-primary"
>
<span
class="flex items-center gap-2"
>
<svg
class="lucide lucide-shield-check w-4 h-4 text-fg-muted"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"
/>
<path
d="m9 12 2 2 4-4"
/>
</svg>
Privacy
</span>
</h3>
<p
class="mt-0.5 text-xs text-fg-muted"
>
What AgentHub keeps from hooks and imported history. Both are off by default and apply to new data only.
</p>
<div
class="mt-2"
>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="min-w-0"
>
<p
class="text-sm text-fg-secondary"
>
Redact secrets before storing
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
>
API keys and tokens of known shapes, bearer headers, private keys and password=/TOKEN= values in event payloads and summaries become [redacted]. The transcripts on disk are the CLIs' own files and are not changed.
</p>
</div>
<div
class="shrink-0"
>
<button
aria-checked="false"
aria-label="Redact secrets before storing"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80"
role="switch"
type="button"
>
<span
aria-hidden="true"
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
/>
</button>
</div>
</div>
<div
class="py-3 space-y-2"
>
<label
class="block text-sm text-fg-secondary"
for="privacy-ignore"
>
Ignored folders
</label>
<p
class="text-xs text-fg-muted"
>
One absolute path per line. Hooks from these folders, and anything under them, are dropped whole; history import and the Codex reader skip their sessions. AGENTHUB_IGNORE_CWD sets the same list.
</p>
<textarea
class="input w-full font-mono text-xs min-h-[72px]"
id="privacy-ignore"
placeholder="/home/you/clients/secret-project
~/private"
/>
<button
class="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled=""
type="button"
>
Save
</button>
</div>
</div>
</section>
</div>
<section
class="card p-5 space-y-4 scroll-mt-24"
id="cleanup"
@@ -318,6 +318,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
totalCost: r(cost),
sessionCost: r(cost),
},
budgets: { status: r({ budgets: [] }) },
settings: {
info: r(settingsInfo),
claudeHome: {
@@ -328,6 +329,19 @@ vi.mock("../../lib/api", async (importOriginal) => {
reimport: r({ ok: true, imported: 0, skipped: 0, errors: 0 }),
reinstallHooks: r({ ok: true, hooks: { installed: true, hooks: {} } }),
resetPricing: r({ ok: true, pricing: [] }),
pushToken: {
get: r({ enabled: false, env_owned: false }),
rotate: r({ token: "t", enabled: true, env_owned: false }),
revoke: r({ enabled: false, env_owned: false }),
},
privacy: {
get: r({
redact_secrets: false,
ignore_cwd: [],
env_owned: { redact_secrets: false, ignore_cwd: false },
}),
save: r({ ok: true }),
},
exportData: () => "/api/settings/export",
cleanup: r({
ok: true,
@@ -483,6 +497,7 @@ vi.mock("../../lib/push", () => ({
}));
// Page components (imported after the mocks above; vi.mock is hoisted).
import { api } from "../../lib/api";
import { Dashboard } from "../Dashboard";
import { Sessions } from "../Sessions";
import { SessionDetail } from "../SessionDetail";
@@ -570,6 +585,19 @@ describe("screen snapshots", () => {
const headings = [...container.querySelectorAll("h2")].map((h) => h.textContent);
expect(headings).toContain(i18n.t("workflows:title"));
});
it("Dashboard Analytics reads its date range from ?range=", async () => {
localStorage.removeItem("dashboard_tab");
render(
<MemoryRouter initialEntries={["/?tab=analytics&range=7d"]}>
<Dashboard />
</MemoryRouter>
);
await settle();
const range = { from: "2026-06-04", to: "2026-06-10" };
expect(api.analytics.get).toHaveBeenCalledWith({ tool: "", range });
expect(api.pricing.totalCost).toHaveBeenCalledWith({ tool: "", range });
expect(screen.getByRole("button", { name: /Date range/ })).toHaveTextContent("Last 7 days");
});
it("Sessions", async () => {
await snapshot(<Sessions />, "/sessions");
});