feat: analytics parity — tool cost, date ranges, budgets, query explorer, replay, snapshots (v2.4.0)
Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files
Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
This commit is contained in:
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user