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
+186 -6
View File
@@ -107,9 +107,15 @@ import { AgentStatusBadge } from "../components/StatusBadge";
import { EmptyState } from "../components/EmptyState";
import { Tip } from "../components/Tip";
import { Skeleton } from "../components/Skeleton";
import { Select } from "../components/Select";
import { AiToolIcon } from "../components/AiToolIcon";
import { useAiTools } from "../hooks/useAiTools";
import { spendPace } from "../lib/burnRate";
import { RANGE_PRESETS, parsePreset, rangeDates } from "../lib/dateRange";
import { timeAgo, fmt, fmtCost, fmtCostFull, formatModelName } from "../lib/format";
import { statusFromEventType } from "../lib/event-grouping";
import type {
BudgetStatus,
Stats,
Agent,
DashboardEvent,
@@ -700,6 +706,7 @@ function AnalyticsTab() {
const { t, i18n } = useTranslation("analytics");
const [data, setData] = useState<AnalyticsData | null>(null);
const [costData, setCostData] = useState<CostResult | null>(null);
const [budgets, setBudgets] = useState<BudgetStatus[]>([]);
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
const [loading, setLoading] = useState(true);
// `?sub=` deep-links a sub-tab (the old /workflows route lands on `workflow`).
@@ -723,6 +730,28 @@ function AnalyticsTab() {
);
// Global data scope; a change re-runs `load` (api injects the `sources` param).
const [scope] = useDataScope();
// One AI tool's sessions only ("" = every tool); a change re-runs `load`.
const [tool, setTool] = useState("");
const aiTools = useAiTools();
// The date range, kept in the URL (`?range=`, plus `from`/`to` when custom).
// Headline numbers, cost/token cards and by-model/by-tool follow it; the
// 52-week and 30-day charts keep their own windows.
const preset = parsePreset(params.get("range"));
const customFrom = params.get("from") ?? "";
const customTo = params.get("to") ?? "";
const { from: rangeFrom, to: rangeTo } = rangeDates(preset, { from: customFrom, to: customTo });
const range = useMemo(() => ({ from: rangeFrom, to: rangeTo }), [rangeFrom, rangeTo]);
const setRangeParam = (patch: Record<string, string>) =>
setParams(
(p) => {
for (const [k, v] of Object.entries(patch)) {
if (v) p.set(k, v);
else p.delete(k);
}
return p;
},
{ replace: true }
);
const [loadError, setLoadError] = useState<string | null>(null);
// Responses can land out of order (a slow poll after a fast refresh); only
@@ -731,14 +760,16 @@ function AnalyticsTab() {
const load = useCallback(async () => {
const seq = ++loadSeq.current;
try {
const [result, cost] = await Promise.all([
api.analytics.get(),
api.pricing.totalCost().catch(() => null),
const [result, cost, budgetRes] = await Promise.all([
api.analytics.get({ tool, range }),
api.pricing.totalCost({ tool, range }).catch(() => null),
api.budgets.status().catch(() => null),
]);
if (seq !== loadSeq.current) return;
setData(result);
setCostData(cost);
setLoadError(null);
if (budgetRes) setBudgets(budgetRes.budgets);
setLastUpdate(new Date());
} catch (err) {
if (seq === loadSeq.current)
@@ -746,7 +777,7 @@ function AnalyticsTab() {
} finally {
if (seq === loadSeq.current) setLoading(false);
}
}, [scope]);
}, [scope, tool, range]);
useEffect(() => {
load();
@@ -861,6 +892,7 @@ function AnalyticsTab() {
dailyCostLast30[0] ?? { date: "", cost: 0 }
);
const totalCost30d = dailyCostLast30.reduce((sum, day) => sum + day.cost, 0);
const pace = spendPace(dailyCostsLocal, localDateStr(today));
const costBreakdown = [...(costData?.breakdown ?? [])]
.filter((b) => b.cost > 0)
.sort((a, b) => b.cost - a.cost);
@@ -985,6 +1017,53 @@ function AnalyticsTab() {
{lastUpdate.toLocaleTimeString()}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="w-40">
<Select
value={preset}
onChange={(v) =>
setRangeParam(
v === "custom"
? { range: v, from: rangeFrom ?? "", to: rangeTo ?? "" }
: { range: v === "all" ? "" : v, from: "", to: "" }
)
}
label={t("range")}
options={RANGE_PRESETS.map((v) => ({ value: v, label: t(`rangePreset.${v}`) }))}
/>
</div>
{preset === "custom" && (
<>
<input
type="date"
className="input text-sm"
value={customFrom}
max={customTo || undefined}
onChange={(e) => setRangeParam({ from: e.target.value })}
aria-label={t("rangeFrom")}
title={t("rangeFrom")}
/>
<input
type="date"
className="input text-sm"
value={customTo}
min={customFrom || undefined}
onChange={(e) => setRangeParam({ to: e.target.value })}
aria-label={t("rangeTo")}
title={t("rangeTo")}
/>
</>
)}
<div className="w-44">
<Select
value={tool}
onChange={setTool}
label={t("toolFilter")}
options={[
{ value: "", label: t("allTools") },
...aiTools.tools.map((x) => ({ value: x.id, label: x.label })),
]}
/>
</div>
<button onClick={load} className="btn-ghost" disabled={loading}>
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
{t("common:refresh")}
@@ -1353,6 +1432,107 @@ function AnalyticsTab() {
)}
</div>
{/* Spend pace: burn rate, month to date, month-end forecast */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("spendPace")}</h3>
<div className="space-y-3">
{[
{ label: t("burnRate"), value: pace.perDay7, hint: t("burnRateHint") },
{ label: t("monthToDate"), value: pace.monthToDate },
{
label: t("forecastMonth"),
value: pace.forecastMonth,
hint: t("forecastHint"),
},
].map(({ label, value, hint }) => (
<div
key={label}
className="flex justify-between items-center py-2 border-b border-border last:border-0"
>
<span className="text-xs text-fg-secondary" title={hint}>
{label}
</span>
<span className="text-sm font-mono font-medium text-status-success">
<Tip raw={fmtCostFull(value)}>{fmtCost(value)}</Tip>
</span>
</div>
))}
</div>
</div>
{/* Budgets (budget alert rules) */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("budgets")}</h3>
{budgets.length === 0 ? (
<p className="text-sm text-fg-muted">
{t("noBudgets")}{" "}
<Link to="/settings/notifications" className="text-accent hover:underline">
{t("addBudget")}
</Link>
</p>
) : (
<div className="space-y-4">
{budgets.map((b) => {
const over = b.pct >= 100;
const warn = b.crossed.length > 0;
return (
<div key={b.id} className="space-y-1.5">
<div className="flex justify-between text-xs gap-2">
<span className="text-fg-secondary truncate" title={b.name}>
{b.name}
</span>
<span className="font-mono text-fg-secondary flex-shrink-0">
{fmtCost(b.spend)} / {fmtCost(b.config.limit_usd ?? 0)}
</span>
</div>
<div className="bg-surface-3 rounded-full h-2">
<div
className={`h-2 rounded-full ${over ? "bg-status-danger" : warn ? "bg-status-warning" : "bg-status-success"}`}
style={{ width: `${Math.min(100, Math.max(2, b.pct))}%` }}
/>
</div>
<div className="flex justify-between text-[11px] text-fg-muted">
<span>
{b.period_start} → {b.period_end}
</span>
<span title={t("projectedHint")}>
{t("projected")} {fmtCost(b.projected)}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Cost by AI tool */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("costByTool")}</h3>
{(costData?.by_tool?.length ?? 0) > 0 ? (
<div className="space-y-3">
{(costData?.by_tool ?? []).map((b) => (
<div key={b.tool} className="flex items-center gap-2">
<AiToolIcon
tool={{ id: b.tool, label: aiTools.label(b.tool) }}
className="h-5 w-5"
/>
<div className="flex-1 min-w-0">
<CostBarRow
label={aiTools.label(b.tool)}
cost={b.cost}
max={Math.max(costData?.by_tool?.[0]?.cost ?? 0, 0.0001)}
color="bg-accent"
/>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-fg-muted">{t("noCostData")}</p>
)}
</div>
{/* Cost by weekday */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-1">
@@ -1471,7 +1651,7 @@ function AnalyticsTab() {
{/* The former Workflows page, folded into this sub-tab. */}
<RouteErrorBoundary>
<Suspense fallback={<AnalyticsChartsSkeleton />}>
<Workflows embedded />
<Workflows embedded tool={tool || undefined} range={range} />
</Suspense>
</RouteErrorBoundary>
</div>
@@ -1605,7 +1785,7 @@ function SystemHealthTab() {
try {
const [infoRes, workflowRes] = await Promise.all([
api.settings.info(),
api.workflows.get(undefined, HEALTH_SECTIONS),
api.workflows.get(undefined, undefined, undefined, HEALTH_SECTIONS),
]);
setInfo(infoRes as any);
setWorkflow(workflowRes);
+341
View File
@@ -0,0 +1,341 @@
/**
* @file QueryExplorer.tsx
* @description `/query`: filter sessions, events or agents (free text, AI tool,
* status, local date range, folder subtree, plus the global data scope), sort
* by a column the server allows, page through the rows, and export the whole
* filtered set as CSV or JSON (`GET /api/query`, capped server-side).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, Download, RotateCcw } from "lucide-react";
import { api } from "../lib/api";
import { applyRange } from "../lib/dateRange";
import { useDataScope } from "../lib/dataScope";
import { useAiTools } from "../hooks/useAiTools";
import { Select } from "../components/Select";
import type { QueryEntity, QueryPage } from "../lib/types";
const PAGE_SIZE = 50;
const ENTITIES: QueryEntity[] = ["sessions", "events", "agents"];
const STATUSES: Record<QueryEntity, string[]> = {
sessions: ["active", "completed", "error", "abandoned"],
events: [],
agents: ["working", "waiting", "completed", "error"],
};
export function QueryExplorer() {
const { t } = useTranslation("analytics");
const aiTools = useAiTools();
const [scope] = useDataScope();
const [entity, setEntity] = useState<QueryEntity>("sessions");
const [q, setQ] = useState("");
const [cwd, setCwd] = useState("");
const [typed, setTyped] = useState({ q: "", cwd: "" });
const [tool, setTool] = useState("");
const [status, setStatus] = useState("");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [sort, setSort] = useState("");
const [dir, setDir] = useState<"asc" | "desc">("desc");
const [offset, setOffset] = useState(0);
const [page, setPage] = useState<QueryPage | null>(null);
const [error, setError] = useState<string | null>(null);
// Text filters wait for a pause in typing.
useEffect(() => {
const h = setTimeout(() => {
const next = { q: q.trim(), cwd: cwd.trim() };
setTyped((prev) => (prev.q === next.q && prev.cwd === next.cwd ? prev : next));
setOffset(0);
}, 300);
return () => clearTimeout(h);
}, [q, cwd]);
/** Every filter change starts over at the first page. */
const fresh =
<T,>(set: (v: T) => void) =>
(v: T) => {
set(v);
setOffset(0);
};
const filters = useMemo(() => {
const p = new URLSearchParams({ entity });
if (typed.q) p.set("q", typed.q);
if (typed.cwd) p.set("cwd", typed.cwd);
if (tool) p.set("tool", tool);
if (status) p.set("status", status);
if (sort) {
p.set("sort", sort);
p.set("dir", dir);
}
return applyRange(p, { from: from || undefined, to: to || undefined });
}, [entity, typed, tool, status, sort, dir, from, to]);
useEffect(() => {
let live = true;
const p = new URLSearchParams(filters);
p.set("limit", String(PAGE_SIZE));
p.set("offset", String(offset));
api.query
.page(p)
.then((r) => {
if (!live) return;
setPage(r);
setError(null);
})
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
return () => {
live = false;
};
}, [filters, offset, scope]);
const switchEntity = (v: QueryEntity) => {
setEntity(v);
setStatus("");
setSort("");
setDir("desc");
setOffset(0);
setPage(null);
};
const reset = () => {
setQ("");
setCwd("");
setTool("");
setStatus("");
setFrom("");
setTo("");
setSort("");
setDir("desc");
setOffset(0);
};
const sortedBy = sort || page?.sortable[0] || "";
// A click on the current column flips it; another column starts descending.
const sortOn = (col: string) => {
setDir(col === sortedBy && dir === "desc" ? "asc" : "desc");
setSort(col);
setOffset(0);
};
const total = page?.total ?? 0;
const cap = page?.export_cap ?? 50_000;
const statuses = STATUSES[entity];
return (
<div className="animate-fade-in space-y-5">
<div>
<h1 className="text-lg font-semibold text-fg-primary">{t("query.title")}</h1>
<p className="text-sm text-fg-muted">{t("query.subtitle")}</p>
</div>
<div className="card p-4 flex flex-wrap items-end gap-3">
<div className="w-36">
<Select
value={entity}
onChange={switchEntity}
label={t("query.entity")}
options={ENTITIES.map((v) => ({ value: v, label: t(`query.entities.${v}`) }))}
/>
</div>
<input
className="input text-sm w-56"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t("query.searchPlaceholder")}
aria-label={t("query.search")}
/>
<div className="w-40">
<Select
value={tool}
onChange={fresh(setTool)}
label={t("toolFilter")}
options={[
{ value: "", label: t("allTools") },
...aiTools.tools.map((x) => ({ value: x.id, label: x.label })),
]}
/>
</div>
{statuses.length > 0 && (
<div className="w-40">
<Select
value={status}
onChange={fresh(setStatus)}
label={t("query.status")}
options={[
{ value: "", label: t("query.allStatuses") },
...statuses.map((s) => ({ value: s, label: t(`common:status.${s}`) })),
]}
/>
</div>
)}
<input
type="date"
className="input text-sm"
value={from}
max={to || undefined}
onChange={(e) => fresh(setFrom)(e.target.value)}
aria-label={t("query.from")}
title={t("query.from")}
/>
<input
type="date"
className="input text-sm"
value={to}
min={from || undefined}
onChange={(e) => fresh(setTo)(e.target.value)}
aria-label={t("query.to")}
title={t("query.to")}
/>
<input
className="input text-sm w-56 font-mono"
value={cwd}
onChange={(e) => setCwd(e.target.value)}
placeholder={t("query.cwdPlaceholder")}
aria-label={t("query.cwd")}
title={t("query.cwd")}
/>
<button type="button" className="btn-ghost" onClick={reset} title={t("query.reset")}>
<RotateCcw className="w-4 h-4" />
{t("query.reset")}
</button>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-fg-muted">
{total > cap
? t("query.exportTruncated", {
total: total.toLocaleString(),
cap: cap.toLocaleString(),
})
: t("query.exportNote", { cap: cap.toLocaleString() })}
</p>
<div className="flex items-center gap-2">
<a className="btn-ghost" href={api.query.exportUrl(filters, "csv")} download>
<Download className="w-4 h-4" />
{t("query.exportCsv")}
</a>
<a className="btn-ghost" href={api.query.exportUrl(filters, "json")} download>
<Download className="w-4 h-4" />
{t("query.exportJson")}
</a>
</div>
</div>
{error && (
<p role="alert" className="text-sm text-status-danger">
{error}
</p>
)}
{page && page.rows.length === 0 ? (
<p className="py-10 text-center text-sm text-fg-muted">{t("query.empty")}</p>
) : (
page && (
<div className="card overflow-x-auto">
<table className="w-full text-sm" data-testid="query-table">
<thead>
<tr className="border-b border-border text-left">
{page.columns.map((c) => {
const sortable = page.sortable.includes(c);
return (
<th
key={c}
className="px-3 py-2 text-[11px] font-semibold text-fg-muted font-mono"
aria-sort={
c === sortedBy ? (dir === "asc" ? "ascending" : "descending") : undefined
}
>
{sortable ? (
<button
type="button"
onClick={() => sortOn(c)}
className="inline-flex items-center gap-1 hover:text-fg-primary"
title={t("query.sortBy", { column: c })}
>
{c}
{c === sortedBy &&
(dir === "asc" ? (
<ArrowUp className="w-3 h-3" />
) : (
<ArrowDown className="w-3 h-3" />
))}
</button>
) : (
c
)}
</th>
);
})}
</tr>
</thead>
<tbody className="divide-y divide-border">
{page.rows.map((r, i) => (
<tr key={`${r.id}-${i}`} className="hover:bg-surface-4">
{page.columns.map((c) => {
const v = r[c];
const sid =
(entity === "sessions" && c === "id") || c === "session_id" ? v : null;
return (
<td
key={c}
className="px-3 py-1.5 max-w-[22rem] truncate text-fg-secondary"
title={v == null ? undefined : String(v)}
>
{sid ? (
<Link
to={`/sessions/${encodeURIComponent(String(sid))}`}
className="font-mono text-accent hover:underline"
>
{String(sid).slice(0, 8)}
</Link>
) : (
(v ?? "")
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)
)}
{page && total > 0 && (
<div className="flex items-center justify-end gap-2 text-xs text-fg-muted">
<span>
{t("query.rows", {
from: (offset + 1).toLocaleString(),
to: Math.min(offset + PAGE_SIZE, total).toLocaleString(),
total: total.toLocaleString(),
})}
</span>
<button
type="button"
className="btn-ghost"
disabled={offset === 0}
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
title={t("query.prev")}
aria-label={t("query.prev")}
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
type="button"
className="btn-ghost"
disabled={offset + PAGE_SIZE >= total}
onClick={() => setOffset(offset + PAGE_SIZE)}
title={t("query.next")}
aria-label={t("query.next")}
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
</div>
);
}
+56 -109
View File
@@ -1,6 +1,6 @@
/**
* @file SessionDetail.tsx
* @description Displays detailed information about a specific session, including its agents, events, and cost breakdown, with real-time updates and an expandable agent hierarchy view.
* @description Displays detailed information about a specific session, including its agents, events, and cost breakdown, with real-time updates and an expandable agent hierarchy view; a Replay tab scrubs through its events and a Snapshot action freezes it under a share link.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -28,7 +28,7 @@
* - `../components/conversation/ConversationView`
* - `../components/StatusBadge`
* - `../lib/types`
* - `../components/EventDetail`
* - `../components/EventTimeline`, `../components/EventReplay`, `../components/SnapshotButton`
* - `../components/EventFilters`
* - `../components/EventFiltersInfo`
* - `../components/Skeleton`
@@ -79,6 +79,7 @@ import {
Hourglass,
X,
Download,
Film,
} from "lucide-react";
import { api, type RunHandle } from "../lib/api";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
@@ -89,9 +90,12 @@ import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { AgentCard } from "../components/AgentCard";
import { SessionOverview } from "../components/SessionOverview";
import { ConversationView } from "../components/conversation/ConversationView";
import { SessionStatusBadge, AgentStatusBadge, REASON_ICONS } from "../components/StatusBadge";
import { SessionStatusBadge, REASON_ICONS } from "../components/StatusBadge";
import { SessionToolBadge } from "../components/SessionToolBadge";
import { SessionResumeButton } from "../components/lanes/SessionResumeButton";
import { SnapshotButton } from "../components/SnapshotButton";
import { EventTimeline } from "../components/EventTimeline";
import { EventReplay, loadReplayEvents } from "../components/EventReplay";
import { CopyButton } from "../components/event-views/primitives";
import {
effectiveSessionStatus,
@@ -99,7 +103,6 @@ import {
sessionAwaitingReason,
AWAITING_REASON_CONFIG,
} from "../lib/types";
import { EventDetail } from "../components/EventDetail";
import {
EventFilters,
isEmptyFilters,
@@ -110,14 +113,6 @@ import {
import type { EventFiltersValue } from "../components/EventFilters";
import { EventFiltersInfo } from "../components/EventFiltersInfo";
import { Skeleton } from "../components/Skeleton";
import {
agentOriginLabel,
buildEventTitle,
buildOriginLabel,
projectFromCwd,
projectFromEvent,
statusFromEventType,
} from "../lib/event-grouping";
import type { AgentInfo } from "../lib/event-grouping";
import {
formatDateTime,
@@ -139,7 +134,7 @@ import type {
} from "../lib/types";
import { WorkflowRunsPanel } from "../components/workflows/WorkflowRunsPanel";
type DetailTab = "agents" | "conversation" | "timeline";
type DetailTab = "agents" | "conversation" | "timeline" | "replay";
const EVENTS_INITIAL_BATCH = 50;
const EVENTS_MORE_BATCH = 500;
@@ -204,16 +199,18 @@ export function SessionDetail() {
const [pendingTranscriptId, setPendingTranscriptId] = useState<string | null>(null);
const [transcriptNotFound, setTranscriptNotFound] = useState(false);
const notFoundTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [expandedEvents, setExpandedEvents] = useState<Set<number>>(() => new Set());
function toggleEvent(id: number) {
setExpandedEvents((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
// The Replay tab's own copy: every event, oldest first, read on first visit.
const [replay, setReplay] = useState<{ events: DashboardEvent[]; total: number } | null>(null);
useEffect(() => {
if (activeTab !== "replay" || replay || !id) return;
let live = true;
loadReplayEvents(id)
.then((r) => live && setReplay(r))
.catch(() => live && setReplay({ events: [], total: 0 }));
return () => {
live = false;
};
}, [activeTab, replay, id]);
// Refs let the websocket handler access latest state without re-subscribing.
const eventApiParamsRef = useRef<Record<string, unknown> | null>(null);
@@ -427,13 +424,6 @@ export function SessionDetail() {
return map;
}, [session?.id, session?.name]);
// Precompute project per event so flat-row rendering doesn't re-parse JSON.
const projectByEventId = useMemo(() => {
const map = new Map<number, string | null>();
for (const e of events) map.set(e.id, projectFromEvent(e));
return map;
}, [events]);
const loadEvents = useCallback(async () => {
if (!eventApiParams) return;
try {
@@ -680,6 +670,7 @@ export function SessionDetail() {
) : null;
})()}
<SessionResumeButton session={session} />
<SnapshotButton sessionId={session.id} />
<button
onClick={load}
className="btn-ghost"
@@ -822,6 +813,20 @@ export function SessionDetail() {
<List className="w-4 h-4" />
{t("detail.timeline", { shown: events.length, total: eventsTotal })}
</button>
<button
onClick={() => {
setActiveTab("replay");
setTranscriptNotFound(false);
}}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === "replay"
? "border-violet-500 text-violet-700 dark:text-violet-400"
: "border-transparent text-fg-muted hover:text-fg-secondary"
}`}
>
<Film className="w-4 h-4" />
{t("replay.tab")}
</button>
</div>
{/* Tab Content */}
@@ -1139,85 +1144,12 @@ export function SessionDetail() {
{isEmptyFilters(filters) ? t("detail.noEvents") : t("common:eventFilters.noResults")}
</p>
) : (
<div className="card overflow-hidden">
<div className="divide-y divide-border max-h-[600px] overflow-y-auto overflow-x-auto">
{events.map((event, i) => {
const key = event.id ?? i;
const isOpen = event.id != null && expandedEvents.has(event.id);
return (
// Off-screen rows skip layout/paint (a long timeline is not virtualized).
<div
key={key}
style={
isOpen
? undefined
: { contentVisibility: "auto", containIntrinsicSize: "auto 45px" }
}
>
<button
type="button"
onClick={() => event.id != null && toggleEvent(event.id)}
aria-expanded={isOpen}
aria-label={
isOpen ? t("common:eventDetail.collapse") : t("common:eventDetail.expand")
}
className="w-full text-left px-5 py-3 flex items-center gap-4 hover:bg-surface-4 transition-colors min-w-0 cursor-pointer"
>
<span
className={`text-fg-muted text-[10px] w-3 flex-shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
aria-hidden="true"
>
▶
</span>
<div className="w-16 text-[11px] text-fg-muted font-mono flex-shrink-0">
{timeAgo(event.created_at)}
</div>
<AgentStatusBadge status={statusFromEventType(event.event_type)} />
{(() => {
// Session is implicit on this page - project is
// still shown so the row identifies the working
// directory when you share / search. Events without
// their own cwd (e.g. TurnDuration) fall back to the
// session's cwd so every row carries the dir prefix.
const project =
projectByEventId.get(event.id) ?? projectFromCwd(session?.cwd) ?? null;
const origin = buildOriginLabel(
project,
null,
agentOriginLabel(event.agent_id, agentInfoById)
);
return (
<span className="text-sm text-fg-secondary flex-1 truncate">
{origin && (
<span
className="text-fg-muted mr-1"
title={event.agent_id ?? undefined}
>
{origin} ·
</span>
)}
{buildEventTitle(event)}
</span>
);
})()}
{event.tool_name && (
<span className="text-[11px] px-2 py-0.5 bg-surface-2 rounded text-fg-muted font-mono">
{event.tool_name}
</span>
)}
</button>
{isOpen && (
<EventDetail
event={event}
agentInfoById={agentInfoById}
sessionNameById={sessionNameById}
/>
)}
</div>
);
})}
</div>
</div>
<EventTimeline
events={events}
agentInfoById={agentInfoById}
sessionNameById={sessionNameById}
fallbackCwd={session?.cwd}
/>
)}
{events.length < eventsTotal && (
<div className="flex items-center justify-between mt-3 px-1">
@@ -1238,6 +1170,21 @@ export function SessionDetail() {
)}
</div>
)}
{visitedTabs.has("replay") && (
<div hidden={activeTab !== "replay"}>
{replay ? (
<EventReplay
events={replay.events}
total={replay.total}
agentInfoById={agentInfoById}
sessionNameById={sessionNameById}
/>
) : (
<p className="text-sm text-fg-muted">{t("replay.loading")}</p>
)}
</div>
)}
</div>
);
}
+7 -1
View File
@@ -1,6 +1,6 @@
/**
* @file Settings.tsx
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — system information — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — system information — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -105,6 +105,8 @@ import { notify, playSound } from "../lib/notify";
import { Tip } from "../components/Tip";
import { ImportHistory } from "../components/ImportHistory";
import { RemoteSources } from "../components/RemoteSources";
import { PushIngestSettings } from "../components/settings/PushIngestSettings";
import { PrivacySettings } from "../components/settings/PrivacySettings";
import { Skeleton } from "../components/Skeleton";
import { AlertsNotifications } from "../components/AlertsNotifications";
import {
@@ -962,6 +964,10 @@ export function Settings({
<RemoteSources />
</section>
{/* ─── PUSH INGEST + PRIVACY (each its own anchored card) ─── */}
<PushIngestSettings />
<PrivacySettings />
{/* ─── SESSION CLEANUP ─── */}
<section id="cleanup" className="card p-5 space-y-4 scroll-mt-24">
<div className="flex items-center gap-3">
+18
View File
@@ -33,6 +33,8 @@ import {
Database,
Info,
Bot,
Camera,
CalendarClock,
Coins,
GitBranch,
KeyRound,
@@ -47,6 +49,8 @@ import { AiToolConfig } from "./AiToolConfig";
import { AiRouterConfig } from "./AiRouterConfig";
import { GitCredentialsConfig } from "./GitCredentialsConfig";
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
import { SnapshotSettings } from "../components/settings/SnapshotSettings";
import { ReportSettings } from "../components/settings/ReportSettings";
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
@@ -223,6 +227,20 @@ export function SettingsHub() {
...s("appearance"),
render: () => <AppearanceSettings />,
},
{
id: "snapshots",
group: "general",
Icon: Camera,
...s("snapshots"),
render: () => <SnapshotSettings />,
},
{
id: "reports",
group: "general",
Icon: CalendarClock,
...s("reports"),
render: () => <ReportSettings />,
},
{
id: "notifications",
group: "general",
+191
View File
@@ -0,0 +1,191 @@
/**
* @file SnapshotView.tsx
* @description The read-only page `/snapshot/:token`: a session as it was frozen
* by `POST /api/snapshots` — header, SessionOverview fed the frozen stats, the
* agents, and the Replay / Timeline tabs over the frozen events. Nothing here
* reads live data.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Bot, Camera, Clock, DollarSign, Film, List, ShieldCheck } from "lucide-react";
import { api } from "../lib/api";
import type { AgentInfo } from "../lib/event-grouping";
import { formatDateTime, fmtCostFull } from "../lib/format";
import type { SessionSnapshotInfo, SessionSnapshotPayload } from "../lib/types";
import { SessionOverview } from "../components/SessionOverview";
import { AgentCard } from "../components/AgentCard";
import { EventTimeline } from "../components/EventTimeline";
import { EventReplay } from "../components/EventReplay";
import { SessionStatusBadge } from "../components/StatusBadge";
import { Skeleton } from "../components/Skeleton";
type Tab = "overview" | "replay" | "timeline";
export function SnapshotView() {
const { token = "" } = useParams<{ token: string }>();
const { t } = useTranslation("sessions");
const [data, setData] = useState<{
snapshot: SessionSnapshotInfo;
payload: SessionSnapshotPayload;
} | null>(null);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("overview");
useEffect(() => {
let live = true;
setData(null);
setError(null);
api.snapshots
.get(token)
.then((r) => live && setData(r))
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
return () => {
live = false;
};
}, [token]);
const payload = data?.payload;
const agentInfoById = useMemo(() => {
const map = new Map<string, AgentInfo>();
for (const a of payload?.agents ?? []) map.set(a.id, a);
return map;
}, [payload]);
const sessionNameById = useMemo(() => {
const s = payload?.session;
return new Map(s ? [[s.id, s.name?.trim() || `Session ${s.id.slice(0, 8)}`]] : []);
}, [payload]);
if (error) {
return (
<div className="py-20 text-center">
<p className="mb-2 text-status-danger">{t("snapshot.notFound")}</p>
<p className="text-xs text-fg-muted">{error}</p>
</div>
);
}
if (!payload) {
return (
<div className="space-y-4" aria-busy="true">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-24 w-full" />
</div>
);
}
const { session, agents, events, stats, cost } = payload;
const tabs: { id: Tab; label: string; Icon: typeof Bot }[] = [
{ id: "overview", label: `${t("detail.agents")} (${agents.length})`, Icon: Bot },
{ id: "replay", label: t("replay.tab"), Icon: Film },
{ id: "timeline", label: t("snapshot.timeline", { n: events.length }), Icon: List },
];
return (
<div className="animate-fade-in space-y-6">
<div className="flex items-start gap-3 rounded-lg border border-accent/30 bg-accent/10 px-4 py-2.5 text-sm">
<Camera className="mt-0.5 h-4 w-4 flex-shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<div className="font-medium text-fg-primary">
{t("snapshot.banner", { date: formatDateTime(payload.captured_at) })}
</div>
<div className="text-[11px] text-fg-muted">{t("snapshot.bannerBody")}</div>
</div>
{payload.redacted.file_paths && (
<span className="inline-flex items-center gap-1 text-[11px] text-fg-secondary">
<ShieldCheck className="h-3.5 w-3.5" />
{t("snapshot.redacted")}
</span>
)}
<Link to={`/sessions/${session.id}`} className="text-[11px] text-accent hover:underline">
{t("snapshot.openLive")}
</Link>
</div>
<div>
<div className="mb-2 flex items-center gap-3">
<h2 className="text-xl font-semibold text-fg-primary">
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
</h2>
<SessionStatusBadge status={session.status} />
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
<span className="rounded bg-surface-2 px-2 py-1 font-mono text-xs text-fg-muted">
{session.id.slice(0, 16)}
</span>
<span className="inline-flex items-center gap-1.5 rounded bg-surface-2 px-2 py-1 text-xs text-fg-secondary">
<Clock className="h-3 w-3 text-fg-muted" />
{formatDateTime(session.started_at)}
</span>
{cost.total_cost > 0 && (
<span className="inline-flex items-center gap-1.5 rounded bg-status-success/10 px-2 py-1 text-xs font-medium text-status-success">
<DollarSign className="h-3 w-3" />
{fmtCostFull(cost.total_cost).slice(1)}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1 border-b border-border">
{tabs.map(({ id, label, Icon }) => (
<button
key={id}
type="button"
onClick={() => setTab(id)}
className={`flex items-center gap-2 border-b-2 px-4 py-2.5 text-sm font-medium transition-colors ${
tab === id
? "border-violet-500 text-violet-700 dark:text-violet-400"
: "border-transparent text-fg-muted hover:text-fg-secondary"
}`}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</div>
{tab === "overview" && (
<div>
{/* Frozen: its duration ends at the capture, not at "now". */}
<SessionOverview
session={{ ...session, ended_at: session.ended_at ?? payload.captured_at }}
agents={agents}
stats={stats}
/>
<div className="space-y-2">
{[...agents]
.sort((a, b) => (a.started_at || "").localeCompare(b.started_at || ""))
.map((a) => (
<AgentCard
key={a.id}
agent={a}
session={{ ...session, cost: cost.total_cost }}
// Without a handler the card opens the LIVE session.
onClick={() => undefined}
/>
))}
</div>
</div>
)}
{tab === "replay" && (
<EventReplay
events={events}
agentInfoById={agentInfoById}
sessionNameById={sessionNameById}
/>
)}
{tab === "timeline" &&
(events.length === 0 ? (
<p className="text-sm text-fg-muted">{t("detail.noEvents")}</p>
) : (
<EventTimeline
events={[...events].reverse()}
agentInfoById={agentInfoById}
sessionNameById={sessionNameById}
fallbackCwd={session.cwd}
/>
))}
</div>
);
}
+8 -3
View File
@@ -75,6 +75,7 @@ import {
import { useTranslation } from "react-i18next";
import { Workflow, RefreshCw, Download, AlertCircle, Info } from "lucide-react";
import { api } from "../lib/api";
import type { DateRange } from "../lib/dateRange";
import { eventBus } from "../lib/eventBus";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import type { WorkflowData, WSMessage } from "../lib/types";
@@ -91,7 +92,11 @@ import { SkillUsagePanel } from "../components/workflows/SkillUsagePanel";
type StatusFilter = "all" | "active" | "completed";
export function Workflows({ embedded = false }: { embedded?: boolean } = {}) {
export function Workflows({
embedded = false,
tool,
range,
}: { embedded?: boolean; tool?: string; range?: DateRange } = {}) {
const { t } = useTranslation("workflows");
const [data, setData] = useState<WorkflowData | null>(null);
const [loading, setLoading] = useState(true);
@@ -108,7 +113,7 @@ export function Workflows({ embedded = false }: { embedded?: boolean } = {}) {
const fetchData = useCallback(async () => {
const seq = ++fetchSeq.current;
try {
const result = await api.workflows.get(statusFilter);
const result = await api.workflows.get(statusFilter, tool, range);
if (seq !== fetchSeq.current) return;
setError(null);
setData(result);
@@ -118,7 +123,7 @@ export function Workflows({ embedded = false }: { embedded?: boolean } = {}) {
} finally {
if (seq === fetchSeq.current) setLoading(false);
}
}, [statusFilter]);
}, [statusFilter, tool, range?.from, range?.to]);
const selectSession = useCallback((id: string | null) => {
setSelectedSessionId(id);
@@ -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");
});