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.
136 lines
4.9 KiB
TypeScript
136 lines
4.9 KiB
TypeScript
/**
|
|
* @file EventTimeline.tsx
|
|
* @description A session's events as a flat, expandable list — one row per
|
|
* event (age, status badge, origin · title, tool), opening into EventDetail.
|
|
* Used by the session analysis page's Timeline tab and the read-only snapshot
|
|
* page.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { AgentStatusBadge } from "./StatusBadge";
|
|
import { EventDetail } from "./EventDetail";
|
|
import {
|
|
agentOriginLabel,
|
|
buildEventTitle,
|
|
buildOriginLabel,
|
|
projectFromCwd,
|
|
projectFromEvent,
|
|
statusFromEventType,
|
|
} from "../lib/event-grouping";
|
|
import type { AgentInfo } from "../lib/event-grouping";
|
|
import { timeAgo } from "../lib/format";
|
|
import type { DashboardEvent } from "../lib/types";
|
|
|
|
export function EventTimeline({
|
|
events,
|
|
agentInfoById,
|
|
sessionNameById,
|
|
fallbackCwd,
|
|
}: {
|
|
events: DashboardEvent[];
|
|
agentInfoById: Map<string, AgentInfo>;
|
|
sessionNameById: Map<string, string>;
|
|
/** The session's cwd, for events that carry none of their own. */
|
|
fallbackCwd?: string | null;
|
|
}) {
|
|
const { t } = useTranslation("sessions");
|
|
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;
|
|
});
|
|
}
|
|
|
|
// 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]);
|
|
|
|
return (
|
|
<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(fallbackCwd) ?? 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>
|
|
);
|
|
}
|