Files
AgentHub/client/src/pages/QueryExplorer.tsx
T
nntrivi2001 4aae61297f 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.
2026-10-06 17:12:28 +07:00

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