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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user