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,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();
|
||||
});
|
||||
});
|
||||
@@ -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:<name>) 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 <HookEvent> — a Claude Code hook command:
|
||||
# "command": "sh ~/.claude/agenthub-push.sh PreToolUse"
|
||||
# ~/.claude/agenthub-push.header (chmod 600) holds one line:
|
||||
# Authorization: Bearer <token>
|
||||
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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user