/** * @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ĩ */ 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 = { 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("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(null); const [error, setError] = useState(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 = (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 (

{t("query.title")}

{t("query.subtitle")}

setQ(e.target.value)} placeholder={t("query.searchPlaceholder")} aria-label={t("query.search")} />
({ value: s, label: t(`common:status.${s}`) })), ]} />
)} fresh(setFrom)(e.target.value)} aria-label={t("query.from")} title={t("query.from")} /> fresh(setTo)(e.target.value)} aria-label={t("query.to")} title={t("query.to")} /> setCwd(e.target.value)} placeholder={t("query.cwdPlaceholder")} aria-label={t("query.cwd")} title={t("query.cwd")} />

{total > cap ? t("query.exportTruncated", { total: total.toLocaleString(), cap: cap.toLocaleString(), }) : t("query.exportNote", { cap: cap.toLocaleString() })}

{error && (

{error}

)} {page && page.rows.length === 0 ? (

{t("query.empty")}

) : ( page && (
{page.columns.map((c) => { const sortable = page.sortable.includes(c); return ( ); })} {page.rows.map((r, i) => ( {page.columns.map((c) => { const v = r[c]; const sid = (entity === "sessions" && c === "id") || c === "session_id" ? v : null; return ( ); })} ))}
{sortable ? ( ) : ( c )}
{sid ? ( {String(sid).slice(0, 8)} ) : ( (v ?? "") )}
) )} {page && total > 0 && (
{t("query.rows", { from: (offset + 1).toLocaleString(), to: Math.min(offset + PAGE_SIZE, total).toLocaleString(), total: total.toLocaleString(), })}
)}
); }