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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user