"use client"; import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { useTranslations } from "next-intl"; import Card from "./Card"; import RequestLoggerDetail from "./RequestLoggerDetail"; import { copyToClipboard } from "@/shared/utils/clipboard"; import { PROVIDER_COLORS, getHttpStatusStyle as getStatusStyle, getProtocolColor, } from "@/shared/constants/colors"; import { formatTime, formatDuration, maskSegment, maskAccount, stableAccountSuffix, formatApiKeyLabel, } from "@/shared/utils/formatting"; import { getProviderDisplayLabel } from "@/shared/utils/providerDisplayLabel"; import useEmailPrivacyStore from "@/store/emailPrivacyStore"; // Number of call-log rows fetched per page. The viewer grows its window by this // amount on "Load more" / infinite scroll so users can browse past the first // page (previously hardcoded to a single 300-row window). See #2565. const PAGE_SIZE = 300; function getLogTotalTokens(log) { return (log?.tokens?.in || 0) + (log?.tokens?.out || 0); } function getLogTps(log): number { const tokensOut = log?.tokens?.out || 0; const durationMs = log?.duration || 0; if (tokensOut <= 0 || durationMs <= 0) return 0; return tokensOut / (durationMs / 1000); } function formatTps(tps: number): string { if (tps <= 0) return "—"; if (tps >= 100) return Math.round(tps).toLocaleString(); return tps.toFixed(1); } function getCacheSourceMeta(cacheSource: unknown) { if (cacheSource === "semantic") { return { key: "semantic", className: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border border-emerald-500/30", }; } return { key: "upstream", className: "bg-sky-500/15 text-sky-700 dark:text-sky-300 border border-sky-500/30", }; } export default function RequestLoggerV2() { const t = useTranslations("requestLogger"); const { emailsVisible } = useEmailPrivacyStore(); // Get translated status filters const statusFilters = useMemo( () => [ { key: "all", label: t("statusFilters.all"), icon: "" }, { key: "error", label: t("statusFilters.error"), icon: "error" }, { key: "ok", label: t("statusFilters.success"), icon: "check_circle" }, { key: "combo", label: t("statusFilters.combo"), icon: "hub" }, ], [t] ); // Get translated columns const columns = useMemo( () => [ { key: "status", label: t("columns.status") }, { key: "cacheSource", label: t("columns.cacheSource") }, { key: "model", label: t("columns.model") }, { key: "requestedModel", label: t("columns.requested") }, { key: "provider", label: t("columns.provider") }, { key: "protocol", label: t("columns.protocol") }, { key: "account", label: t("columns.account") }, { key: "apiKey", label: t("columns.apiKey") }, { key: "combo", label: t("columns.combo") }, { key: "tokens", label: t("columns.tokens") }, { key: "tps", label: t("columns.tps") }, { key: "duration", label: t("columns.duration") }, { key: "time", label: t("columns.time") }, ], [t] ); const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); const [recording, setRecording] = useState(true); const [search, setSearch] = useState(""); const [activeFilter, setActiveFilter] = useState("all"); const [selectedModel, setSelectedModel] = useState(""); const [selectedAccount, setSelectedAccount] = useState(""); const [selectedProvider, setSelectedProvider] = useState(""); const [selectedApiKey, setSelectedApiKey] = useState(""); const [sortBy, setSortBy] = useState("newest"); const [selectedLog, setSelectedLog] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [detailData, setDetailData] = useState(null); const [detailLoggingEnabled, setDetailLoggingEnabled] = useState(false); const [detailLoggingLoading, setDetailLoggingLoading] = useState(false); const [limit, setLimit] = useState(PAGE_SIZE); const [hasMore, setHasMore] = useState(false); const intervalRef = useRef(null); const hasLoadedRef = useRef(false); const logsSignatureRef = useRef(""); const scrollContainerRef = useRef(null); const loadMoreSentinelRef = useRef(null); const [providerNodes, setProviderNodes] = useState([]); // Column visibility with localStorage persistence const [visibleColumns, setVisibleColumns] = useState(() => { const defaultVisible = Object.fromEntries(columns.map((c) => [c.key, true])); if (typeof window === "undefined") return defaultVisible; try { const saved = localStorage.getItem("loggerVisibleColumns"); return saved ? { ...defaultVisible, ...JSON.parse(saved) } : defaultVisible; } catch { return defaultVisible; } }); const toggleColumn = useCallback((key) => { setVisibleColumns((prev) => { const next = { ...prev, [key]: !prev[key] }; try { localStorage.setItem("loggerVisibleColumns", JSON.stringify(next)); } catch {} return next; }); }, []); const fetchLogs = useCallback( async (showLoading = false) => { if (showLoading) setLoading(true); try { const params = new URLSearchParams(); if (search) params.set("search", search); if (activeFilter === "error") params.set("status", "error"); if (activeFilter === "ok") params.set("status", "ok"); if (activeFilter === "combo") params.set("combo", "1"); if (selectedModel) params.set("model", selectedModel); if (selectedProvider) params.set("provider", selectedProvider); if (selectedAccount) params.set("account", selectedAccount); if (selectedApiKey) params.set("apiKey", selectedApiKey); params.set("limit", String(limit)); const res = await fetch(`/api/usage/call-logs?${params}`); if (res.ok) { const data = await res.json(); // If the server returned a full window, more rows may exist beyond it. setHasMore(Array.isArray(data) && data.length >= limit); // Skip re-render if data hasn't changed (#1369 GPU perf) const sig = JSON.stringify(data.map?.((l: any) => l.id) ?? []); if (sig !== logsSignatureRef.current) { logsSignatureRef.current = sig; setLogs(data); } } } catch (error) { console.error("Failed to fetch call logs:", error); } finally { if (showLoading) setLoading(false); } }, [search, activeFilter, selectedModel, selectedAccount, selectedProvider, selectedApiKey, limit] ); useEffect(() => { const showLoading = !hasLoadedRef.current; hasLoadedRef.current = true; fetchLogs(showLoading); }, [fetchLogs]); // Fetch provider nodes for display labels useEffect(() => { fetch("/api/provider-nodes") .then((r) => (r.ok ? r.json() : { nodes: [] })) .then((d) => setProviderNodes(d.nodes || [])) .catch(() => {}); }, []); useEffect(() => { fetch("/api/logs/detail?limit=1") .then(async (res) => { if (!res.ok) return null; return await res.json(); }) .then((data) => { if (!data) return; setDetailLoggingEnabled(data.enabled === true); }) .catch(() => {}); }, []); // Auto-refresh useEffect(() => { if (intervalRef.current) clearInterval(intervalRef.current); if (recording) { intervalRef.current = setInterval(() => fetchLogs(false), 3000); } return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; }, [recording, fetchLogs]); // Reset the window back to the first page whenever the active filters change, // so switching filters doesn't keep fetching a large expanded window. useEffect(() => { setLimit(PAGE_SIZE); }, [search, activeFilter, selectedModel, selectedAccount, selectedProvider, selectedApiKey]); const loadMore = useCallback(() => { setLimit((prev) => prev + PAGE_SIZE); }, []); // Infinite scroll: grow the window when the sentinel near the bottom of the // scroll container becomes visible. useEffect(() => { const sentinel = loadMoreSentinelRef.current; const root = scrollContainerRef.current; if (!sentinel || !hasMore) return; const observer = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting && !loading) loadMore(); }, { root, rootMargin: "200px" } ); observer.observe(sentinel); return () => observer.disconnect(); }, [hasMore, loading, loadMore]); const filteredLogs = useMemo(() => { if (activeFilter === "combo") return logs.filter((l) => l.comboName); return logs; }, [activeFilter, logs]); const sortedLogs = useMemo(() => { const arr = [...filteredLogs]; arr.sort((a, b) => { switch (sortBy) { case "oldest": return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(); case "tokens_desc": return getLogTotalTokens(b) - getLogTotalTokens(a); case "tokens_asc": return getLogTotalTokens(a) - getLogTotalTokens(b); case "duration_desc": return (b.duration || 0) - (a.duration || 0); case "duration_asc": return (a.duration || 0) - (b.duration || 0); case "tps_desc": return getLogTps(b) - getLogTps(a); case "tps_asc": return getLogTps(a) - getLogTps(b); case "status_desc": return (b.status || 0) - (a.status || 0); case "status_asc": return (a.status || 0) - (b.status || 0); case "model_asc": return (a.model || "").localeCompare(b.model || ""); case "model_desc": return (b.model || "").localeCompare(a.model || ""); case "newest": default: return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(); } }); return arr; }, [filteredLogs, sortBy]); // Fetch log detail const openDetail = async (logEntry) => { setSelectedLog(logEntry); setDetailLoading(true); setDetailData(null); try { const res = await fetch(`/api/usage/call-logs/${logEntry.id}`); if (res.ok) { const data = await res.json(); setDetailData(data); } } catch (error) { console.error("Failed to fetch log detail:", error); } finally { setDetailLoading(false); } }; const closeDetail = () => { setSelectedLog(null); setDetailData(null); }; const toggleDetailLogging = async () => { setDetailLoggingLoading(true); try { const nextEnabled = !detailLoggingEnabled; const res = await fetch("/api/logs/detail", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled: nextEnabled }), }); if (!res.ok) throw new Error(t("updatePipelineFailed")); setDetailLoggingEnabled(nextEnabled); } catch (error) { console.error("Failed to toggle pipeline logging:", error); } finally { setDetailLoggingLoading(false); } }; // Unique accounts and providers for dropdowns const uniqueAccounts = [...new Set(logs.map((l) => l.account).filter((a) => a && a !== "-"))]; const uniqueModels = [ ...new Set(logs.flatMap((l) => [l.model, l.requestedModel]).filter((value) => Boolean(value))), ].sort(); const uniqueProviders = [ ...new Set(logs.map((l) => l.provider).filter((p) => p && p !== "-")), ].sort(); const uniqueApiKeys = [ ...new Set(logs.map((l) => l.apiKeyId || l.apiKeyName).filter(Boolean)), ].sort(); // Stats const totalCount = filteredLogs.length; const okCount = filteredLogs.filter((l) => l.status >= 200 && l.status < 300).length; const errorCount = filteredLogs.filter((l) => l.status >= 400).length; const comboCount = logs.filter((l) => l.comboName).length; const apiKeyCount = uniqueApiKeys.length; return (
| {t("columns.status")} | )} {visibleColumns.cacheSource && ({t("columns.cacheSource")} | )} {visibleColumns.model && ({t("columns.model")} | )} {visibleColumns.requestedModel && ({t("columns.requested")} | )} {visibleColumns.provider && ({t("columns.provider")} | )} {visibleColumns.protocol && ({t("columns.protocol")} | )} {visibleColumns.account && ({t("columns.account")} | )} {visibleColumns.apiKey && ({t("columns.apiKey")} | )} {visibleColumns.combo && ({t("columns.combo")} | )} {visibleColumns.tokens && ({t("columns.tokens")} | )} {visibleColumns.tps && ({t("columns.tps")} | )} {visibleColumns.duration && ({t("columns.duration")} | )} {visibleColumns.time && ({t("columns.time")} | )}
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {log.status || "..."} | )} {visibleColumns.cacheSource && ({isSemanticCache ? t("semantic") : t("upstream")} | )} {visibleColumns.model && ({log.model} | )} {visibleColumns.requestedModel && ({log.requestedModel ? ( {log.requestedModel} ) : ( — )} | )} {visibleColumns.provider && ({providerLabel} | )} {visibleColumns.protocol && ({protocol.label} | )} {visibleColumns.account && ({accountLabel} | )} {visibleColumns.apiKey && ({formatApiKeyLabel(log.apiKeyName, log.apiKeyId)} | )} {visibleColumns.combo && ({log.comboName ? ( {log.comboName} ) : ( — )} | )} {visibleColumns.tokens && (TI:{" "} {log.tokens?.in?.toLocaleString() || 0} | TO:{" "} {log.tokens?.out?.toLocaleString() || 0} {log.tokens?.compressed != null && log.tokens.compressed > 0 && ( <> | ↓{log.tokens.compressed.toLocaleString()} > )} | )} {visibleColumns.tps && ({(() => { const tps = getLogTps(log); const color = tps <= 0 ? "text-text-muted" : tps >= 80 ? "text-emerald-600 dark:text-emerald-400" : tps >= 30 ? "text-sky-600 dark:text-sky-400" : "text-amber-600 dark:text-amber-400"; return ( {formatTps(tps)} ); })()} | )} {visibleColumns.duration && ({formatDuration(log.duration)} | )} {visibleColumns.time && ({formatTime(log.timestamp)} | )}