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
+252 -12
View File
@@ -70,6 +70,8 @@ import {
BellOff,
Check,
CheckCheck,
ChevronDown,
Eye,
ListChecks,
Pencil,
Plus,
@@ -87,9 +89,10 @@ import { ConfirmModal } from "./ConfirmModal";
import { Checkbox } from "./Checkbox";
import { FieldHelp } from "./FieldHelp";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
import { timeAgo } from "../lib/format";
import { fmtCost, timeAgo } from "../lib/format";
import { toast } from "../lib/toast";
import type { AlertEvent, AlertRule, AlertRuleType, WSMessage } from "../lib/types";
import { useAiTools } from "../hooks/useAiTools";
import type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types";
const PAGE_SIZE = 25;
@@ -124,6 +127,8 @@ const RULE_TYPES: AlertRuleType[] = [
"inactivity",
"status_duration",
"token_threshold",
"cost_threshold",
"budget",
];
type TabKey = "rules" | "channels" | "activity";
@@ -139,6 +144,11 @@ interface RuleFormState {
minutes: string;
status: "working" | "waiting";
total_tokens: string;
usd: string;
period: "day" | "week" | "month";
limit_usd: string;
thresholds: string;
budget_tool: string;
cooldown_seconds: string;
}
@@ -153,6 +163,11 @@ const EMPTY_FORM: RuleFormState = {
minutes: "10",
status: "working",
total_tokens: "1000000",
usd: "5",
period: "month",
limit_usd: "100",
thresholds: "80, 100",
budget_tool: "",
cooldown_seconds: "300",
};
@@ -171,12 +186,28 @@ function formFromRule(rule: AlertRule): RuleFormState {
minutes: str(c.minutes, EMPTY_FORM.minutes),
status: c.status === "waiting" ? "waiting" : "working",
total_tokens: str(c.total_tokens, EMPTY_FORM.total_tokens),
usd: str(c.usd, EMPTY_FORM.usd),
period: c.period === "day" || c.period === "week" ? c.period : "month",
limit_usd: str(c.limit_usd, EMPTY_FORM.limit_usd),
thresholds: Array.isArray(c.thresholds) ? c.thresholds.join(", ") : EMPTY_FORM.thresholds,
budget_tool: str(c.tool, ""),
cooldown_seconds: String(rule.cooldown_seconds),
};
}
const errText = (err: unknown) => (err instanceof Error ? err.message : String(err));
/** "80, 100" → [80, 100]; null when any part is not a whole percent 1..1000. */
function parseThresholds(text: string): number[] | null {
const parts = text
.split(",")
.map((p) => p.trim())
.filter(Boolean);
const nums = parts.map((p) => Number(p));
if (nums.length === 0 || nums.length > 10) return null;
return nums.every((n) => Number.isInteger(n) && n > 0 && n <= 1000) ? nums : null;
}
function buildConfig(form: RuleFormState): AlertRule["config"] {
switch (form.rule_type) {
case "event_pattern": {
@@ -198,6 +229,18 @@ function buildConfig(form: RuleFormState): AlertRule["config"] {
return { status: form.status, minutes: parseFloat(form.minutes) };
case "token_threshold":
return { total_tokens: parseInt(form.total_tokens, 10) };
case "cost_threshold":
return { usd: parseFloat(form.usd) };
case "budget": {
const config: AlertRule["config"] = {
period: form.period,
limit_usd: parseFloat(form.limit_usd),
thresholds: parseThresholds(form.thresholds) ?? [80, 100],
tz_offset: new Date().getTimezoneOffset(),
};
if (form.budget_tool) config.tool = form.budget_tool;
return config;
}
}
}
@@ -225,6 +268,14 @@ function describeRule(rule: AlertRule, t: (key: string, opts?: Record<string, un
return t("ruleDesc.statusDuration", { status: c.status, minutes: c.minutes });
case "token_threshold":
return t("ruleDesc.tokenThreshold", { tokens: (c.total_tokens ?? 0).toLocaleString() });
case "cost_threshold":
return t("ruleDesc.costThreshold", { usd: fmtCost(c.usd ?? 0) });
case "budget":
return t("ruleDesc.budget", {
limit: fmtCost(c.limit_usd ?? 0),
period: t(`periods.${c.period ?? "month"}`),
thresholds: (c.thresholds ?? []).join("%, ") + "%",
});
}
}
@@ -244,6 +295,9 @@ export function AlertsNotifications() {
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [confirmRule, setConfirmRule] = useState<AlertRule | null>(null);
const [preview, setPreview] = useState<RulePreview | null>(null);
const [previewing, setPreviewing] = useState(false);
const aiTools = useAiTools();
const ruleCtx = useContextMenu<AlertRule>();
// A rule-menu item: close the menu, then act on the rule it was opened on.
const pickRule = (fn: (rule: AlertRule) => unknown) => () => {
@@ -322,10 +376,35 @@ export function AlertsNotifications() {
});
}, [loadAlerts]);
const set = (patch: Partial<RuleFormState>) => setForm((prev) => ({ ...prev, ...patch }));
// Any edit makes a shown preview stale.
const set = (patch: Partial<RuleFormState>) => {
setPreview(null);
setForm((prev) => ({ ...prev, ...patch }));
};
const onPreview = async () => {
if (previewing) return;
setPreviewing(true);
setFormError(null);
try {
const cooldown = parseInt(form.cooldown_seconds, 10);
setPreview(
await api.alerts.rules.preview({
rule_type: form.rule_type,
config: buildConfig(form),
cooldown_seconds: Number.isFinite(cooldown) && cooldown >= 0 ? cooldown : 300,
})
);
} catch (err) {
setFormError(err instanceof Error ? err.message : String(err));
} finally {
setPreviewing(false);
}
};
const closeForm = () => {
setForm(EMPTY_FORM);
setPreview(null);
setEditingId(null);
setFormOpen(false);
setFormError(null);
@@ -333,6 +412,7 @@ export function AlertsNotifications() {
const openEditRule = (rule: AlertRule) => {
setForm(formFromRule(rule));
setPreview(null);
setEditingId(rule.id);
setFormError(null);
setFormOpen(true);
@@ -417,7 +497,10 @@ export function AlertsNotifications() {
(countVal <= 1 || (Number.isFinite(windowVal) && windowVal > 0)))) &&
((form.rule_type !== "inactivity" && form.rule_type !== "status_duration") ||
(Number.isFinite(minutesVal) && minutesVal > 0)) &&
(form.rule_type !== "token_threshold" || (Number.isFinite(tokensVal) && tokensVal > 0));
(form.rule_type !== "token_threshold" || (Number.isFinite(tokensVal) && tokensVal > 0)) &&
(form.rule_type !== "cost_threshold" || parseFloat(form.usd) > 0) &&
(form.rule_type !== "budget" ||
(parseFloat(form.limit_usd) > 0 && parseThresholds(form.thresholds) !== null));
const TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
{
@@ -680,6 +763,100 @@ export function AlertsNotifications() {
</div>
)}
{form.rule_type === "cost_threshold" && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.usd")}
<FieldHelp description={t("rules.help.usd")} />
</span>
<input
type="number"
min={0}
step="0.01"
value={form.usd}
onChange={(e) => set({ usd: e.target.value })}
className="input mt-1 w-full"
/>
</label>
</div>
)}
{form.rule_type === "budget" && (
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.period")}
<FieldHelp description={t("rules.help.period")} />
</span>
<div className="relative mt-1">
<select
value={form.period}
onChange={(e) => set({ period: e.target.value as RuleFormState["period"] })}
className="input w-full appearance-none pr-8"
>
{(["day", "week", "month"] as const).map((p) => (
<option key={p} value={p}>
{t(`periods.${p}`)}
</option>
))}
</select>
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
</div>
</label>
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.limitUsd")}
<FieldHelp description={t("rules.help.limitUsd")} />
</span>
<input
type="number"
min={0}
step="0.01"
value={form.limit_usd}
onChange={(e) => set({ limit_usd: e.target.value })}
className="input mt-1 w-full"
/>
</label>
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.thresholds")}
<FieldHelp description={t("rules.help.thresholds")} />
</span>
<input
type="text"
value={form.thresholds}
onChange={(e) => set({ thresholds: e.target.value })}
placeholder="80, 100"
className="input mt-1 w-full"
/>
</label>
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.budgetTool")}
<FieldHelp description={t("rules.help.budgetTool")} />
</span>
<div className="relative mt-1">
<select
value={form.budget_tool}
onChange={(e) => set({ budget_tool: e.target.value })}
className="input w-full appearance-none pr-8"
>
<option value="">{t("rules.form.allTools")}</option>
{aiTools.tools.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</select>
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
</div>
</label>
</div>
)}
{preview && <RulePreviewResult preview={preview} />}
<div className="flex flex-wrap items-end justify-between gap-3">
<label className="block text-xs text-fg-secondary">
<span className="mb-1.5 flex items-center gap-1">
@@ -701,14 +878,29 @@ export function AlertsNotifications() {
</span>
)}
</label>
<button
onClick={onSaveRule}
disabled={!canSubmit || saving}
className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{editingId ? <Check className="w-3.5 h-3.5" /> : <Plus className="w-3.5 h-3.5" />}
{saving ? t("rules.saving") : editingId ? t("rules.save") : t("rules.create")}
</button>
<div className="flex items-center gap-2">
<button
onClick={onPreview}
disabled={!canSubmit || previewing}
title={t("rules.previewHint")}
className="btn-ghost border border-border inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<Eye className="w-3.5 h-3.5" />
{previewing ? t("rules.previewing") : t("rules.preview")}
</button>
<button
onClick={onSaveRule}
disabled={!canSubmit || saving}
className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{editingId ? (
<Check className="w-3.5 h-3.5" />
) : (
<Plus className="w-3.5 h-3.5" />
)}
{saving ? t("rules.saving") : editingId ? t("rules.save") : t("rules.create")}
</button>
</div>
</div>
{formError && <p className="text-xs text-status-danger">{formError}</p>}
</div>
@@ -927,3 +1119,51 @@ export function AlertsNotifications() {
</div>
);
}
/** What a rule would have fired, from POST /api/alerts/rules/preview. */
function RulePreviewResult({ preview }: { preview: RulePreview }) {
const { t } = useTranslation("alerts");
if (!preview.supported) {
return <p className="text-xs text-fg-muted">{t("rules.previewUnsupported")}</p>;
}
if (preview.periods) {
return (
<div className="rounded-md border border-border bg-surface-1 p-2 text-xs space-y-1">
<p className="text-fg-secondary">{t("rules.previewBudget")}</p>
{preview.periods.map((p) => (
<div key={p.period_start} className="flex justify-between gap-3 font-mono">
<span className="text-fg-muted">{p.period_start}</span>
<span className="text-fg-secondary">
{fmtCost(p.spend)} · {Math.round(p.pct)}%
</span>
<span className={p.crossed.length ? "text-status-warning" : "text-fg-muted"}>
{p.crossed.length ? p.crossed.map((c) => `${c}%`).join(" ") : "—"}
</span>
</div>
))}
</div>
);
}
return (
<div className="rounded-md border border-border bg-surface-1 p-2 text-xs space-y-1">
<p className="text-fg-secondary">
{t("rules.previewResult", {
fires: preview.fires,
sessions: preview.sessions,
days: preview.window_days,
})}
</p>
{preview.samples.map((s, i) => (
<div key={i} className="flex justify-between gap-3 font-mono text-fg-muted">
<Link
to={`/sessions/${encodeURIComponent(s.session_id)}`}
className="truncate hover:text-accent"
>
{s.session_id.slice(0, 8)}
</Link>
<span>{timeAgo(s.at)}</span>
</div>
))}
</div>
);
}