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:
@@ -107,9 +107,15 @@ import { AgentStatusBadge } from "../components/StatusBadge";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { Tip } from "../components/Tip";
|
||||
import { Skeleton } from "../components/Skeleton";
|
||||
import { Select } from "../components/Select";
|
||||
import { AiToolIcon } from "../components/AiToolIcon";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { spendPace } from "../lib/burnRate";
|
||||
import { RANGE_PRESETS, parsePreset, rangeDates } from "../lib/dateRange";
|
||||
import { timeAgo, fmt, fmtCost, fmtCostFull, formatModelName } from "../lib/format";
|
||||
import { statusFromEventType } from "../lib/event-grouping";
|
||||
import type {
|
||||
BudgetStatus,
|
||||
Stats,
|
||||
Agent,
|
||||
DashboardEvent,
|
||||
@@ -700,6 +706,7 @@ function AnalyticsTab() {
|
||||
const { t, i18n } = useTranslation("analytics");
|
||||
const [data, setData] = useState<AnalyticsData | null>(null);
|
||||
const [costData, setCostData] = useState<CostResult | null>(null);
|
||||
const [budgets, setBudgets] = useState<BudgetStatus[]>([]);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
||||
const [loading, setLoading] = useState(true);
|
||||
// `?sub=` deep-links a sub-tab (the old /workflows route lands on `workflow`).
|
||||
@@ -723,6 +730,28 @@ function AnalyticsTab() {
|
||||
);
|
||||
// Global data scope; a change re-runs `load` (api injects the `sources` param).
|
||||
const [scope] = useDataScope();
|
||||
// One AI tool's sessions only ("" = every tool); a change re-runs `load`.
|
||||
const [tool, setTool] = useState("");
|
||||
const aiTools = useAiTools();
|
||||
// The date range, kept in the URL (`?range=`, plus `from`/`to` when custom).
|
||||
// Headline numbers, cost/token cards and by-model/by-tool follow it; the
|
||||
// 52-week and 30-day charts keep their own windows.
|
||||
const preset = parsePreset(params.get("range"));
|
||||
const customFrom = params.get("from") ?? "";
|
||||
const customTo = params.get("to") ?? "";
|
||||
const { from: rangeFrom, to: rangeTo } = rangeDates(preset, { from: customFrom, to: customTo });
|
||||
const range = useMemo(() => ({ from: rangeFrom, to: rangeTo }), [rangeFrom, rangeTo]);
|
||||
const setRangeParam = (patch: Record<string, string>) =>
|
||||
setParams(
|
||||
(p) => {
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (v) p.set(k, v);
|
||||
else p.delete(k);
|
||||
}
|
||||
return p;
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
// Responses can land out of order (a slow poll after a fast refresh); only
|
||||
@@ -731,14 +760,16 @@ function AnalyticsTab() {
|
||||
const load = useCallback(async () => {
|
||||
const seq = ++loadSeq.current;
|
||||
try {
|
||||
const [result, cost] = await Promise.all([
|
||||
api.analytics.get(),
|
||||
api.pricing.totalCost().catch(() => null),
|
||||
const [result, cost, budgetRes] = await Promise.all([
|
||||
api.analytics.get({ tool, range }),
|
||||
api.pricing.totalCost({ tool, range }).catch(() => null),
|
||||
api.budgets.status().catch(() => null),
|
||||
]);
|
||||
if (seq !== loadSeq.current) return;
|
||||
setData(result);
|
||||
setCostData(cost);
|
||||
setLoadError(null);
|
||||
if (budgetRes) setBudgets(budgetRes.budgets);
|
||||
setLastUpdate(new Date());
|
||||
} catch (err) {
|
||||
if (seq === loadSeq.current)
|
||||
@@ -746,7 +777,7 @@ function AnalyticsTab() {
|
||||
} finally {
|
||||
if (seq === loadSeq.current) setLoading(false);
|
||||
}
|
||||
}, [scope]);
|
||||
}, [scope, tool, range]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -861,6 +892,7 @@ function AnalyticsTab() {
|
||||
dailyCostLast30[0] ?? { date: "", cost: 0 }
|
||||
);
|
||||
const totalCost30d = dailyCostLast30.reduce((sum, day) => sum + day.cost, 0);
|
||||
const pace = spendPace(dailyCostsLocal, localDateStr(today));
|
||||
const costBreakdown = [...(costData?.breakdown ?? [])]
|
||||
.filter((b) => b.cost > 0)
|
||||
.sort((a, b) => b.cost - a.cost);
|
||||
@@ -985,6 +1017,53 @@ function AnalyticsTab() {
|
||||
{lastUpdate.toLocaleTimeString()}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="w-40">
|
||||
<Select
|
||||
value={preset}
|
||||
onChange={(v) =>
|
||||
setRangeParam(
|
||||
v === "custom"
|
||||
? { range: v, from: rangeFrom ?? "", to: rangeTo ?? "" }
|
||||
: { range: v === "all" ? "" : v, from: "", to: "" }
|
||||
)
|
||||
}
|
||||
label={t("range")}
|
||||
options={RANGE_PRESETS.map((v) => ({ value: v, label: t(`rangePreset.${v}`) }))}
|
||||
/>
|
||||
</div>
|
||||
{preset === "custom" && (
|
||||
<>
|
||||
<input
|
||||
type="date"
|
||||
className="input text-sm"
|
||||
value={customFrom}
|
||||
max={customTo || undefined}
|
||||
onChange={(e) => setRangeParam({ from: e.target.value })}
|
||||
aria-label={t("rangeFrom")}
|
||||
title={t("rangeFrom")}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
className="input text-sm"
|
||||
value={customTo}
|
||||
min={customFrom || undefined}
|
||||
onChange={(e) => setRangeParam({ to: e.target.value })}
|
||||
aria-label={t("rangeTo")}
|
||||
title={t("rangeTo")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="w-44">
|
||||
<Select
|
||||
value={tool}
|
||||
onChange={setTool}
|
||||
label={t("toolFilter")}
|
||||
options={[
|
||||
{ value: "", label: t("allTools") },
|
||||
...aiTools.tools.map((x) => ({ value: x.id, label: x.label })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={load} className="btn-ghost" disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||
{t("common:refresh")}
|
||||
@@ -1353,6 +1432,107 @@ function AnalyticsTab() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Spend pace: burn rate, month to date, month-end forecast */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("spendPace")}</h3>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ label: t("burnRate"), value: pace.perDay7, hint: t("burnRateHint") },
|
||||
{ label: t("monthToDate"), value: pace.monthToDate },
|
||||
{
|
||||
label: t("forecastMonth"),
|
||||
value: pace.forecastMonth,
|
||||
hint: t("forecastHint"),
|
||||
},
|
||||
].map(({ label, value, hint }) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex justify-between items-center py-2 border-b border-border last:border-0"
|
||||
>
|
||||
<span className="text-xs text-fg-secondary" title={hint}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-sm font-mono font-medium text-status-success">
|
||||
<Tip raw={fmtCostFull(value)}>{fmtCost(value)}</Tip>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Budgets (budget alert rules) */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("budgets")}</h3>
|
||||
{budgets.length === 0 ? (
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t("noBudgets")}{" "}
|
||||
<Link to="/settings/notifications" className="text-accent hover:underline">
|
||||
{t("addBudget")}
|
||||
</Link>
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{budgets.map((b) => {
|
||||
const over = b.pct >= 100;
|
||||
const warn = b.crossed.length > 0;
|
||||
return (
|
||||
<div key={b.id} className="space-y-1.5">
|
||||
<div className="flex justify-between text-xs gap-2">
|
||||
<span className="text-fg-secondary truncate" title={b.name}>
|
||||
{b.name}
|
||||
</span>
|
||||
<span className="font-mono text-fg-secondary flex-shrink-0">
|
||||
{fmtCost(b.spend)} / {fmtCost(b.config.limit_usd ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-surface-3 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${over ? "bg-status-danger" : warn ? "bg-status-warning" : "bg-status-success"}`}
|
||||
style={{ width: `${Math.min(100, Math.max(2, b.pct))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px] text-fg-muted">
|
||||
<span>
|
||||
{b.period_start} → {b.period_end}
|
||||
</span>
|
||||
<span title={t("projectedHint")}>
|
||||
{t("projected")} {fmtCost(b.projected)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cost by AI tool */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("costByTool")}</h3>
|
||||
{(costData?.by_tool?.length ?? 0) > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{(costData?.by_tool ?? []).map((b) => (
|
||||
<div key={b.tool} className="flex items-center gap-2">
|
||||
<AiToolIcon
|
||||
tool={{ id: b.tool, label: aiTools.label(b.tool) }}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CostBarRow
|
||||
label={aiTools.label(b.tool)}
|
||||
cost={b.cost}
|
||||
max={Math.max(costData?.by_tool?.[0]?.cost ?? 0, 0.0001)}
|
||||
color="bg-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-fg-muted">{t("noCostData")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cost by weekday */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-medium text-fg-secondary mb-1">
|
||||
@@ -1471,7 +1651,7 @@ function AnalyticsTab() {
|
||||
{/* The former Workflows page, folded into this sub-tab. */}
|
||||
<RouteErrorBoundary>
|
||||
<Suspense fallback={<AnalyticsChartsSkeleton />}>
|
||||
<Workflows embedded />
|
||||
<Workflows embedded tool={tool || undefined} range={range} />
|
||||
</Suspense>
|
||||
</RouteErrorBoundary>
|
||||
</div>
|
||||
@@ -1605,7 +1785,7 @@ function SystemHealthTab() {
|
||||
try {
|
||||
const [infoRes, workflowRes] = await Promise.all([
|
||||
api.settings.info(),
|
||||
api.workflows.get(undefined, HEALTH_SECTIONS),
|
||||
api.workflows.get(undefined, undefined, undefined, HEALTH_SECTIONS),
|
||||
]);
|
||||
setInfo(infoRes as any);
|
||||
setWorkflow(workflowRes);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file SessionDetail.tsx
|
||||
* @description Displays detailed information about a specific session, including its agents, events, and cost breakdown, with real-time updates and an expandable agent hierarchy view.
|
||||
* @description Displays detailed information about a specific session, including its agents, events, and cost breakdown, with real-time updates and an expandable agent hierarchy view; a Replay tab scrubs through its events and a Snapshot action freezes it under a share link.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -28,7 +28,7 @@
|
||||
* - `../components/conversation/ConversationView`
|
||||
* - `../components/StatusBadge`
|
||||
* - `../lib/types`
|
||||
* - `../components/EventDetail`
|
||||
* - `../components/EventTimeline`, `../components/EventReplay`, `../components/SnapshotButton`
|
||||
* - `../components/EventFilters`
|
||||
* - `../components/EventFiltersInfo`
|
||||
* - `../components/Skeleton`
|
||||
@@ -79,6 +79,7 @@ import {
|
||||
Hourglass,
|
||||
X,
|
||||
Download,
|
||||
Film,
|
||||
} from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
@@ -89,9 +90,12 @@ import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||
import { AgentCard } from "../components/AgentCard";
|
||||
import { SessionOverview } from "../components/SessionOverview";
|
||||
import { ConversationView } from "../components/conversation/ConversationView";
|
||||
import { SessionStatusBadge, AgentStatusBadge, REASON_ICONS } from "../components/StatusBadge";
|
||||
import { SessionStatusBadge, REASON_ICONS } from "../components/StatusBadge";
|
||||
import { SessionToolBadge } from "../components/SessionToolBadge";
|
||||
import { SessionResumeButton } from "../components/lanes/SessionResumeButton";
|
||||
import { SnapshotButton } from "../components/SnapshotButton";
|
||||
import { EventTimeline } from "../components/EventTimeline";
|
||||
import { EventReplay, loadReplayEvents } from "../components/EventReplay";
|
||||
import { CopyButton } from "../components/event-views/primitives";
|
||||
import {
|
||||
effectiveSessionStatus,
|
||||
@@ -99,7 +103,6 @@ import {
|
||||
sessionAwaitingReason,
|
||||
AWAITING_REASON_CONFIG,
|
||||
} from "../lib/types";
|
||||
import { EventDetail } from "../components/EventDetail";
|
||||
import {
|
||||
EventFilters,
|
||||
isEmptyFilters,
|
||||
@@ -110,14 +113,6 @@ import {
|
||||
import type { EventFiltersValue } from "../components/EventFilters";
|
||||
import { EventFiltersInfo } from "../components/EventFiltersInfo";
|
||||
import { Skeleton } from "../components/Skeleton";
|
||||
import {
|
||||
agentOriginLabel,
|
||||
buildEventTitle,
|
||||
buildOriginLabel,
|
||||
projectFromCwd,
|
||||
projectFromEvent,
|
||||
statusFromEventType,
|
||||
} from "../lib/event-grouping";
|
||||
import type { AgentInfo } from "../lib/event-grouping";
|
||||
import {
|
||||
formatDateTime,
|
||||
@@ -139,7 +134,7 @@ import type {
|
||||
} from "../lib/types";
|
||||
import { WorkflowRunsPanel } from "../components/workflows/WorkflowRunsPanel";
|
||||
|
||||
type DetailTab = "agents" | "conversation" | "timeline";
|
||||
type DetailTab = "agents" | "conversation" | "timeline" | "replay";
|
||||
|
||||
const EVENTS_INITIAL_BATCH = 50;
|
||||
const EVENTS_MORE_BATCH = 500;
|
||||
@@ -204,16 +199,18 @@ export function SessionDetail() {
|
||||
const [pendingTranscriptId, setPendingTranscriptId] = useState<string | null>(null);
|
||||
const [transcriptNotFound, setTranscriptNotFound] = useState(false);
|
||||
const notFoundTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [expandedEvents, setExpandedEvents] = useState<Set<number>>(() => new Set());
|
||||
|
||||
function toggleEvent(id: number) {
|
||||
setExpandedEvents((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
// The Replay tab's own copy: every event, oldest first, read on first visit.
|
||||
const [replay, setReplay] = useState<{ events: DashboardEvent[]; total: number } | null>(null);
|
||||
useEffect(() => {
|
||||
if (activeTab !== "replay" || replay || !id) return;
|
||||
let live = true;
|
||||
loadReplayEvents(id)
|
||||
.then((r) => live && setReplay(r))
|
||||
.catch(() => live && setReplay({ events: [], total: 0 }));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [activeTab, replay, id]);
|
||||
|
||||
// Refs let the websocket handler access latest state without re-subscribing.
|
||||
const eventApiParamsRef = useRef<Record<string, unknown> | null>(null);
|
||||
@@ -427,13 +424,6 @@ export function SessionDetail() {
|
||||
return map;
|
||||
}, [session?.id, session?.name]);
|
||||
|
||||
// Precompute project per event so flat-row rendering doesn't re-parse JSON.
|
||||
const projectByEventId = useMemo(() => {
|
||||
const map = new Map<number, string | null>();
|
||||
for (const e of events) map.set(e.id, projectFromEvent(e));
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
const loadEvents = useCallback(async () => {
|
||||
if (!eventApiParams) return;
|
||||
try {
|
||||
@@ -680,6 +670,7 @@ export function SessionDetail() {
|
||||
) : null;
|
||||
})()}
|
||||
<SessionResumeButton session={session} />
|
||||
<SnapshotButton sessionId={session.id} />
|
||||
<button
|
||||
onClick={load}
|
||||
className="btn-ghost"
|
||||
@@ -822,6 +813,20 @@ export function SessionDetail() {
|
||||
<List className="w-4 h-4" />
|
||||
{t("detail.timeline", { shown: events.length, total: eventsTotal })}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab("replay");
|
||||
setTranscriptNotFound(false);
|
||||
}}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "replay"
|
||||
? "border-violet-500 text-violet-700 dark:text-violet-400"
|
||||
: "border-transparent text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
<Film className="w-4 h-4" />
|
||||
{t("replay.tab")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
@@ -1139,85 +1144,12 @@ export function SessionDetail() {
|
||||
{isEmptyFilters(filters) ? t("detail.noEvents") : t("common:eventFilters.noResults")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="divide-y divide-border max-h-[600px] overflow-y-auto overflow-x-auto">
|
||||
{events.map((event, i) => {
|
||||
const key = event.id ?? i;
|
||||
const isOpen = event.id != null && expandedEvents.has(event.id);
|
||||
return (
|
||||
// Off-screen rows skip layout/paint (a long timeline is not virtualized).
|
||||
<div
|
||||
key={key}
|
||||
style={
|
||||
isOpen
|
||||
? undefined
|
||||
: { contentVisibility: "auto", containIntrinsicSize: "auto 45px" }
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => event.id != null && toggleEvent(event.id)}
|
||||
aria-expanded={isOpen}
|
||||
aria-label={
|
||||
isOpen ? t("common:eventDetail.collapse") : t("common:eventDetail.expand")
|
||||
}
|
||||
className="w-full text-left px-5 py-3 flex items-center gap-4 hover:bg-surface-4 transition-colors min-w-0 cursor-pointer"
|
||||
>
|
||||
<span
|
||||
className={`text-fg-muted text-[10px] w-3 flex-shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
<div className="w-16 text-[11px] text-fg-muted font-mono flex-shrink-0">
|
||||
{timeAgo(event.created_at)}
|
||||
</div>
|
||||
<AgentStatusBadge status={statusFromEventType(event.event_type)} />
|
||||
{(() => {
|
||||
// Session is implicit on this page - project is
|
||||
// still shown so the row identifies the working
|
||||
// directory when you share / search. Events without
|
||||
// their own cwd (e.g. TurnDuration) fall back to the
|
||||
// session's cwd so every row carries the dir prefix.
|
||||
const project =
|
||||
projectByEventId.get(event.id) ?? projectFromCwd(session?.cwd) ?? null;
|
||||
const origin = buildOriginLabel(
|
||||
project,
|
||||
null,
|
||||
agentOriginLabel(event.agent_id, agentInfoById)
|
||||
);
|
||||
return (
|
||||
<span className="text-sm text-fg-secondary flex-1 truncate">
|
||||
{origin && (
|
||||
<span
|
||||
className="text-fg-muted mr-1"
|
||||
title={event.agent_id ?? undefined}
|
||||
>
|
||||
{origin} ·
|
||||
</span>
|
||||
)}
|
||||
{buildEventTitle(event)}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{event.tool_name && (
|
||||
<span className="text-[11px] px-2 py-0.5 bg-surface-2 rounded text-fg-muted font-mono">
|
||||
{event.tool_name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<EventDetail
|
||||
event={event}
|
||||
agentInfoById={agentInfoById}
|
||||
sessionNameById={sessionNameById}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<EventTimeline
|
||||
events={events}
|
||||
agentInfoById={agentInfoById}
|
||||
sessionNameById={sessionNameById}
|
||||
fallbackCwd={session?.cwd}
|
||||
/>
|
||||
)}
|
||||
{events.length < eventsTotal && (
|
||||
<div className="flex items-center justify-between mt-3 px-1">
|
||||
@@ -1238,6 +1170,21 @@ export function SessionDetail() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visitedTabs.has("replay") && (
|
||||
<div hidden={activeTab !== "replay"}>
|
||||
{replay ? (
|
||||
<EventReplay
|
||||
events={replay.events}
|
||||
total={replay.total}
|
||||
agentInfoById={agentInfoById}
|
||||
sessionNameById={sessionNameById}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-fg-muted">{t("replay.loading")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Settings.tsx
|
||||
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — system information — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — system information — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -105,6 +105,8 @@ import { notify, playSound } from "../lib/notify";
|
||||
import { Tip } from "../components/Tip";
|
||||
import { ImportHistory } from "../components/ImportHistory";
|
||||
import { RemoteSources } from "../components/RemoteSources";
|
||||
import { PushIngestSettings } from "../components/settings/PushIngestSettings";
|
||||
import { PrivacySettings } from "../components/settings/PrivacySettings";
|
||||
import { Skeleton } from "../components/Skeleton";
|
||||
import { AlertsNotifications } from "../components/AlertsNotifications";
|
||||
import {
|
||||
@@ -962,6 +964,10 @@ export function Settings({
|
||||
<RemoteSources />
|
||||
</section>
|
||||
|
||||
{/* ─── PUSH INGEST + PRIVACY (each its own anchored card) ─── */}
|
||||
<PushIngestSettings />
|
||||
<PrivacySettings />
|
||||
|
||||
{/* ─── SESSION CLEANUP ─── */}
|
||||
<section id="cleanup" className="card p-5 space-y-4 scroll-mt-24">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
Database,
|
||||
Info,
|
||||
Bot,
|
||||
Camera,
|
||||
CalendarClock,
|
||||
Coins,
|
||||
GitBranch,
|
||||
KeyRound,
|
||||
@@ -47,6 +49,8 @@ import { AiToolConfig } from "./AiToolConfig";
|
||||
import { AiRouterConfig } from "./AiRouterConfig";
|
||||
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
||||
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
||||
import { SnapshotSettings } from "../components/settings/SnapshotSettings";
|
||||
import { ReportSettings } from "../components/settings/ReportSettings";
|
||||
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||
@@ -223,6 +227,20 @@ export function SettingsHub() {
|
||||
...s("appearance"),
|
||||
render: () => <AppearanceSettings />,
|
||||
},
|
||||
{
|
||||
id: "snapshots",
|
||||
group: "general",
|
||||
Icon: Camera,
|
||||
...s("snapshots"),
|
||||
render: () => <SnapshotSettings />,
|
||||
},
|
||||
{
|
||||
id: "reports",
|
||||
group: "general",
|
||||
Icon: CalendarClock,
|
||||
...s("reports"),
|
||||
render: () => <ReportSettings />,
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
group: "general",
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @file SnapshotView.tsx
|
||||
* @description The read-only page `/snapshot/:token`: a session as it was frozen
|
||||
* by `POST /api/snapshots` — header, SessionOverview fed the frozen stats, the
|
||||
* agents, and the Replay / Timeline tabs over the frozen events. Nothing here
|
||||
* reads live data.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bot, Camera, Clock, DollarSign, Film, List, ShieldCheck } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { AgentInfo } from "../lib/event-grouping";
|
||||
import { formatDateTime, fmtCostFull } from "../lib/format";
|
||||
import type { SessionSnapshotInfo, SessionSnapshotPayload } from "../lib/types";
|
||||
import { SessionOverview } from "../components/SessionOverview";
|
||||
import { AgentCard } from "../components/AgentCard";
|
||||
import { EventTimeline } from "../components/EventTimeline";
|
||||
import { EventReplay } from "../components/EventReplay";
|
||||
import { SessionStatusBadge } from "../components/StatusBadge";
|
||||
import { Skeleton } from "../components/Skeleton";
|
||||
|
||||
type Tab = "overview" | "replay" | "timeline";
|
||||
|
||||
export function SnapshotView() {
|
||||
const { token = "" } = useParams<{ token: string }>();
|
||||
const { t } = useTranslation("sessions");
|
||||
const [data, setData] = useState<{
|
||||
snapshot: SessionSnapshotInfo;
|
||||
payload: SessionSnapshotPayload;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<Tab>("overview");
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setData(null);
|
||||
setError(null);
|
||||
api.snapshots
|
||||
.get(token)
|
||||
.then((r) => live && setData(r))
|
||||
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
const payload = data?.payload;
|
||||
const agentInfoById = useMemo(() => {
|
||||
const map = new Map<string, AgentInfo>();
|
||||
for (const a of payload?.agents ?? []) map.set(a.id, a);
|
||||
return map;
|
||||
}, [payload]);
|
||||
const sessionNameById = useMemo(() => {
|
||||
const s = payload?.session;
|
||||
return new Map(s ? [[s.id, s.name?.trim() || `Session ${s.id.slice(0, 8)}`]] : []);
|
||||
}, [payload]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-20 text-center">
|
||||
<p className="mb-2 text-status-danger">{t("snapshot.notFound")}</p>
|
||||
<p className="text-xs text-fg-muted">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!payload) {
|
||||
return (
|
||||
<div className="space-y-4" aria-busy="true">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { session, agents, events, stats, cost } = payload;
|
||||
const tabs: { id: Tab; label: string; Icon: typeof Bot }[] = [
|
||||
{ id: "overview", label: `${t("detail.agents")} (${agents.length})`, Icon: Bot },
|
||||
{ id: "replay", label: t("replay.tab"), Icon: Film },
|
||||
{ id: "timeline", label: t("snapshot.timeline", { n: events.length }), Icon: List },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<div className="flex items-start gap-3 rounded-lg border border-accent/30 bg-accent/10 px-4 py-2.5 text-sm">
|
||||
<Camera className="mt-0.5 h-4 w-4 flex-shrink-0 text-accent" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-fg-primary">
|
||||
{t("snapshot.banner", { date: formatDateTime(payload.captured_at) })}
|
||||
</div>
|
||||
<div className="text-[11px] text-fg-muted">{t("snapshot.bannerBody")}</div>
|
||||
</div>
|
||||
{payload.redacted.file_paths && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-fg-secondary">
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
{t("snapshot.redacted")}
|
||||
</span>
|
||||
)}
|
||||
<Link to={`/sessions/${session.id}`} className="text-[11px] text-accent hover:underline">
|
||||
{t("snapshot.openLive")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<h2 className="text-xl font-semibold text-fg-primary">
|
||||
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
|
||||
</h2>
|
||||
<SessionStatusBadge status={session.status} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<span className="rounded bg-surface-2 px-2 py-1 font-mono text-xs text-fg-muted">
|
||||
{session.id.slice(0, 16)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded bg-surface-2 px-2 py-1 text-xs text-fg-secondary">
|
||||
<Clock className="h-3 w-3 text-fg-muted" />
|
||||
{formatDateTime(session.started_at)}
|
||||
</span>
|
||||
{cost.total_cost > 0 && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded bg-status-success/10 px-2 py-1 text-xs font-medium text-status-success">
|
||||
<DollarSign className="h-3 w-3" />
|
||||
{fmtCostFull(cost.total_cost).slice(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 border-b border-border">
|
||||
{tabs.map(({ id, label, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setTab(id)}
|
||||
className={`flex items-center gap-2 border-b-2 px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||
tab === id
|
||||
? "border-violet-500 text-violet-700 dark:text-violet-400"
|
||||
: "border-transparent text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "overview" && (
|
||||
<div>
|
||||
{/* Frozen: its duration ends at the capture, not at "now". */}
|
||||
<SessionOverview
|
||||
session={{ ...session, ended_at: session.ended_at ?? payload.captured_at }}
|
||||
agents={agents}
|
||||
stats={stats}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{[...agents]
|
||||
.sort((a, b) => (a.started_at || "").localeCompare(b.started_at || ""))
|
||||
.map((a) => (
|
||||
<AgentCard
|
||||
key={a.id}
|
||||
agent={a}
|
||||
session={{ ...session, cost: cost.total_cost }}
|
||||
// Without a handler the card opens the LIVE session.
|
||||
onClick={() => undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{tab === "replay" && (
|
||||
<EventReplay
|
||||
events={events}
|
||||
agentInfoById={agentInfoById}
|
||||
sessionNameById={sessionNameById}
|
||||
/>
|
||||
)}
|
||||
{tab === "timeline" &&
|
||||
(events.length === 0 ? (
|
||||
<p className="text-sm text-fg-muted">{t("detail.noEvents")}</p>
|
||||
) : (
|
||||
<EventTimeline
|
||||
events={[...events].reverse()}
|
||||
agentInfoById={agentInfoById}
|
||||
sessionNameById={sessionNameById}
|
||||
fallbackCwd={session.cwd}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -75,6 +75,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Workflow, RefreshCw, Download, AlertCircle, Info } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { DateRange } from "../lib/dateRange";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import type { WorkflowData, WSMessage } from "../lib/types";
|
||||
@@ -91,7 +92,11 @@ import { SkillUsagePanel } from "../components/workflows/SkillUsagePanel";
|
||||
|
||||
type StatusFilter = "all" | "active" | "completed";
|
||||
|
||||
export function Workflows({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
export function Workflows({
|
||||
embedded = false,
|
||||
tool,
|
||||
range,
|
||||
}: { embedded?: boolean; tool?: string; range?: DateRange } = {}) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const [data, setData] = useState<WorkflowData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -108,7 +113,7 @@ export function Workflows({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
const fetchData = useCallback(async () => {
|
||||
const seq = ++fetchSeq.current;
|
||||
try {
|
||||
const result = await api.workflows.get(statusFilter);
|
||||
const result = await api.workflows.get(statusFilter, tool, range);
|
||||
if (seq !== fetchSeq.current) return;
|
||||
setError(null);
|
||||
setData(result);
|
||||
@@ -118,7 +123,7 @@ export function Workflows({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
} finally {
|
||||
if (seq === fetchSeq.current) setLoading(false);
|
||||
}
|
||||
}, [statusFilter]);
|
||||
}, [statusFilter, tool, range?.from, range?.to]);
|
||||
|
||||
const selectSession = useCallback((id: string | null) => {
|
||||
setSelectedSessionId(id);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @file QueryExplorer.test.tsx
|
||||
* @description The Query Explorer page: it renders a page of rows with session
|
||||
* links, sorts by an allowed column on a header click (flipping on a second),
|
||||
* switches entity, and points both export links at the current filter.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
|
||||
const { query } = vi.hoisted(() => ({
|
||||
query: {
|
||||
page: vi.fn(),
|
||||
exportUrl: (p: URLSearchParams, f: string) => `/api/query?${p.toString()}&format=${f}`,
|
||||
},
|
||||
}));
|
||||
vi.mock("../../lib/api", () => ({ api: { query } }));
|
||||
vi.mock("../../hooks/useAiTools", () => ({
|
||||
useAiTools: () => ({ tools: [{ id: "codex", label: "Codex" }] }),
|
||||
}));
|
||||
|
||||
import { QueryExplorer } from "../QueryExplorer";
|
||||
|
||||
const page = (entity: string, rows: Record<string, string>[]) => ({
|
||||
entity,
|
||||
columns: Object.keys(rows[0] ?? {}),
|
||||
sortable: entity === "sessions" ? ["started_at", "name"] : ["created_at"],
|
||||
statuses: [],
|
||||
rows,
|
||||
total: rows.length,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
export_cap: 50000,
|
||||
});
|
||||
const lastParams = () => {
|
||||
const calls = query.page.mock.calls;
|
||||
return calls[calls.length - 1]![0] as URLSearchParams;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.clearAllMocks();
|
||||
query.page.mockImplementation((p: URLSearchParams) =>
|
||||
Promise.resolve(
|
||||
p.get("entity") === "events"
|
||||
? page("events", [{ id: "1", session_id: "sess-abcdef12", created_at: "2026-10-01" }])
|
||||
: page("sessions", [{ id: "sess-abcdef12", name: "first", started_at: "2026-10-01" }])
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
describe("QueryExplorer", () => {
|
||||
it("shows rows, sorts by an allowed column and exports the filter", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryExplorer />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByText("first")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "sess-abc" })).toHaveAttribute(
|
||||
"href",
|
||||
"/sessions/sess-abcdef12"
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "name" }));
|
||||
await waitFor(() => expect(lastParams().get("sort")).toBe("name"));
|
||||
expect(lastParams().get("dir")).toBe("desc");
|
||||
await user.click(screen.getByRole("button", { name: "name" }));
|
||||
await waitFor(() => expect(lastParams().get("dir")).toBe("asc"));
|
||||
// A column outside `sortable` has no sort button.
|
||||
expect(screen.queryByRole("button", { name: "id" })).toBeNull();
|
||||
const csv = screen.getByRole("link", { name: /Export CSV/ });
|
||||
expect(csv.getAttribute("href")).toContain("sort=name");
|
||||
expect(csv.getAttribute("href")).toContain("format=csv");
|
||||
});
|
||||
|
||||
it("switches entity and drops the status filter for events", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryExplorer />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await screen.findByText("first");
|
||||
expect(screen.getByRole("button", { name: /Status/ })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /^Data/ }));
|
||||
await user.click(screen.getByRole("option", { name: "Events" }));
|
||||
await waitFor(() => expect(lastParams().get("entity")).toBe("events"));
|
||||
expect(screen.queryByRole("button", { name: /Status/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
|
||||
|
||||
@@ -44,6 +44,14 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
clearData: () => clearData(),
|
||||
gc: vi.fn(async () => ({ reaped: [], capped: [] })),
|
||||
exportData: () => "/api/settings/export",
|
||||
pushToken: { get: vi.fn(async () => ({ enabled: false, env_owned: false })) },
|
||||
privacy: {
|
||||
get: vi.fn(async () => ({
|
||||
redact_secrets: false,
|
||||
ignore_cwd: [],
|
||||
env_owned: { redact_secrets: false, ignore_cwd: false },
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -112,7 +120,7 @@ describe("Settings", () => {
|
||||
const dialog = screen.getByRole("dialog", { name: /clear all data/i });
|
||||
const yes = screen.getByRole("button", { name: /yes, clear all/i });
|
||||
expect(yes).toBeDisabled();
|
||||
await user.type(screen.getByRole("textbox"), "clear all data");
|
||||
await user.type(within(dialog).getByRole("textbox"), "clear all data");
|
||||
expect(yes).toBeEnabled();
|
||||
await user.click(yes);
|
||||
await waitFor(() => expect(clearData).toHaveBeenCalled());
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @file SnapshotView.test.tsx
|
||||
* @description The read-only `/snapshot/:token` page renders the frozen payload
|
||||
* without reading live session data, and Settings → Snapshots lists and
|
||||
* deletes snapshots behind a confirm.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { SnapshotView } from "../SnapshotView";
|
||||
import { SnapshotSettings } from "../../components/settings/SnapshotSettings";
|
||||
|
||||
const stats = {
|
||||
session_id: "s1",
|
||||
total_events: 2,
|
||||
events_by_type: [],
|
||||
tools_used: [{ tool_name: "Read", count: 2 }],
|
||||
error_count: 0,
|
||||
first_event_at: "2026-10-01T10:00:00.000Z",
|
||||
last_event_at: "2026-10-01T10:00:01.000Z",
|
||||
agents: { total: 1, main: 1, subagent: 0, compaction: 0, by_status: {} },
|
||||
subagent_types: [],
|
||||
tokens: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0 },
|
||||
};
|
||||
|
||||
const sessionStats = vi.fn();
|
||||
const remove = vi.fn(() => Promise.resolve({ ok: true }));
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
sessions: { stats: (...a: unknown[]) => sessionStats(...a) },
|
||||
snapshots: {
|
||||
get: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
snapshot: { id: 1, token: "tok", session_id: "s1", created_at: "2026-10-02T00:00:00Z" },
|
||||
payload: {
|
||||
version: 1,
|
||||
captured_at: "2026-10-02T00:00:00.000Z",
|
||||
redacted: { file_paths: true },
|
||||
session: {
|
||||
id: "s1",
|
||||
name: "Frozen session",
|
||||
status: "completed",
|
||||
cwd: "[redacted]",
|
||||
model: null,
|
||||
started_at: "2026-10-01T10:00:00.000Z",
|
||||
ended_at: null,
|
||||
metadata: null,
|
||||
},
|
||||
agents: [],
|
||||
events: [
|
||||
{
|
||||
id: 1,
|
||||
session_id: "s1",
|
||||
agent_id: null,
|
||||
event_type: "SessionStart",
|
||||
tool_name: null,
|
||||
summary: "start",
|
||||
data: null,
|
||||
created_at: "2026-10-01T10:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
session_id: "s1",
|
||||
agent_id: null,
|
||||
event_type: "Stop",
|
||||
tool_name: null,
|
||||
summary: "stop",
|
||||
data: null,
|
||||
created_at: "2026-10-01T10:00:01.000Z",
|
||||
},
|
||||
],
|
||||
stats,
|
||||
cost: { total_cost: 0, breakdown: [] },
|
||||
},
|
||||
})
|
||||
),
|
||||
list: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
snapshots: [
|
||||
{
|
||||
id: 7,
|
||||
token: "tok7",
|
||||
session_id: "s1",
|
||||
created_at: "2026-10-02T00:00:00Z",
|
||||
session_name: "Frozen session",
|
||||
redacted: true,
|
||||
event_count: 2,
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
remove: (...a: unknown[]) => remove(...(a as [])),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("SnapshotView", () => {
|
||||
it("renders the frozen session and its replay, never the live stats", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/snapshot/tok"]}>
|
||||
<Routes>
|
||||
<Route path="/snapshot/:token" element={<SnapshotView />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByText("Frozen session")).toBeTruthy();
|
||||
expect(screen.getByText("Paths redacted")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Replay/ }));
|
||||
expect(screen.getByTestId("event-replay")).toBeTruthy();
|
||||
expect(sessionStats).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SnapshotSettings", () => {
|
||||
it("lists snapshots and deletes one after confirming", async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SnapshotSettings />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByText("Frozen session")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
const buttons = screen.getAllByRole("button", { name: "Delete" });
|
||||
fireEvent.click(buttons[buttons.length - 1]!);
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith(7));
|
||||
await waitFor(() => expect(screen.queryByText("Frozen session")).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -3133,6 +3133,92 @@ exports[`screen snapshots > Dashboard 1`] = `
|
||||
<div
|
||||
class="flex items-center gap-2 flex-shrink-0"
|
||||
>
|
||||
<div
|
||||
class="w-40"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Date range"
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
title="Date range"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
All time
|
||||
</span>
|
||||
</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="w-44"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-haspopup="listbox"
|
||||
aria-label="AI tool"
|
||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
title="AI tool"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
All AI tools
|
||||
</span>
|
||||
</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn-ghost"
|
||||
>
|
||||
@@ -5296,6 +5382,110 @@ exports[`screen snapshots > Dashboard 1`] = `
|
||||
No cost data yet.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="card p-5"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-medium text-fg-secondary mb-5"
|
||||
>
|
||||
Spend pace
|
||||
</h3>
|
||||
<div
|
||||
class="space-y-3"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between items-center py-2 border-b border-border last:border-0"
|
||||
>
|
||||
<span
|
||||
class="text-xs text-fg-secondary"
|
||||
title="Average cost per day over the last 7 days, today included"
|
||||
>
|
||||
Burn rate (7-day avg / day)
|
||||
</span>
|
||||
<span
|
||||
class="text-sm font-mono font-medium text-status-success"
|
||||
>
|
||||
<span
|
||||
class="relative inline-block cursor-default"
|
||||
>
|
||||
$0.00
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between items-center py-2 border-b border-border last:border-0"
|
||||
>
|
||||
<span
|
||||
class="text-xs text-fg-secondary"
|
||||
>
|
||||
Month to date
|
||||
</span>
|
||||
<span
|
||||
class="text-sm font-mono font-medium text-status-success"
|
||||
>
|
||||
<span
|
||||
class="relative inline-block cursor-default"
|
||||
>
|
||||
$0.00
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between items-center py-2 border-b border-border last:border-0"
|
||||
>
|
||||
<span
|
||||
class="text-xs text-fg-secondary"
|
||||
title="Month to date plus the 7-day pace for each day left"
|
||||
>
|
||||
Forecast for this month
|
||||
</span>
|
||||
<span
|
||||
class="text-sm font-mono font-medium text-status-success"
|
||||
>
|
||||
<span
|
||||
class="relative inline-block cursor-default"
|
||||
>
|
||||
$0.00
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="card p-5"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-medium text-fg-secondary mb-5"
|
||||
>
|
||||
Budgets
|
||||
</h3>
|
||||
<p
|
||||
class="text-sm text-fg-muted"
|
||||
>
|
||||
No spend budget yet.
|
||||
|
||||
<a
|
||||
class="text-accent hover:underline"
|
||||
href="/settings/notifications"
|
||||
>
|
||||
Add one in Settings → Notifications
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="card p-5"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-medium text-fg-secondary mb-5"
|
||||
>
|
||||
Cost by AI tool
|
||||
</h3>
|
||||
<p
|
||||
class="text-sm text-fg-muted"
|
||||
>
|
||||
No cost data yet.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="card p-5"
|
||||
>
|
||||
@@ -5795,6 +5985,38 @@ exports[`screen snapshots > Session detail 1`] = `
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-end gap-1"
|
||||
>
|
||||
<button
|
||||
class="btn-ghost text-xs"
|
||||
title="Freeze this session into a read-only copy with a share link"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-camera h-4 w-4"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="13"
|
||||
r="3"
|
||||
/>
|
||||
</svg>
|
||||
Snapshot
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Refresh"
|
||||
class="btn-ghost"
|
||||
@@ -5930,6 +6152,52 @@ exports[`screen snapshots > Session detail 1`] = `
|
||||
</svg>
|
||||
Timeline (0/0)
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors border-transparent text-fg-muted hover:text-fg-secondary"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-film w-4 h-4"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
height="18"
|
||||
rx="2"
|
||||
width="18"
|
||||
x="3"
|
||||
y="3"
|
||||
/>
|
||||
<path
|
||||
d="M7 3v18"
|
||||
/>
|
||||
<path
|
||||
d="M3 7.5h4"
|
||||
/>
|
||||
<path
|
||||
d="M3 12h18"
|
||||
/>
|
||||
<path
|
||||
d="M3 16.5h4"
|
||||
/>
|
||||
<path
|
||||
d="M17 3v18"
|
||||
/>
|
||||
<path
|
||||
d="M17 7.5h4"
|
||||
/>
|
||||
<path
|
||||
d="M17 16.5h4"
|
||||
/>
|
||||
</svg>
|
||||
Replay
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
@@ -6913,7 +7181,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v2.3.1 (rebuild client to match)
|
||||
UI build v2.4.0 (rebuild client to match)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
@@ -8443,6 +8711,266 @@ exports[`screen snapshots > Settings — data 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
class="scroll-mt-24"
|
||||
id="push-ingest"
|
||||
>
|
||||
<section
|
||||
class="card p-5"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
<span
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-key-round w-4 h-4 text-fg-muted"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"
|
||||
/>
|
||||
<circle
|
||||
cx="16.5"
|
||||
cy="7.5"
|
||||
fill="currentColor"
|
||||
r=".5"
|
||||
/>
|
||||
</svg>
|
||||
Push ingest
|
||||
</span>
|
||||
</h3>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
For a machine this dashboard cannot reach over SSH: it pushes its own hooks to POST /api/hooks/ingest-batch with a token made here. Its sessions are tagged with the name it sends (push:<name>) and show live status and events; their token cost needs the transcripts, which only an SSH source brings.
|
||||
</p>
|
||||
<div
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="space-y-3 py-2"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="text-xs text-fg-muted"
|
||||
>
|
||||
Off — no token yet
|
||||
</span>
|
||||
<button
|
||||
class="btn-primary text-xs disabled:opacity-40"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-refresh-cw w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"
|
||||
/>
|
||||
<path
|
||||
d="M21 3v5h-5"
|
||||
/>
|
||||
<path
|
||||
d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"
|
||||
/>
|
||||
<path
|
||||
d="M8 16H3v5"
|
||||
/>
|
||||
</svg>
|
||||
Create token
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-fg-muted"
|
||||
>
|
||||
The other machine must reach this dashboard: start it with DASHBOARD_HOST (and DASHBOARD_ALLOWED_HOSTS for its name). Over plain http the token crosses the network readable — use it only on a network you trust, or behind TLS. The forwarder below keeps the token in a header file, never on a command line:
|
||||
</p>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<pre
|
||||
class="text-[11px] font-mono bg-surface-1 border border-border rounded-lg p-3 overflow-x-auto text-fg-secondary"
|
||||
>
|
||||
#!/bin/sh
|
||||
# ~/.claude/agenthub-push.sh <HookEvent> — a Claude Code hook command:
|
||||
# "command": "sh ~/.claude/agenthub-push.sh PreToolUse"
|
||||
# ~/.claude/agenthub-push.header (chmod 600) holds one line:
|
||||
# Authorization: Bearer <token>
|
||||
jq -c --arg t "$1" --arg s "$(hostname -s)" \\
|
||||
'{source: $s, events: [{hook_type: $t, data: .}]}' |
|
||||
curl -s -m 5 -o /dev/null -X POST "http://localhost/api/hooks/ingest-batch" \\
|
||||
-H "@$HOME/.claude/agenthub-push.header" \\
|
||||
-H "Content-Type: application/json" --data-binary @-
|
||||
exit 0
|
||||
</pre>
|
||||
<button
|
||||
class="btn-ghost text-xs absolute top-1.5 right-1.5"
|
||||
title="Copy"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-copy w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
height="14"
|
||||
rx="2"
|
||||
ry="2"
|
||||
width="14"
|
||||
x="8"
|
||||
y="8"
|
||||
/>
|
||||
<path
|
||||
d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-fg-muted"
|
||||
>
|
||||
At most 500 events and 1 MB per request. A hook for a session another source owns is refused; transcript paths are dropped (they name files on the sender). Codex hooks are taken; Antigravity hooks are not.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div
|
||||
class="scroll-mt-24"
|
||||
id="privacy"
|
||||
>
|
||||
<section
|
||||
class="card p-5"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
<span
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-shield-check w-4 h-4 text-fg-muted"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"
|
||||
/>
|
||||
<path
|
||||
d="m9 12 2 2 4-4"
|
||||
/>
|
||||
</svg>
|
||||
Privacy
|
||||
</span>
|
||||
</h3>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
What AgentHub keeps from hooks and imported history. Both are off by default and apply to new data only.
|
||||
</p>
|
||||
<div
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Redact secrets before storing
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
API keys and tokens of known shapes, bearer headers, private keys and password=/TOKEN= values in event payloads and summaries become [redacted]. The transcripts on disk are the CLIs' own files and are not changed.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-label="Redact secrets before storing"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="py-3 space-y-2"
|
||||
>
|
||||
<label
|
||||
class="block text-sm text-fg-secondary"
|
||||
for="privacy-ignore"
|
||||
>
|
||||
Ignored folders
|
||||
</label>
|
||||
<p
|
||||
class="text-xs text-fg-muted"
|
||||
>
|
||||
One absolute path per line. Hooks from these folders, and anything under them, are dropped whole; history import and the Codex reader skip their sessions. AGENTHUB_IGNORE_CWD sets the same list.
|
||||
</p>
|
||||
<textarea
|
||||
class="input w-full font-mono text-xs min-h-[72px]"
|
||||
id="privacy-ignore"
|
||||
placeholder="/home/you/clients/secret-project
|
||||
~/private"
|
||||
/>
|
||||
<button
|
||||
class="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<section
|
||||
class="card p-5 space-y-4 scroll-mt-24"
|
||||
id="cleanup"
|
||||
|
||||
@@ -318,6 +318,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
totalCost: r(cost),
|
||||
sessionCost: r(cost),
|
||||
},
|
||||
budgets: { status: r({ budgets: [] }) },
|
||||
settings: {
|
||||
info: r(settingsInfo),
|
||||
claudeHome: {
|
||||
@@ -328,6 +329,19 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
reimport: r({ ok: true, imported: 0, skipped: 0, errors: 0 }),
|
||||
reinstallHooks: r({ ok: true, hooks: { installed: true, hooks: {} } }),
|
||||
resetPricing: r({ ok: true, pricing: [] }),
|
||||
pushToken: {
|
||||
get: r({ enabled: false, env_owned: false }),
|
||||
rotate: r({ token: "t", enabled: true, env_owned: false }),
|
||||
revoke: r({ enabled: false, env_owned: false }),
|
||||
},
|
||||
privacy: {
|
||||
get: r({
|
||||
redact_secrets: false,
|
||||
ignore_cwd: [],
|
||||
env_owned: { redact_secrets: false, ignore_cwd: false },
|
||||
}),
|
||||
save: r({ ok: true }),
|
||||
},
|
||||
exportData: () => "/api/settings/export",
|
||||
cleanup: r({
|
||||
ok: true,
|
||||
@@ -483,6 +497,7 @@ vi.mock("../../lib/push", () => ({
|
||||
}));
|
||||
|
||||
// Page components (imported after the mocks above; vi.mock is hoisted).
|
||||
import { api } from "../../lib/api";
|
||||
import { Dashboard } from "../Dashboard";
|
||||
import { Sessions } from "../Sessions";
|
||||
import { SessionDetail } from "../SessionDetail";
|
||||
@@ -570,6 +585,19 @@ describe("screen snapshots", () => {
|
||||
const headings = [...container.querySelectorAll("h2")].map((h) => h.textContent);
|
||||
expect(headings).toContain(i18n.t("workflows:title"));
|
||||
});
|
||||
it("Dashboard Analytics reads its date range from ?range=", async () => {
|
||||
localStorage.removeItem("dashboard_tab");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/?tab=analytics&range=7d"]}>
|
||||
<Dashboard />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
const range = { from: "2026-06-04", to: "2026-06-10" };
|
||||
expect(api.analytics.get).toHaveBeenCalledWith({ tool: "", range });
|
||||
expect(api.pricing.totalCost).toHaveBeenCalledWith({ tool: "", range });
|
||||
expect(screen.getByRole("button", { name: /Date range/ })).toHaveTextContent("Last 7 days");
|
||||
});
|
||||
it("Sessions", async () => {
|
||||
await snapshot(<Sessions />, "/sessions");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user