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:
2026-10-06 17:12:28 +07:00
parent 837da6e5de
commit 4aae61297f
146 changed files with 13565 additions and 537 deletions
+56 -109
View File
@@ -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>
);
}