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);