4aae61297f
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.
290 lines
10 KiB
TypeScript
290 lines
10 KiB
TypeScript
/**
|
||
* @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>
|
||
);
|
||
}
|