4aae61297f
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.
342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
/**
|
|
* @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>
|
|
);
|
|
}
|