Files
AgentHub/client/src/components/EventReplay.tsx
T
nntrivi2001 4aae61297f feat: analytics parity — tool cost, date ranges, budgets, query explorer, replay, snapshots (v2.4.0)
Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
  Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
  with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
  Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
  its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
  status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
  reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
  ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files

Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
2026-10-06 17:12:28 +07:00

290 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file EventReplay.tsx
* @description The session Replay tab: a scrubber over a session's recorded
* events (play/pause, 0.5×–5×, step, jump, arrow keys), a lifecycle mini-map
* whose markers jump the cursor, the state derived up to the cursor
* (`lib/replay.ts`) and the selected event through EventDetail. Fed a list of
* events, so the live analysis page and a frozen snapshot share it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useState, type KeyboardEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Pause, Play, SkipBack, SkipForward, StepBack, StepForward } from "lucide-react";
import { api } from "../lib/api";
import {
chronological,
lifecycleMarkers,
replayState,
LIFECYCLE_TYPES,
REPLAY_SPEEDS,
REPLAY_STEP_MS,
} from "../lib/replay";
import { buildEventTitle } from "../lib/event-grouping";
import type { AgentInfo } from "../lib/event-grouping";
import { formatDateTimeFull, formatMs } from "../lib/format";
import type { DashboardEvent } from "../lib/types";
import { AgentStatusBadge } from "./StatusBadge";
import { EventDetail } from "./EventDetail";
/** The newest this many events are replayed; older ones are left out. */
export const REPLAY_MAX_EVENTS = 5000;
const PAGE = 500;
/** A session's events in playback order (the newest REPLAY_MAX_EVENTS). */
export async function loadReplayEvents(
sessionId: string
): Promise<{ events: DashboardEvent[]; total: number }> {
const all: DashboardEvent[] = [];
let total = 0;
do {
const r = await api.events.list({ session_id: sessionId, limit: PAGE, offset: all.length });
total = r.total;
if (r.events.length === 0) break;
all.push(...r.events);
} while (all.length < Math.min(total, REPLAY_MAX_EVENTS));
return { events: chronological(all.slice(0, REPLAY_MAX_EVENTS)), total };
}
const MARKER_COLOR: Record<string, string> = {
SessionStart: "bg-status-success",
UserPromptSubmit: "bg-accent",
Compaction: "bg-status-warning",
SubagentStop: "bg-violet-500",
APIError: "bg-status-danger",
Stop: "bg-fg-muted",
};
const btn =
"inline-flex h-8 w-8 items-center justify-center rounded-md text-fg-secondary hover:bg-surface-3 hover:text-fg-primary disabled:opacity-40 disabled:cursor-not-allowed";
export function EventReplay({
events: raw,
total,
agentInfoById,
sessionNameById,
}: {
events: DashboardEvent[];
/** The session's full event count, when more than `events` were recorded. */
total?: number;
agentInfoById: Map<string, AgentInfo>;
sessionNameById: Map<string, string>;
}) {
const { t } = useTranslation("sessions");
const events = useMemo(() => chronological(raw), [raw]);
const markers = useMemo(() => lifecycleMarkers(events), [events]);
const last = events.length - 1;
const [cursor, setCursor] = useState(0);
const [playing, setPlaying] = useState(false);
const [speed, setSpeed] = useState<number>(1);
const at = Math.min(cursor, Math.max(0, last));
const state = useMemo(() => replayState(events, at), [events, at]);
const event = events[at];
useEffect(() => {
if (!playing) return;
if (at >= last) {
setPlaying(false);
return;
}
const timer = setTimeout(() => setCursor((c) => Math.min(c + 1, last)), REPLAY_STEP_MS / speed);
return () => clearTimeout(timer);
}, [playing, at, last, speed]);
if (!event) return <p className="text-sm text-fg-muted">{t("detail.noEvents")}</p>;
const go = (i: number) => setCursor(Math.max(0, Math.min(last, i)));
const toggle = () => {
if (!playing && at >= last) setCursor(0);
setPlaying((p) => !p);
};
const onKey = (e: KeyboardEvent) => {
// The slider steps itself on arrow keys; only the rest is ours there.
const onSlider = e.target instanceof HTMLInputElement;
if (e.key === " " && !(e.target instanceof HTMLButtonElement)) {
e.preventDefault();
toggle();
} else if (onSlider) return;
else if (e.key === "ArrowLeft") go(at - 1);
else if (e.key === "ArrowRight") go(at + 1);
else if (e.key === "Home") go(0);
else if (e.key === "End") go(last);
else return;
e.preventDefault();
};
const markerLabel = (type: string) => t(`replay.marker.${type}`);
const present = LIFECYCLE_TYPES.filter((k) => markers.some((m) => m.type === k));
return (
<div
className="space-y-4 outline-none"
tabIndex={0}
onKeyDown={onKey}
role="group"
aria-label={t("replay.label")}
data-testid="event-replay"
>
{total != null && total > events.length && (
<p className="text-xs text-fg-muted">
{t("replay.truncated", { shown: events.length, total })}
</p>
)}
<div className="card space-y-3 p-4">
<div className="flex flex-wrap items-center gap-1">
<button type="button" className={btn} onClick={() => go(0)} title={t("replay.start")}>
<SkipBack className="h-4 w-4" />
</button>
<button
type="button"
className={btn}
onClick={() => go(at - 1)}
disabled={at <= 0}
title={t("replay.back")}
>
<StepBack className="h-4 w-4" />
</button>
<button
type="button"
className={`${btn} bg-accent/15 text-accent`}
onClick={toggle}
title={playing ? t("replay.pause") : t("replay.play")}
aria-pressed={playing}
>
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</button>
<button
type="button"
className={btn}
onClick={() => go(at + 1)}
disabled={at >= last}
title={t("replay.forward")}
>
<StepForward className="h-4 w-4" />
</button>
<button type="button" className={btn} onClick={() => go(last)} title={t("replay.end")}>
<SkipForward className="h-4 w-4" />
</button>
<div
className="ml-2 flex items-center gap-0.5"
role="group"
aria-label={t("replay.speed")}
>
{REPLAY_SPEEDS.map((s) => (
<button
key={s}
type="button"
onClick={() => setSpeed(s)}
aria-pressed={speed === s}
className={`rounded px-2 py-1 text-xs font-mono ${
speed === s
? "bg-accent/15 text-accent"
: "text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{s}×
</button>
))}
</div>
<span className="ml-auto font-mono text-xs text-fg-muted">
{t("replay.position", { at: at + 1, n: events.length })} ·{" "}
{formatDateTimeFull(event.created_at)}
</span>
</div>
<input
type="range"
min={0}
max={last}
value={at}
onChange={(e) => go(Number(e.target.value))}
aria-label={t("replay.scrubber")}
className="w-full accent-[rgb(var(--accent))]"
/>
<div
className="relative h-4 rounded bg-surface-2"
role="group"
aria-label={t("replay.minimap")}
>
<div
className="pointer-events-none absolute inset-y-0 w-px bg-fg-primary"
style={{ left: `${last > 0 ? (at / last) * 100 : 0}%` }}
/>
{markers.map((m) => (
<button
key={m.index}
type="button"
onClick={() => go(m.index)}
title={`${markerLabel(m.type)} · #${m.index + 1}`}
aria-label={`${markerLabel(m.type)} · #${m.index + 1}`}
className={`absolute inset-y-0 w-1.5 -translate-x-1/2 rounded-sm opacity-80 hover:opacity-100 ${MARKER_COLOR[m.type]}`}
style={{ left: `${m.pct}%` }}
/>
))}
</div>
{present.length > 0 && (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-fg-muted">
{present.map((k) => (
<span key={k} className="inline-flex items-center gap-1">
<span className={`h-2 w-2 rounded-sm ${MARKER_COLOR[k]}`} />
{markerLabel(k)}
</span>
))}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-6">
<Tile label={t("replay.state.status")}>
{state.status ? <AgentStatusBadge status={state.status} /> : "-"}
</Tile>
<Tile label={t("replay.state.tool")}>
<span className="font-mono">{state.currentTool ?? "-"}</span>
</Tile>
<Tile label={t("replay.state.prompts")}>{state.prompts}</Tile>
<Tile label={t("replay.state.errors")}>{state.errors}</Tile>
<Tile label={t("replay.state.compactions")}>{state.compactions}</Tile>
<Tile label={t("replay.state.elapsed")}>{formatMs(state.elapsedMs)}</Tile>
</div>
{state.toolCounts.length > 0 && (
<div
className="flex flex-wrap gap-1.5"
role="group"
aria-label={t("replay.state.toolCounts")}
>
{state.toolCounts.map((x) => (
<span
key={x.tool}
className="rounded bg-surface-2 px-2 py-0.5 font-mono text-[11px] text-fg-secondary"
>
{x.tool} ×{x.count}
</span>
))}
</div>
)}
<div className="card overflow-hidden">
<div className="border-b border-border px-5 py-3 text-sm text-fg-secondary">
<span className="mr-2 font-mono text-xs text-fg-muted">#{at + 1}</span>
{buildEventTitle(event)}
</div>
<EventDetail
event={event}
agentInfoById={agentInfoById}
sessionNameById={sessionNameById}
/>
</div>
</div>
);
}
function Tile({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2">
<div className="text-[10px] uppercase tracking-wider text-fg-muted">{label}</div>
<div className="mt-1 text-sm font-semibold text-fg-primary">{children}</div>
</div>
);
}