"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 (
{/* Header Bar */}
{/* Recording Toggle */} {/* Search */}
search setSearch(e.target.value)} className="w-full pl-10 pr-4 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:border-primary" />
{/* Provider Dropdown */} {/* Model Dropdown */} {/* Account Dropdown */} {/* API Key Dropdown */} {/* Stats */}
{totalCount} {t("total")} {okCount} {t("ok")} {errorCount > 0 && ( {errorCount} {t("err")} )} {comboCount > 0 && ( {comboCount} {t("combo")} )} {apiKeyCount > 0 && ( {apiKeyCount} {t("keys")} )} {sortedLogs.length} {t("shown")}
{/* Sort Dropdown */} {/* Refresh */}
{/* Quick Filters */}
{/* Status Filters */} {statusFilters.map((f) => ( ))} {/* Divider */} {uniqueProviders.length > 0 && } {/* Dynamic Provider Quick Filters (from data) */} {uniqueProviders.map((p) => { const compatLabel = getProviderDisplayLabel(p, providerNodes); const pc = PROVIDER_COLORS[p] || { bg: "#374151", text: "#fff", label: compatLabel || p.toUpperCase(), }; const displayLabel = compatLabel || pc.label; const isActive = selectedProvider === p; return ( ); })}
{/* Column Visibility Toggles */}
{t("columnsLabel")} {columns.map((col) => ( ))}
{/* Table */}
{loading && logs.length === 0 ? (
{t("loadingLogs")}
) : logs.length === 0 ? (
receipt_long {t("noLogs")}
) : sortedLogs.length === 0 ? (
{t("noMatchingLogs")}
) : ( {visibleColumns.status && ( )} {visibleColumns.cacheSource && ( )} {visibleColumns.model && ( )} {visibleColumns.requestedModel && ( )} {visibleColumns.provider && ( )} {visibleColumns.protocol && ( )} {visibleColumns.account && ( )} {visibleColumns.apiKey && ( )} {visibleColumns.combo && ( )} {visibleColumns.tokens && ( )} {visibleColumns.tps && ( )} {visibleColumns.duration && ( )} {visibleColumns.time && ( )} {sortedLogs.map((log) => { const statusStyle = getStatusStyle(log.status); const protocolKey = log.sourceFormat || log.provider; const protocol = getProtocolColor(protocolKey, log.provider); const compatLabel = getProviderDisplayLabel(log.provider, providerNodes); const providerColor = PROVIDER_COLORS[log.provider] || { bg: "#374151", text: "#fff", label: compatLabel || (log.provider || "-").toUpperCase(), }; const providerLabel = compatLabel || providerColor.label; const isError = log.status >= 400; const cacheSourceMeta = getCacheSourceMeta(log.cacheSource); const isSemanticCache = cacheSourceMeta.key === "semantic"; const accountLabel = maskAccount(log.account, emailsVisible); return ( openDetail(log)} className={`cursor-pointer hover:bg-primary/5 transition-colors ${isError ? "bg-red-500/5" : ""}`} > {visibleColumns.status && ( )} {visibleColumns.cacheSource && ( )} {visibleColumns.model && ( )} {visibleColumns.requestedModel && ( )} {visibleColumns.provider && ( )} {visibleColumns.protocol && ( )} {visibleColumns.account && ( )} {visibleColumns.apiKey && ( )} {visibleColumns.combo && ( )} {visibleColumns.tokens && ( )} {visibleColumns.tps && ( )} {visibleColumns.duration && ( )} {visibleColumns.time && ( )} ); })}
{t("columns.status")} {t("columns.cacheSource")} {t("columns.model")} {t("columns.requested")} {t("columns.provider")} {t("columns.protocol")} {t("columns.account")} {t("columns.apiKey")} {t("columns.combo")} {t("columns.tokens")} {t("columns.tps")} {t("columns.duration")} {t("columns.time")}
{log.status || "..."} {isSemanticCache ? t("semantic") : t("upstream")} {log.model} {log.requestedModel ? ( {log.requestedModel} ) : ( )} {providerLabel} {protocol.label} {accountLabel} {formatApiKeyLabel(log.apiKeyName, log.apiKeyId)} {log.comboName ? ( {log.comboName} ) : ( )} TI:{" "} {log.tokens?.in?.toLocaleString() || 0} | TO:{" "} {log.tokens?.out?.toLocaleString() || 0} {log.tokens?.compressed != null && log.tokens.compressed > 0 && ( <> | ↓{log.tokens.compressed.toLocaleString()} )} {(() => { 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)} ); })()} {formatDuration(log.duration)} {formatTime(log.timestamp)}
)} {hasMore && sortedLogs.length > 0 && (
)}
{t("callLogsInfo", { dataDir: "{DATA_DIR}/call_logs/", retentionDays: "CALL_LOG_RETENTION_DAYS", maxEntries: "CALL_LOG_MAX_ENTRIES", })}
{/* Detail Modal */} {selectedLog && ( )}
); }