"use client"; import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { copyToClipboard } from "@/shared/utils/clipboard"; import RequestLoggerDetail from "@/shared/components/RequestLoggerDetail"; import useEmailPrivacyStore from "@/store/emailPrivacyStore"; import { type TimelineLog, type ViewMode, VISIBLE_WINDOW_MS, BAR_HEIGHT, LANE_GAP, LANE_HEIGHT, HEADER_HEIGHT, AXIS_HEIGHT, MIN_BAR_WIDTH, DEFAULT_LIST_POLL_SECONDS, TIMELINE_LIST_POLL_STORAGE_KEY, FOLLOW_LINE_X, LIVE_LINE_FRACTION, computeBarRange, MODE_META, formatTimeAxis, getStatusColor, CONVERSATION_LANE_REUSE_STORAGE_KEY, allocateLanes, truncateModel, formatDateLabel, } from "@/shared/components/RequestTimeline.utils"; export type { TimelineLog } from "@/shared/components/RequestTimeline.utils"; export { allocateLanes } from "@/shared/components/RequestTimeline.utils"; export default function RequestTimeline({ initialSelectedId, }: { initialSelectedId?: string | null; } = {}) { const router = useRouter(); const t = useTranslations("requestTimeline"); const [logs, setLogs] = useState([]); const [mode, setMode] = useState("follow"); const [zoom, setZoom] = useState(1); const [hoveredId, setHoveredId] = useState(null); const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null); const [nowMs, setNowMs] = useState(0); const [canvasWidth, setCanvasWidth] = useState(1200); const [panOffsetMs, setPanOffsetMs] = useState(0); const [panFrozenMs, setPanFrozenMs] = useState(0); const [liveBaseMs, setLiveBaseMs] = useState(0); const [isDragging, setIsDragging] = useState(false); const [dragStartX, setDragStartX] = useState(0); const [dragStartOffset, setDragStartOffset] = useState(0); const { emailsVisible } = useEmailPrivacyStore(); const [selectedLog, setSelectedLog] = useState(null); const [detailData, setDetailData] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [detailLoggingEnabled, setDetailLoggingEnabled] = useState(false); const [conversationLaneReuseMinutes, setConversationLaneReuseMinutes] = useState(() => { if (globalThis.window === undefined) return 2; try { const saved = localStorage.getItem(CONVERSATION_LANE_REUSE_STORAGE_KEY); const parsed = saved ? Number(saved) : 2; return Number.isFinite(parsed) && parsed > 0 ? parsed : 2; } catch { return 2; } }); const [listPollSeconds, setListPollSeconds] = useState(() => { if (globalThis.window === undefined) return DEFAULT_LIST_POLL_SECONDS; try { const saved = localStorage.getItem(TIMELINE_LIST_POLL_STORAGE_KEY); const parsed = saved ? Number(saved) : DEFAULT_LIST_POLL_SECONDS; return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_LIST_POLL_SECONDS; } catch { return DEFAULT_LIST_POLL_SECONDS; } }); const canvasRef = useRef(null); const animRef = useRef(0); // Guards the ?id= deep-link mount effect below. Also armed by any manual // open/close so a stale `initialSelectedId` (App Router's router.replace() // commits the URL after the re-render it triggers, so the prop can briefly // still reflect the just-closed id on the very next render) can never // reopen the modal right after the user closed it. const initialOpenedRef = useRef(false); useEffect(() => { fetch("/api/logs/detail?limit=1") .then((res) => (res.ok ? res.json() : null)) .then((data) => { if (!data) return; setDetailLoggingEnabled(data.enabled === true); }) .catch(() => {}); }, []); useEffect(() => { let cancelled = false; fetch("/api/usage/call-logs?limit=200") .then((res) => (res.ok ? res.json() : [])) .then((data) => { if (!cancelled) setLogs(data); }) .catch(() => {}); const id = setInterval(() => { // #8354: skip the poll while the tab is backgrounded (Page Visibility // API), matching the pause-when-hidden pattern in RequestLoggerV2 and // UsageStats so a background tab doesn't keep hammering the API. if (document.visibilityState !== "visible") return; fetch("/api/usage/call-logs?limit=200") .then((res) => (res.ok ? res.json() : [])) .then((data) => setLogs(data)) .catch(() => {}); }, listPollSeconds * 1000); return () => { cancelled = true; clearInterval(id); }; }, [listPollSeconds]); useEffect(() => { if (!canvasRef.current) return undefined; const observer = new ResizeObserver((entries) => { for (const entry of entries) { setCanvasWidth(entry.contentRect.width); } }); observer.observe(canvasRef.current); return () => observer.disconnect(); }, []); useEffect(() => { let lastUpdate = 0; const THROTTLE_MS = mode === "follow" ? 0 : 50; const onFrame = (frame: number) => { if (frame - lastUpdate >= THROTTLE_MS) { lastUpdate = frame; setNowMs(Date.now()); } animRef.current = requestAnimationFrame(onFrame); }; animRef.current = requestAnimationFrame(onFrame); return () => cancelAnimationFrame(animRef.current); }, [mode]); const windowMs = VISIBLE_WINDOW_MS / zoom; const timeRange = useMemo(() => { if (mode === "follow") { const end = nowMs + windowMs * (1 - FOLLOW_LINE_X); const start = end - windowMs; return { start, end }; } if (mode === "live") { const base = liveBaseMs || nowMs; const elapsed = nowMs - base; const snapWindow = windowMs * LIVE_LINE_FRACTION; const slot = elapsed % snapWindow; const start = nowMs - slot - windowMs * (1 - LIVE_LINE_FRACTION); return { start, end: start + windowMs }; } const base = panFrozenMs || nowMs; const start = base - windowMs * 0.5 + panOffsetMs; return { start, end: start + windowMs }; }, [mode, nowMs, windowMs, liveBaseMs, panFrozenMs, panOffsetMs]); const { start: timeStart, end: timeEnd } = timeRange; const nowLineX = useMemo(() => { if (mode === "follow") { return FOLLOW_LINE_X * 100; } const totalMs = timeEnd - timeStart; if (totalMs <= 0) return 50; return Math.max(0, Math.min(100, ((nowMs - timeStart) / totalMs) * 100)); }, [mode, nowMs, timeStart, timeEnd]); const visibleLogs = useMemo(() => { return logs.filter((log) => { const { startMs, endMs } = computeBarRange(log, nowMs); return endMs >= timeStart && startMs <= timeEnd; }); }, [logs, timeStart, timeEnd, nowMs]); const laneMap = useMemo( () => allocateLanes(logs, nowMs, conversationLaneReuseMinutes * 60 * 1000), [logs, nowMs, conversationLaneReuseMinutes] ); const maxLane = useMemo(() => (laneMap.size > 0 ? Math.max(...laneMap.values()) : 0), [laneMap]); const barElements = useMemo(() => { const totalMs = timeEnd - timeStart; return visibleLogs.map((log) => { const { startMs, endMs } = computeBarRange(log, nowMs); const leftPct = ((startMs - timeStart) / totalMs) * 100; const rightPct = ((endMs - timeStart) / totalMs) * 100; const widthPct = Math.max(rightPct - leftPct, (MIN_BAR_WIDTH / canvasWidth) * 100); const lane = laneMap.get(log.id) ?? 0; const topPx = lane * LANE_HEIGHT; const color = getStatusColor(log.status, log.active); const opacity = log.active ? 0.9 : 0.7; return { log, leftPct, widthPct, topPx, color, opacity }; }); }, [visibleLogs, timeStart, timeEnd, nowMs, laneMap, canvasWidth]); // One connector per consecutive pair of bars sharing a conversation id AND // lane (i.e. allocateLanes actually treated them as one continuous // conversation, not two bars that just happen to be adjacent). const connectorElements = useMemo(() => { const byConversation = new Map(); for (const el of barElements) { const cid = el.log.sessionTag; if (!cid) continue; const list = byConversation.get(cid); if (list) list.push(el); else byConversation.set(cid, [el]); } const connectors: { id: string; x1: number; x2: number; y: number }[] = []; for (const els of byConversation.values()) { const sorted = [...els].sort( (a, b) => new Date(a.log.timestamp).getTime() - new Date(b.log.timestamp).getTime() ); for (let i = 0; i < sorted.length - 1; i++) { const a = sorted[i]; const b = sorted[i + 1]; if (a.topPx !== b.topPx) continue; // different lanes — reuse window lapsed connectors.push({ id: `${a.log.id}-${b.log.id}`, x1: a.leftPct + a.widthPct, x2: b.leftPct, y: a.topPx + BAR_HEIGHT / 2, }); } } return connectors; }, [barElements]); const axisTicks = useMemo(() => { const totalMs = timeEnd - timeStart; if (totalMs <= 0) return []; const MS_MIN = 60 * 1000; const MS_10MIN = 10 * MS_MIN; const MS_HOUR = 60 * MS_MIN; const MS_DAY = 24 * MS_HOUR; const startOfHour = (ms: number) => { const d = new Date(ms); d.setMinutes(0, 0, 0); return d.getTime(); }; const startOfDay = (ms: number) => { const d = new Date(ms); d.setHours(0, 0, 0, 0); return d.getTime(); }; const ticks: { pct: number; label: string; kind: "day" | "hour" | "minor" }[] = []; const addTick = (ms: number, label: string, kind: "day" | "hour" | "minor") => { const pct = ((ms - timeStart) / totalMs) * 100; if (pct >= 0 && pct <= 100) { ticks.push({ pct, label, kind }); } }; if (totalMs <= MS_HOUR * 4) { const interval = totalMs <= MS_10MIN ? MS_MIN : MS_10MIN; const first = Math.ceil(timeStart / interval) * interval; for (let ms = first; ms <= timeEnd; ms += interval) { const isDay = startOfDay(ms) === ms; const isHour = startOfHour(ms) === ms; addTick( ms, isDay ? formatDateLabel(ms) : formatTimeAxis(ms), isDay ? "day" : isHour ? "hour" : "minor" ); } } else if (totalMs <= MS_DAY) { const interval = totalMs <= MS_HOUR * 6 ? MS_10MIN : MS_HOUR; const first = Math.ceil(timeStart / interval) * interval; for (let ms = first; ms <= timeEnd; ms += interval) { const isDay = startOfDay(ms) === ms; addTick(ms, isDay ? formatDateLabel(ms) : formatTimeAxis(ms), isDay ? "day" : "hour"); } } else { const firstDay = startOfDay(timeStart); const start = firstDay < timeStart ? firstDay + MS_DAY : firstDay; for (let ms = start; ms <= timeEnd; ms += MS_DAY) { addTick(ms, formatDateLabel(ms), "day"); } } return ticks; }, [timeStart, timeEnd]); const contentHeight = Math.max((maxLane + 1) * LANE_HEIGHT + 20, 200); const handleBarClick = useCallback( (log: TimelineLog) => { initialOpenedRef.current = true; setSelectedLog(log); setDetailData(null); setDetailLoading(true); try { const url = new URL(globalThis.location.href); url.searchParams.set("id", log.id); router.replace(url.pathname + url.search); } catch { // ignore navigation errors } fetch(`/api/logs/${log.id}`, { cache: "no-store" }) .then((res) => (res.ok ? res.json() : null)) .then((data) => { if (data) setDetailData(data); }) .catch(() => {}) .finally(() => setDetailLoading(false)); }, [router] ); const closeDetail = useCallback(() => { initialOpenedRef.current = true; setSelectedLog(null); setDetailData(null); try { const url = new URL(globalThis.location.href); url.searchParams.delete("id"); router.replace(url.pathname + url.search); } catch { // ignore navigation errors } }, [router]); // Deep-link support: open the request from ?id= on mount without waiting for // it to show up in the polled `logs` list (mirrors RequestLoggerV2's openDetail). const openById = useCallback( async (id: string) => { try { const url = new URL(globalThis.location.href); url.searchParams.set("id", id); router.replace(url.pathname + url.search); } catch { // ignore navigation errors } setDetailLoading(true); try { const res = await fetch(`/api/logs/${id}`, { cache: "no-store" }); const data = res.ok ? await res.json() : null; if (data) { setSelectedLog({ id: data.id ?? id, timestamp: data.timestamp, status: data.status ?? 0, model: data.model ?? null, provider: data.provider ?? null, account: data.account ?? null, duration: data.duration ?? 0, tokens: data.tokens ?? { in: 0, out: 0 }, active: data.active, error: data.error ?? null, path: data.path ?? null, }); setDetailData(data); } } catch { // ignore fetch errors } finally { setDetailLoading(false); } }, [router] ); useEffect(() => { if (!initialSelectedId || initialOpenedRef.current) return; initialOpenedRef.current = true; openById(initialSelectedId) .then((r) => r) .catch(() => {}); }, [initialSelectedId, openById]); const handleBarHover = (log: TimelineLog, e: React.MouseEvent) => { setHoveredId(log.id); setTooltipPos({ x: e.clientX, y: e.clientY }); }; const handleBarLeave = () => { setHoveredId(null); setTooltipPos(null); }; const handleReset = useCallback(() => { setPanOffsetMs(0); setLiveBaseMs(nowMs); }, [nowMs]); const handleModeChange = useCallback( (newMode: ViewMode) => { setMode(newMode); setPanOffsetMs(0); if (newMode === "pan") setPanFrozenMs(nowMs); setLiveBaseMs(nowMs); }, [nowMs] ); const handleMouseDown = (e: React.MouseEvent) => { if (e.button !== 0) return; if (mode !== "pan") { setMode("pan"); setPanFrozenMs(nowMs); setLiveBaseMs(nowMs); } setIsDragging(true); setDragStartX(e.clientX); setDragStartOffset(mode === "pan" ? panOffsetMs : 0); }; const handleMouseMove = useCallback( (e: React.MouseEvent) => { if (!isDragging) return; const dx = e.clientX - dragStartX; const msPerPx = windowMs / canvasWidth; setPanOffsetMs(dragStartOffset - dx * msPerPx); }, [isDragging, dragStartX, dragStartOffset, windowMs, canvasWidth] ); const handleMouseUp = useCallback(() => { setIsDragging(false); }, []); // --- Touch handlers (mobile panning + pinch-to-zoom) --- // Track touch start position to distinguish taps from drags. // A tap (< 10px movement) passes through to onClick for bar selection; // a drag triggers pan mode. const touchStartRef = useRef<{ x: number; y: number; dist: number; offset: number; moved: boolean; } | null>(null); const handleTouchStart = (e: React.TouchEvent) => { if (e.touches.length === 1) { const touch = e.touches[0]; touchStartRef.current = { x: touch.clientX, y: touch.clientY, dist: 0, offset: mode === "pan" ? panOffsetMs : 0, moved: false, }; } else if (e.touches.length === 2) { // Pinch-to-zoom: record initial distance between two fingers const [a, b] = [e.touches[0], e.touches[1]]; const dist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY); touchStartRef.current = { x: 0, y: 0, dist, offset: zoom, moved: false }; setIsDragging(false); } }; const handleTouchMove = useCallback( (e: React.TouchEvent) => { if (!touchStartRef.current) return; if (e.touches.length === 1) { const touch = e.touches[0]; const dx = touch.clientX - touchStartRef.current.x; const dy = touch.clientY - touchStartRef.current.y; // Only start panning after a 10px threshold to avoid stealing taps if (!touchStartRef.current.moved && Math.hypot(dx, dy) < 10) return; if (!touchStartRef.current.moved) { touchStartRef.current.moved = true; if (mode !== "pan") { setMode("pan"); setPanFrozenMs(nowMs); setLiveBaseMs(nowMs); } setIsDragging(true); } const msPerPx = windowMs / canvasWidth; setPanOffsetMs(touchStartRef.current.offset - dx * msPerPx); } else if (e.touches.length === 2 && touchStartRef.current.dist > 0) { // Pinch-to-zoom e.preventDefault(); const [a, b] = [e.touches[0], e.touches[1]]; const dist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY); const scale = dist / touchStartRef.current.dist; setZoom(Math.max(0.001, Math.min(8, touchStartRef.current.offset * scale))); } }, [mode, nowMs, windowMs, canvasWidth] ); const handleTouchEnd = useCallback(() => { setIsDragging(false); touchStartRef.current = null; }, []); const handleWheel = useCallback((e: React.WheelEvent) => { if (e.ctrlKey || e.metaKey) return; e.preventDefault(); const factor = e.deltaY < 0 ? 1.25 : 0.8; setZoom((z) => Math.max(0.001, Math.min(8, z * factor))); }, []); const hoveredLog = hoveredId ? logs.find((l) => l.id === hoveredId) : null; return (
{/* Header */}

