Files
OmniRoute/src/shared/components/RequestTimeline.tsx
Alex Jordan 7ca73697b0 fix(i18n): localize hardcoded web UI copy (#9245)
* fix(i18n): localize hardcoded web UI copy

* test(i18n): cover hardcoded UI regressions

* chore(changelog): add PR 9245 fragment
2026-08-11 10:37:50 -03:00

844 lines
29 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { getHttpStatusStyle } from "@/shared/constants/colors";
import { copyToClipboard } from "@/shared/utils/clipboard";
import RequestLoggerDetail from "@/shared/components/RequestLoggerDetail";
interface TimelineLog {
id: string;
timestamp: string;
status: number;
model: string | null;
provider: string | null;
account: string | null;
duration: number;
tokens: { in: number; out: number };
active?: boolean;
completed?: boolean;
error?: string | null;
path?: string | null;
}
interface Lane {
startMs: number;
endMs: number;
}
type ViewMode = "follow" | "live" | "pan";
const VISIBLE_WINDOW_MS = 5 * 60 * 1000;
const BAR_HEIGHT = 28;
const LANE_GAP = 4;
const LANE_HEIGHT = BAR_HEIGHT + LANE_GAP;
const HEADER_HEIGHT = 48;
const AXIS_HEIGHT = 32;
const MIN_BAR_WIDTH = 3;
const POLL_INTERVAL_MS = 2000;
const FOLLOW_LINE_X = 0.75;
const LIVE_LINE_FRACTION = 0.9;
function computeBarRange(log: TimelineLog, nowMs: number): { startMs: number; endMs: number } {
const ts = new Date(log.timestamp).getTime();
if (log.active) return { startMs: ts, endMs: nowMs };
if (log.completed) return { startMs: ts, endMs: ts + (log.duration || 0) };
return { startMs: ts - (log.duration || 0), endMs: ts };
}
const MODE_META: Record<ViewMode, { labelKey: string; descriptionKey: string }> = {
follow: {
labelKey: "follow",
descriptionKey: "followDescription",
},
live: {
labelKey: "now",
descriptionKey: "nowDescription",
},
pan: {
labelKey: "pan",
descriptionKey: "panDescription",
},
};
function formatTimeAxis(ms: number): string {
const d = new Date(ms);
const h = d.getHours().toString().padStart(2, "0");
const m = d.getMinutes().toString().padStart(2, "0");
const s = d.getSeconds().toString().padStart(2, "0");
return `${h}:${m}:${s}`;
}
function getStatusColor(status: number, active: boolean | undefined): string {
if (active) return "#6366F1";
return getHttpStatusStyle(status).bg;
}
function allocateLanes(items: TimelineLog[], nowMs: number): Map<string, number> {
const lanes: Lane[] = [];
const laneMap = new Map<string, number>();
const sorted = [...items].sort((a, b) => {
const aStart = new Date(a.timestamp).getTime();
const bStart = new Date(b.timestamp).getTime();
return aStart - bStart;
});
for (const item of sorted) {
const { startMs, endMs } = computeBarRange(item, nowMs);
let placed = false;
for (let i = 0; i < lanes.length; i++) {
if (lanes[i].endMs < startMs) {
lanes[i] = { startMs, endMs };
laneMap.set(item.id, i);
placed = true;
break;
}
}
if (!placed) {
laneMap.set(item.id, lanes.length);
lanes.push({ startMs, endMs });
}
}
return laneMap;
}
function truncateModel(model: string | null): string {
if (!model) return "";
const parts = model.split("/");
const short = parts[parts.length - 1];
return short.length > 16 ? short.slice(0, 15) + "\u2026" : short;
}
function formatDateLabel(ms: number): string {
const d = new Date(ms);
const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
return `${months[d.getMonth()]} ${d.getDate()}`;
}
export default function RequestTimeline({
initialSelectedId,
}: {
initialSelectedId?: string | null;
} = {}) {
const router = useRouter();
const t = useTranslations("requestTimeline");
const [logs, setLogs] = useState<TimelineLog[]>([]);
const [mode, setMode] = useState<ViewMode>("follow");
const [zoom, setZoom] = useState(1);
const [hoveredId, setHoveredId] = useState<string | null>(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 [selectedLog, setSelectedLog] = useState<TimelineLog | null>(null);
const [detailData, setDetailData] = useState<any>(null);
const [detailLoading, setDetailLoading] = useState(false);
const canvasRef = useRef<HTMLDivElement>(null);
const animRef = useRef<number>(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(() => {
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(() => {});
}, POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(id);
};
}, []);
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), [logs, nowMs]);
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]);
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) => {
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);
}
}, []);
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 (
<div className="flex flex-col h-full min-h-0 select-none">
{/* Header */}
<div
className="flex items-center justify-between px-4 py-2 border-b border-border shrink-0"
style={{ height: HEADER_HEIGHT }}
>
<div className="flex items-center gap-3">
<h2 className="text-sm font-semibold text-text-main">{t("title")}</h2>
<span className="text-[10px] text-text-muted font-mono">
{visibleLogs.length} visible / {logs.length} total
</span>
</div>
<div className="flex items-center gap-2">
{/* Mode selector */}
<div className="flex items-center rounded-lg border border-border overflow-hidden">
{(["follow", "live", "pan"] as ViewMode[]).map((m) => (
<button
key={m}
onClick={() => handleModeChange(m)}
title={t(`modes.${MODE_META[m].descriptionKey}`)}
className={`px-2.5 py-1 text-[11px] font-medium transition-colors ${
mode === m
? "bg-primary text-white"
: "bg-bg-subtle text-text-muted hover:text-text-main"
}`}
>
{t(`modes.${MODE_META[m].labelKey}`)}
</button>
))}
</div>
<button
onClick={handleReset}
title={`Jump to current time, NOW line resets to position ${Math.round(nowLineX)}%`}
className="px-2 py-1 text-[11px] text-text-muted hover:text-text-main bg-bg-subtle rounded-md border border-border transition-colors"
>
{t("reset")}
</button>
{/* Zoom */}
<div className="flex items-center gap-1 rounded-lg border border-border overflow-hidden">
<button
onClick={() => setZoom((z) => Math.max(0.001, z * 0.5))}
className="px-2 py-1 text-[11px] text-text-muted hover:text-text-main bg-bg-subtle transition-colors"
>
-
</button>
<span className="px-1 text-[10px] text-text-muted font-mono min-w-[36px] text-center">
{zoom >= 1 ? `${zoom}x` : `${zoom * 100}%`}
</span>
<button
onClick={() => setZoom((z) => Math.min(8, z * 2))}
className="px-2 py-1 text-[11px] text-text-muted hover:text-text-main bg-bg-subtle transition-colors"
>
+
</button>
</div>
</div>
</div>
{/* Canvas */}
<div
ref={canvasRef}
className={`flex-1 min-h-0 overflow-hidden relative bg-surface ${
isDragging ? "cursor-grabbing" : mode === "pan" ? "cursor-grab" : ""
}`}
style={{ touchAction: "none" }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onWheel={handleWheel}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
>
{/* Scrollable content area */}
<div className="absolute left-0 right-0" style={{ height: contentHeight }}>
{/* Time axis */}
<div
className="absolute left-0 right-0 border-b border-border/50 bg-surface/90 backdrop-blur-sm z-5"
style={{ top: 0, height: AXIS_HEIGHT }}
>
{axisTicks.map((axisTick, i) => (
<div key={i} className="absolute" style={{ left: `${axisTick.pct}%`, top: 0 }}>
<div className="w-px h-3 bg-border -translate-x-1/2" />
<span className="block text-[9px] text-text-muted font-mono mt-0.5 whitespace-nowrap -translate-x-1/2 text-center">
{axisTick.label}
</span>
</div>
))}
</div>
{/* Horizontal grid lines */}
{Array.from({ length: maxLane + 1 }).map((_, i) => (
<div
key={`grid-${i}`}
className="absolute left-0 right-0 border-b border-border/20"
style={{ top: AXIS_HEIGHT + i * LANE_HEIGHT + BAR_HEIGHT }}
/>
))}
{/* Request bars */}
{barElements.map(({ log, leftPct, widthPct, topPx, color, opacity }) => (
<div
key={log.id}
data-testid={`timeline-bar-${log.id}`}
className="absolute rounded-sm cursor-pointer transition-opacity duration-100"
style={{
left: `${leftPct}%`,
width: `${widthPct}%`,
top: AXIS_HEIGHT + topPx,
height: BAR_HEIGHT,
backgroundColor: color,
opacity,
minWidth: MIN_BAR_WIDTH,
zIndex: 2,
}}
onClick={() => handleBarClick(log)}
onMouseEnter={(e) => handleBarHover(log, e)}
onMouseMove={(e) => setTooltipPos({ x: e.clientX, y: e.clientY })}
onMouseLeave={handleBarLeave}
>
<div className="w-full h-full flex items-center px-1.5 overflow-hidden">
<span className="text-[11px] font-mono text-white/90 truncate whitespace-nowrap">
{truncateModel(log.model)}
</span>
</div>
</div>
))}
</div>
{/* NOW line — full height of the canvas, outside content div */}
<div
className="absolute z-10 pointer-events-none"
style={{
left: `${nowLineX}%`,
top: 0,
bottom: 0,
}}
>
<div className="w-px h-full bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.6)] -translate-x-1/2" />
<div className="absolute top-[2px] -translate-x-1/2 bg-red-500 text-white text-[8px] font-mono px-1.5 py-0.5 rounded-b-sm font-bold tracking-wider">
NOW
</div>
</div>
{/* Vertical lines — full height of the canvas, same position as axis ticks */}
{axisTicks.map((axisTick) => (
<div
key={`vl-${axisTick.pct}`}
className="absolute top-0 bottom-0 pointer-events-none -translate-x-1/2"
style={{
left: `${axisTick.pct}%`,
width: axisTick.kind === "day" ? 2 : 1,
backgroundColor:
axisTick.kind === "day"
? "rgba(99,102,241,0.5)"
: axisTick.kind === "hour"
? "rgba(148,163,184,0.45)"
: "rgba(148,163,184,0.15)",
zIndex: 1,
}}
/>
))}
</div>
{/* Tooltip */}
{hoveredLog && tooltipPos && (
<div
className="fixed z-50 pointer-events-none bg-surface border border-border rounded-lg shadow-elevated p-3 max-w-[280px]"
style={{
left: tooltipPos.x + 12,
top: tooltipPos.y - 8,
}}
>
<div className="flex items-center gap-2 mb-1.5">
<div
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: getStatusColor(hoveredLog.status, hoveredLog.active) }}
/>
<span className="text-[11px] font-semibold text-text-main">
{hoveredLog.model || t("unknownModel")}
</span>
{hoveredLog.active && (
<span className="text-[9px] px-1.5 py-0.5 rounded-full bg-indigo-500/20 text-indigo-400 font-medium">
{t("active")}
</span>
)}
</div>
<div className="space-y-0.5 text-[10px] text-text-muted font-mono">
<div className="flex justify-between gap-4">
<span>{t("started")}</span>
<span>
{(() => {
const ts = new Date(hoveredLog.timestamp).getTime();
const startMs =
hoveredLog.active || hoveredLog.completed
? ts
: ts - (hoveredLog.duration || 0);
return new Date(startMs).toLocaleTimeString();
})()}
</span>
</div>
{!hoveredLog.active && (
<div className="flex justify-between gap-4">
<span>{t("ended")}</span>
<span>
{new Date(
hoveredLog.completed
? new Date(hoveredLog.timestamp).getTime() + (hoveredLog.duration || 0)
: new Date(hoveredLog.timestamp).getTime()
).toLocaleTimeString()}
</span>
</div>
)}
<div className="flex justify-between gap-4">
<span>{t("duration")}</span>
<span>
{hoveredLog.active
? `~${Math.round((nowMs - computeBarRange(hoveredLog, nowMs).startMs) / 1000).toLocaleString()}s`
: `${hoveredLog.duration.toLocaleString()}ms`}
</span>
</div>
<div className="flex justify-between gap-4">
<span>{t("status")}</span>
<span>{hoveredLog.status || t("pending")}</span>
</div>
{hoveredLog.provider && (
<div className="flex justify-between gap-4">
<span>{t("provider")}</span>
<span>{hoveredLog.provider}</span>
</div>
)}
<div className="flex justify-between gap-4">
<span>{t("tokens")}</span>
<span>
{hoveredLog.tokens.in.toLocaleString()} / {hoveredLog.tokens.out.toLocaleString()}
</span>
</div>
{hoveredLog.error && (
<div className="mt-1 text-red-400 text-[9px] break-all">{hoveredLog.error}</div>
)}
</div>
</div>
)}
{/* Legend */}
<div className="flex items-center gap-3 px-4 py-1.5 border-t border-border text-[9px] shrink-0">
<div className="flex items-center gap-1">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: "#059669" }} />
<span className="text-text-muted">2xx</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: "#D97706" }} />
<span className="text-text-muted">4xx</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: "#DC2626" }} />
<span className="text-text-muted">5xx</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: "#6366F1" }} />
<span className="text-text-muted">{t("active")}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: "#6B7280" }} />
<span className="text-text-muted">{t("other")}</span>
</div>
<div className="ml-4 flex items-center gap-1.5 text-text-muted">
<div className="w-3 border-t border-dashed border-slate-400/40" />
<span>{t("tenMinutes")}</span>
<div className="w-3 border-t border-slate-400/60 ml-2" />
<span>{t("hour")}</span>
<div className="w-3 border-t-2 border-accent/40 ml-2" />
<span>{t("day")}</span>
</div>
<div
className="ml-auto text-text-muted italic"
title={t(`modes.${MODE_META[mode].descriptionKey}`)}
>
{t(`modes.${MODE_META[mode].descriptionKey}`)}
</div>
</div>
{selectedLog && (
<RequestLoggerDetail
log={selectedLog as any}
detail={detailData}
loading={detailLoading}
debugEnabled={false}
emailsVisible={false}
onClose={closeDetail}
onCopy={copyToClipboard}
onPrevious={undefined}
onNext={undefined}
relatedLogs={[]}
onSelectRelated={undefined}
/>
)}
</div>
);
}