{t("title")}

{visibleLogs.length} visible / {logs.length} total
{/* Mode selector */}
{(["follow", "live", "pan"] as ViewMode[]).map((m) => ( ))}
{/* Conversation lane-reuse window: how long a lane stays reserved for its conversation before falling back to normal packing. */} {/* How often the timeline re-polls /api/usage/call-logs for new rows. */} {/* Zoom */}
{zoom >= 1 ? `${zoom}x` : `${zoom * 100}%`}
{/* Canvas */}
{/* Scrollable content area */}
{/* Time axis */}
{axisTicks.map((axisTick, i) => (
{axisTick.label}
))}
{/* Horizontal grid lines */} {Array.from({ length: maxLane + 1 }).map((_, i) => (
))} {/* Request bars */} {barElements.map(({ log, leftPct, widthPct, topPx, color, opacity }) => (
handleBarClick(log)} onMouseEnter={(e) => handleBarHover(log, e)} onMouseMove={(e) => setTooltipPos({ x: e.clientX, y: e.clientY })} onMouseLeave={handleBarLeave} >
{truncateModel(log.model)}
))} {/* Conversation connectors — one arrow per consecutive same-conversation bar pair sharing a lane. */} {connectorElements.map(({ id, x1, x2, y }) => ( ))}
{/* NOW line — full height of the canvas, outside content div */}
NOW
{/* Vertical lines — full height of the canvas, same position as axis ticks */} {axisTicks.map((axisTick) => (
))}
{/* Tooltip */} {hoveredLog && tooltipPos && (
{hoveredLog.model || t("unknownModel")} {hoveredLog.active && ( {t("active")} )}
{t("started")} {(() => { const ts = new Date(hoveredLog.timestamp).getTime(); const startMs = hoveredLog.active || hoveredLog.completed ? ts : ts - (hoveredLog.duration || 0); return new Date(startMs).toLocaleTimeString(); })()}
{!hoveredLog.active && (
{t("ended")} {new Date( hoveredLog.completed ? new Date(hoveredLog.timestamp).getTime() + (hoveredLog.duration || 0) : new Date(hoveredLog.timestamp).getTime() ).toLocaleTimeString()}
)}
{t("duration")} {hoveredLog.active ? `~${Math.round((nowMs - computeBarRange(hoveredLog, nowMs).startMs) / 1000).toLocaleString()}s` : `${hoveredLog.duration.toLocaleString()}ms`}
{t("status")} {hoveredLog.status || t("pending")}
{hoveredLog.provider && (
{t("provider")} {hoveredLog.provider}
)}
{t("tokens")} {hoveredLog.tokens.in.toLocaleString()} / {hoveredLog.tokens.out.toLocaleString()}
{hoveredLog.error && (
{hoveredLog.error}
)}
)} {/* Legend */}
2xx
4xx
5xx
{t("active")}
{t("other")}
{t("tenMinutes")}
{t("hour")}
{t("day")}
{t(`modes.${MODE_META[mode].descriptionKey}`)}
{selectedLog && ( )}
); }