mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 23:02:10 +03:00
* fix(logs): prevent stale detail refresh reopening modal * chore(stryker): register ollama-quota covering tests (release drift from merge burst) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
1629 lines
68 KiB
TypeScript
1629 lines
68 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
useState,
|
||
useEffect,
|
||
useCallback,
|
||
useMemo,
|
||
useRef,
|
||
forwardRef,
|
||
useImperativeHandle,
|
||
} from "react";
|
||
import { useRouter } from "next/navigation";
|
||
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,
|
||
maskAccount,
|
||
stableAccountSuffix,
|
||
formatApiKeyLabel,
|
||
} from "@/shared/utils/formatting";
|
||
import { getProviderDisplayLabel } from "@/shared/utils/providerDisplayLabel";
|
||
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
|
||
import {
|
||
computeLogsSignature,
|
||
shouldAutoRefresh,
|
||
shouldTriggerInfiniteScroll,
|
||
} from "./requestLoggerSignature";
|
||
import {
|
||
DEFAULT_REFRESH_INTERVAL_SEC,
|
||
clampRefreshIntervalSec,
|
||
readSavedRefreshIntervalSec,
|
||
writeSavedRefreshIntervalSec,
|
||
} from "./requestLoggerPreferences";
|
||
import {
|
||
LOG_TABLE_CLASS,
|
||
LOG_TABLE_HEAD_CLASS,
|
||
LOG_TABLE_HEADER_BG_STYLE,
|
||
LOG_TABLE_HEADER_CELL_CLASS,
|
||
LOG_TABLE_HEADER_CELL_RIGHT_CLASS,
|
||
LOG_TABLE_ROW_CLASS,
|
||
} from "./logTableStyles";
|
||
|
||
// 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.
|
||
// Reduced from 300 → 50 to avoid browser freeze and network saturation.
|
||
const PAGE_SIZE = 50;
|
||
|
||
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 interface RequestLoggerV2Handle {
|
||
openDetail: (logEntry: any) => void;
|
||
getSortedLogs: () => any[];
|
||
}
|
||
|
||
const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?: string }>(
|
||
(props, ref) => {
|
||
const { initialSelectedId } = props as any;
|
||
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 [correlationIdFilter, setCorrelationIdFilter] = useState("");
|
||
const [hoveredCid, setHoveredCid] = useState<string | null>(null);
|
||
const [groupedView, setGroupedView] = useState(false);
|
||
const [detailLoading, setDetailLoading] = useState(false);
|
||
|
||
// Column sort toggle: clicking a column header toggles asc/desc
|
||
const columnSortMap = {
|
||
status: { desc: "status_desc", asc: "status_asc" },
|
||
model: { desc: "model_desc", asc: "model_asc" },
|
||
tokens: { desc: "tokens_desc", asc: "tokens_asc" },
|
||
tps: { desc: "tps_desc", asc: "tps_asc" },
|
||
duration: { desc: "duration_desc", asc: "duration_asc" },
|
||
time: { desc: "newest", asc: "oldest" },
|
||
};
|
||
const toggleSort = useCallback((column: string) => {
|
||
const mapping = columnSortMap[column as keyof typeof columnSortMap];
|
||
if (!mapping) return;
|
||
setSortBy((prev) => {
|
||
if (prev === mapping.desc) return mapping.asc;
|
||
return mapping.desc;
|
||
});
|
||
}, []);
|
||
const getSortIndicator = useCallback(
|
||
(column: string) => {
|
||
const mapping = columnSortMap[column as keyof typeof columnSortMap];
|
||
if (!mapping) return "";
|
||
if (sortBy === mapping.desc) return " ↓";
|
||
if (sortBy === mapping.asc) return " ↑";
|
||
return "";
|
||
},
|
||
[sortBy]
|
||
);
|
||
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 [refreshIntervalSec, setRefreshIntervalSec] = useState(DEFAULT_REFRESH_INTERVAL_SEC);
|
||
const intervalRef = useRef(null);
|
||
const refreshIntervalSecRef = useRef(DEFAULT_REFRESH_INTERVAL_SEC);
|
||
const detailRequestRef = useRef("");
|
||
const hasLoadedRef = useRef(false);
|
||
const logsSignatureRef = useRef("");
|
||
const scrollContainerRef = useRef(null);
|
||
const loadMoreSentinelRef = useRef(null);
|
||
const hasScrolledRef = useRef(false);
|
||
const [providerNodes, setProviderNodes] = useState([]);
|
||
const visibleRef = useRef(true);
|
||
|
||
const [visibleColumns, setVisibleColumns] = useState(() => {
|
||
const defaultVisible = Object.fromEntries(columns.map((c) => [c.key, true]));
|
||
if (globalThis.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;
|
||
});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const saved = readSavedRefreshIntervalSec();
|
||
refreshIntervalSecRef.current = saved;
|
||
setRefreshIntervalSec(saved);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
refreshIntervalSecRef.current = refreshIntervalSec;
|
||
}, [refreshIntervalSec]);
|
||
|
||
const updateRefreshIntervalSec = useCallback(
|
||
(valueOrUpdater: number | ((current: number) => number)) => {
|
||
const current = refreshIntervalSecRef.current;
|
||
const rawValue =
|
||
typeof valueOrUpdater === "function" ? valueOrUpdater(current) : valueOrUpdater;
|
||
const next = clampRefreshIntervalSec(rawValue);
|
||
refreshIntervalSecRef.current = next;
|
||
writeSavedRefreshIntervalSec(next);
|
||
setRefreshIntervalSec(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);
|
||
if (correlationIdFilter) params.set("correlationId", correlationIdFilter);
|
||
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). The signature
|
||
// captures id + status + duration + tokens_out so in-progress updates
|
||
// still re-render while identical snapshots are skipped.
|
||
const sig = computeLogsSignature(data);
|
||
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,
|
||
correlationIdFilter,
|
||
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(() => {});
|
||
}, []);
|
||
|
||
// Visibility-aware auto-refresh: pause polling when tab is hidden or user has
|
||
// scrolled past the first page, or when a detail modal is open.
|
||
useEffect(() => {
|
||
const onVisibility = () => {
|
||
const isVisible = document.visibilityState === "visible";
|
||
visibleRef.current = isVisible;
|
||
if (isVisible && !selectedLog && shouldAutoRefresh(recording, limit, PAGE_SIZE)) {
|
||
fetchLogs(false);
|
||
}
|
||
};
|
||
// #4133: re-arm on window focus. Embedded / proxied hosts (Docker dashboard
|
||
// wrappers, webviews) can fire a one-shot `visibilitychange` → hidden and
|
||
// then keep reporting "hidden" — or recover without firing the event again —
|
||
// which left `visibleRef` stuck `false` and froze auto-refresh permanently
|
||
// (the "still not refreshing on 3.8.28, works on 3.8.24" report). A window
|
||
// `focus` is a reliable signal the page is actively viewed, so re-arm and
|
||
// poll. A genuinely backgrounded tab never receives focus, so this does not
|
||
// defeat the perf pause.
|
||
const onFocus = () => {
|
||
visibleRef.current = true;
|
||
if (!selectedLog && shouldAutoRefresh(recording, limit, PAGE_SIZE)) {
|
||
fetchLogs(false);
|
||
}
|
||
};
|
||
document.addEventListener("visibilitychange", onVisibility);
|
||
window.addEventListener("focus", onFocus);
|
||
return () => {
|
||
document.removeEventListener("visibilitychange", onVisibility);
|
||
window.removeEventListener("focus", onFocus);
|
||
};
|
||
}, [recording, limit, fetchLogs, selectedLog]);
|
||
|
||
useEffect(() => {
|
||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||
if (!selectedLog && shouldAutoRefresh(recording, limit, PAGE_SIZE)) {
|
||
intervalRef.current = setInterval(() => {
|
||
// #3972/#4054/#4133: poll while the page is plausibly viewed — the
|
||
// event-tracked `visibleRef` (fail-open) OR a *live* `visibilityState`
|
||
// read. A real background tab has both false → pause (perf); an
|
||
// embedded host that misreports "hidden" keeps polling instead of
|
||
// freezing. The window-`focus` re-arm above covers a host pinned
|
||
// "hidden" while focused.
|
||
if (visibleRef.current || document.visibilityState === "visible") {
|
||
fetchLogs(false);
|
||
}
|
||
}, refreshIntervalSec * 1000);
|
||
}
|
||
return () => {
|
||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||
};
|
||
}, [recording, fetchLogs, limit, refreshIntervalSec, selectedLog]);
|
||
|
||
// 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);
|
||
// #4269: a filter change is a fresh first-page view — re-arm the ghost-load-more
|
||
// guard so auto-refresh resumes until the user scrolls again.
|
||
hasScrolledRef.current = false;
|
||
}, [
|
||
search,
|
||
activeFilter,
|
||
selectedModel,
|
||
selectedAccount,
|
||
selectedProvider,
|
||
selectedApiKey,
|
||
correlationIdFilter,
|
||
]);
|
||
|
||
const loadMore = useCallback(() => {
|
||
setLimit((prev) => prev + PAGE_SIZE);
|
||
}, []);
|
||
|
||
// #4269: record the first genuine user scroll of the log list. Until then, the
|
||
// infinite-scroll observer below must NOT grow the window (see
|
||
// shouldTriggerInfiniteScroll) — otherwise a sentinel that is already visible on
|
||
// mount fires a "ghost" loadMore and permanently pauses auto-refresh.
|
||
useEffect(() => {
|
||
const root = scrollContainerRef.current;
|
||
if (!root) return;
|
||
const onScroll = () => {
|
||
if (root.scrollTop > 0) hasScrolledRef.current = true;
|
||
};
|
||
root.addEventListener("scroll", onScroll, { passive: true });
|
||
return () => root.removeEventListener("scroll", onScroll);
|
||
}, []);
|
||
|
||
// Infinite scroll: grow the window when the sentinel near the bottom of the
|
||
// scroll container becomes visible — but only after a real user scroll (#4269).
|
||
useEffect(() => {
|
||
const sentinel = loadMoreSentinelRef.current;
|
||
const root = scrollContainerRef.current;
|
||
if (!sentinel || !hasMore) return;
|
||
const observer = new IntersectionObserver(
|
||
(entries) => {
|
||
if (
|
||
shouldTriggerInfiniteScroll({
|
||
isIntersecting: !!entries[0]?.isIntersecting,
|
||
hasMore,
|
||
loading,
|
||
hasScrolled: hasScrolledRef.current,
|
||
})
|
||
) {
|
||
loadMore();
|
||
}
|
||
},
|
||
{ root, rootMargin: "200px" }
|
||
);
|
||
observer.observe(sentinel);
|
||
return () => observer.disconnect();
|
||
}, [hasMore, loading, loadMore]);
|
||
|
||
const filteredLogs = useMemo(() => {
|
||
let arr = logs;
|
||
|
||
// Status filter
|
||
if (activeFilter === "error") {
|
||
arr = arr.filter((l) => l.status >= 400);
|
||
} else if (activeFilter === "ok") {
|
||
arr = arr.filter((l) => l.status >= 200 && l.status < 300);
|
||
} else if (activeFilter === "combo") {
|
||
arr = arr.filter((l) => l.comboName);
|
||
}
|
||
|
||
return arr;
|
||
}, [logs, activeFilter]);
|
||
|
||
// Grouped view: deduplicate by correlationId, keeping only the latest per group.
|
||
// Rows without a correlationId are always shown.
|
||
const dedupedLogs = useMemo(() => {
|
||
if (!groupedView) return filteredLogs;
|
||
const byCid = new Map<string, (typeof filteredLogs)[0]>();
|
||
const noCid: typeof filteredLogs = [];
|
||
for (const log of filteredLogs) {
|
||
const cid = log.correlationId;
|
||
if (!cid) {
|
||
noCid.push(log);
|
||
continue;
|
||
}
|
||
const existing = byCid.get(cid);
|
||
if (
|
||
!existing ||
|
||
new Date(log.timestamp).getTime() > new Date(existing.timestamp).getTime()
|
||
) {
|
||
byCid.set(cid, log);
|
||
}
|
||
}
|
||
return [...noCid, ...byCid.values()];
|
||
}, [filteredLogs, groupedView]);
|
||
|
||
const sortedLogs = useMemo(() => {
|
||
const arr = [...dedupedLogs];
|
||
|
||
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;
|
||
}, [dedupedLogs, sortBy]);
|
||
|
||
// Group by correlationId: mark retries as children so they render indented
|
||
// under the first request in each group. Compute group health:
|
||
// "healed" — at least one failure followed by a success
|
||
// "failed" — all attempts failed
|
||
// null — single request or all succeeded
|
||
const groupedLogs = useMemo(() => {
|
||
const cidGroups = new Map();
|
||
for (const log of sortedLogs) {
|
||
const cid = log.correlationId;
|
||
if (cid) {
|
||
if (!cidGroups.has(cid)) cidGroups.set(cid, []);
|
||
cidGroups.get(cid).push(log);
|
||
}
|
||
}
|
||
return sortedLogs.map((log) => {
|
||
const cid = log.correlationId;
|
||
if (!cid) return { ...log, isRetry: false, groupSize: 1, groupStatus: null };
|
||
const group = cidGroups.get(cid);
|
||
if (!group || group.length <= 1)
|
||
return { ...log, isRetry: false, groupSize: 1, groupStatus: null };
|
||
const isFirst = group[0].id === log.id;
|
||
const hasFailure = group.some((g) => g.status >= 400 || g.active);
|
||
const hasSuccess = group.some((g) => g.status >= 200 && g.status < 300);
|
||
const groupStatus = hasFailure && hasSuccess ? "healed" : hasFailure ? "failed" : null;
|
||
return { ...log, isRetry: !isFirst, groupSize: group.length, groupStatus };
|
||
});
|
||
}, [sortedLogs]);
|
||
|
||
// Fetch log detail from the persisted call-log endpoint. If a deep-linked
|
||
// request is still being finalized, keep the modal open and poll this same
|
||
// endpoint until the row appears.
|
||
const router = useRouter();
|
||
|
||
const openDetail = async (logEntry) => {
|
||
// Guard: if no valid id provided, close instead of opening an empty modal
|
||
if (!logEntry?.id) {
|
||
try {
|
||
closeDetail();
|
||
} catch {}
|
||
return;
|
||
}
|
||
|
||
const requestToken = `${logEntry.id}:${Date.now()}:${Math.random()}`;
|
||
detailRequestRef.current = requestToken;
|
||
const isCurrentDetailRequest = () => detailRequestRef.current === requestToken;
|
||
|
||
setSelectedLog(logEntry);
|
||
try {
|
||
const url = new URL(globalThis.location.href);
|
||
url.searchParams.set("id", logEntry.id);
|
||
router.replace(url.pathname + url.search);
|
||
} catch (e) {
|
||
// ignore navigation errors
|
||
}
|
||
setDetailLoading(true);
|
||
setDetailData(null);
|
||
try {
|
||
const res = await fetch(`/api/logs/${logEntry.id}`, { cache: "no-store" });
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (!isCurrentDetailRequest()) return;
|
||
const dataHasPipeline =
|
||
data?.pipelinePayloads && Object.keys(data.pipelinePayloads || {}).length > 0;
|
||
setDetailData((prev: { pipelinePayloads: any }) => ({
|
||
...prev,
|
||
...data,
|
||
pipelinePayloads: dataHasPipeline ? data.pipelinePayloads : prev?.pipelinePayloads,
|
||
}));
|
||
// ensure the modal summary reflects the fetched call log summary
|
||
if (data && typeof data === "object") {
|
||
setSelectedLog((prev: any) => ({
|
||
...prev,
|
||
...data,
|
||
active: data.active === true,
|
||
}));
|
||
}
|
||
} else {
|
||
// A deep-linked id can legitimately 404 while the request is still
|
||
// finalizing. Keep the modal open and poll /api/logs/[id] instead of
|
||
// falling back to an in-memory active-request endpoint.
|
||
if (!isCurrentDetailRequest()) return;
|
||
if (res.status === 404) {
|
||
if (logEntry.pendingLookup || logEntry.active) {
|
||
setSelectedLog((prev: { method: any; path: any }) => ({
|
||
...prev,
|
||
id: logEntry.id,
|
||
status: 0,
|
||
method: prev?.method,
|
||
path: prev?.path || "",
|
||
}));
|
||
setDetailData({ detailState: "pending" });
|
||
return;
|
||
}
|
||
try {
|
||
console.warn("Log not found:", logEntry.id);
|
||
} catch {}
|
||
try {
|
||
closeDetail();
|
||
} catch {}
|
||
return;
|
||
}
|
||
// other errors: show a minimal error indicator by setting detailData to an error object
|
||
try {
|
||
const body = await res.text().catch(() => null);
|
||
if (!isCurrentDetailRequest()) return;
|
||
setDetailData({ error: `Failed to fetch log (status ${res.status})`, body });
|
||
} catch {}
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to fetch log detail:", error);
|
||
} finally {
|
||
if (isCurrentDetailRequest()) setDetailLoading(false);
|
||
}
|
||
};
|
||
|
||
const closeDetail = () => {
|
||
detailRequestRef.current = "";
|
||
setSelectedLog(null);
|
||
setDetailData(null);
|
||
try {
|
||
// remove id param when closing detail
|
||
const url = new URL(globalThis.location.href);
|
||
url.searchParams.delete("id");
|
||
router.replace(url.pathname + url.search);
|
||
} catch (e) {
|
||
// ignore navigation errors
|
||
}
|
||
};
|
||
|
||
const sortedLogsForNav = useMemo(() => sortedLogs, [sortedLogs]);
|
||
|
||
useImperativeHandle(
|
||
ref,
|
||
() => ({
|
||
openDetail,
|
||
getSortedLogs: () => sortedLogsForNav,
|
||
}),
|
||
[openDetail, sortedLogsForNav]
|
||
);
|
||
|
||
// If the page provided an initialSelectedId (via ?id=), open it on mount.
|
||
// Guard with a ref so subsequent URL updates (close -> replace) don't re-open the modal.
|
||
const initialOpenedRef = useRef(false);
|
||
useEffect(() => {
|
||
if (initialSelectedId && !initialOpenedRef.current) {
|
||
initialOpenedRef.current = true;
|
||
openDetail({ id: initialSelectedId, pendingLookup: true })
|
||
.then((r) => r)
|
||
.catch((error_) => {
|
||
console.error("Failed to open initial log id:", error_);
|
||
});
|
||
}
|
||
}, [initialSelectedId]);
|
||
|
||
useEffect(() => {
|
||
const isActive = selectedLog?.active === true;
|
||
if (!selectedLog?.id || !isActive) return;
|
||
let cancelled = false;
|
||
let graceCount = 0;
|
||
const interval = setInterval(async () => {
|
||
if (document.visibilityState !== "visible") return;
|
||
try {
|
||
const res = await fetch(`/api/logs/${selectedLog.id}`, { cache: "no-store" });
|
||
if (cancelled) return;
|
||
if (res.status === 404) {
|
||
if (isActive) return;
|
||
clearInterval(interval);
|
||
return;
|
||
}
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
const stillActive = data.active === true;
|
||
setDetailData((prev: { pipelinePayloads: any }) => ({
|
||
...prev,
|
||
...data,
|
||
pipelinePayloads: data?.pipelinePayloads || prev?.pipelinePayloads,
|
||
}));
|
||
setSelectedLog((prev: any) => ({
|
||
...prev,
|
||
...data,
|
||
active: stillActive,
|
||
}));
|
||
if (!stillActive) {
|
||
graceCount++;
|
||
if (graceCount >= 3) clearInterval(interval);
|
||
}
|
||
}
|
||
} catch {
|
||
// keep waiting; the detail endpoint is the single source of truth
|
||
}
|
||
}, 1000);
|
||
return () => {
|
||
cancelled = true;
|
||
clearInterval(interval);
|
||
};
|
||
}, [selectedLog?.id, detailData?.detailState, selectedLog?.active, fetchLogs]);
|
||
|
||
// Poll for related logs (same correlationId) while the detail modal is open.
|
||
// This ensures newly completed retries appear without closing the modal.
|
||
useEffect(() => {
|
||
const cid = selectedLog?.correlationId;
|
||
if (!selectedLog?.id || !cid) return;
|
||
let cancelled = false;
|
||
const interval = setInterval(async () => {
|
||
if (document.visibilityState !== "visible") return;
|
||
try {
|
||
const res = await fetch(`/api/usage/call-logs?correlationId=${encodeURIComponent(cid)}`, {
|
||
cache: "no-store",
|
||
});
|
||
if (cancelled || !res.ok) return;
|
||
const cidLogs = await res.json();
|
||
if (!Array.isArray(cidLogs) || cidLogs.length === 0) return;
|
||
setLogs((prev) => {
|
||
const ids = new Set(cidLogs.map((l: any) => l.id));
|
||
let changed = false;
|
||
const merged = prev.map((l: any) => {
|
||
const updated = cidLogs.find((c: any) => c.id === l.id);
|
||
if (updated) {
|
||
changed = true;
|
||
return { ...l, ...updated };
|
||
}
|
||
return l;
|
||
});
|
||
for (const cl of cidLogs) {
|
||
if (!merged.some((m: any) => m.id === cl.id)) {
|
||
merged.push(cl);
|
||
changed = true;
|
||
}
|
||
}
|
||
return changed ? merged : prev;
|
||
});
|
||
} catch {
|
||
/* poll failed — non-critical */
|
||
}
|
||
}, 3000);
|
||
return () => {
|
||
cancelled = true;
|
||
clearInterval(interval);
|
||
};
|
||
}, [selectedLog?.id, selectedLog?.correlationId]);
|
||
|
||
const currentLogIndex = useMemo(() => {
|
||
if (!selectedLog) return -1;
|
||
return sortedLogsForNav.findIndex((l) => l.id === selectedLog.id);
|
||
}, [selectedLog, sortedLogsForNav]);
|
||
|
||
const handlePrev = useCallback(() => {
|
||
const idx = currentLogIndex;
|
||
const target = idx > 0 ? sortedLogsForNav[idx - 1] : null;
|
||
if (target?.id) {
|
||
openDetail(target)
|
||
.then((r) => r)
|
||
.catch((error_) => {
|
||
console.error("Failed to open previous log id:", error_);
|
||
});
|
||
} else {
|
||
closeDetail();
|
||
}
|
||
}, [currentLogIndex, sortedLogsForNav]);
|
||
|
||
const handleNext = useCallback(() => {
|
||
const idx = currentLogIndex;
|
||
const target =
|
||
idx >= 0 && idx < sortedLogsForNav.length - 1 ? sortedLogsForNav[idx + 1] : null;
|
||
if (target?.id) {
|
||
openDetail(target)
|
||
.then((r) => r)
|
||
.catch((error_) => {
|
||
console.error("Failed to open previous log id:", error_);
|
||
});
|
||
} else {
|
||
closeDetail();
|
||
}
|
||
}, [currentLogIndex, sortedLogsForNav]);
|
||
|
||
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);
|
||
}
|
||
};
|
||
|
||
const sourceLogsForDropdowns = logs;
|
||
|
||
// Unique accounts and providers for dropdowns
|
||
|
||
const uniqueAccounts = useMemo(
|
||
() => [
|
||
...new Set(sourceLogsForDropdowns.map((l) => l.account).filter((a) => a && a !== "-")),
|
||
],
|
||
[sourceLogsForDropdowns]
|
||
);
|
||
const uniqueModels = useMemo(
|
||
() =>
|
||
[
|
||
...new Set(
|
||
sourceLogsForDropdowns.flatMap((l) => [l.model, l.requestedModel]).filter(Boolean)
|
||
),
|
||
].sort(),
|
||
[sourceLogsForDropdowns]
|
||
);
|
||
const uniqueProviders = useMemo(
|
||
() =>
|
||
[
|
||
...new Set(sourceLogsForDropdowns.map((l) => l.provider).filter((p) => p && p !== "-")),
|
||
].sort(),
|
||
[sourceLogsForDropdowns]
|
||
);
|
||
const uniqueApiKeys = useMemo(
|
||
() =>
|
||
[
|
||
...new Set(sourceLogsForDropdowns.map((l) => l.apiKeyId || l.apiKeyName).filter(Boolean)),
|
||
].sort(),
|
||
[sourceLogsForDropdowns]
|
||
);
|
||
|
||
// Stats (memoized to avoid re-computation on every render)
|
||
const { totalCount, okCount, errorCount, comboCount, apiKeyCount, runningCount } = useMemo(
|
||
() => ({
|
||
totalCount: filteredLogs.length,
|
||
okCount: filteredLogs.filter((l) => l.status >= 200 && l.status < 300).length,
|
||
errorCount: filteredLogs.filter((l) => l.status >= 400).length,
|
||
comboCount: logs.filter((l) => l.comboName).length,
|
||
apiKeyCount: uniqueApiKeys.length,
|
||
runningCount: filteredLogs.filter((l) => l.active === true).length,
|
||
}),
|
||
[filteredLogs, logs, uniqueApiKeys]
|
||
);
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
{/* Header Bar */}
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
{/* Recording Toggle */}
|
||
<button
|
||
onClick={() => setRecording(!recording)}
|
||
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border transition-colors ${
|
||
recording
|
||
? "bg-red-500/10 border-red-500/30 text-red-700 dark:text-red-400"
|
||
: "bg-bg-subtle border-border text-text-muted"
|
||
}`}
|
||
>
|
||
<span
|
||
className={`w-2 h-2 rounded-full ${recording ? "bg-red-500 animate-pulse" : "bg-text-muted"}`}
|
||
/>
|
||
{recording ? t("recording") : t("paused")}
|
||
</button>
|
||
|
||
<button
|
||
onClick={toggleDetailLogging}
|
||
disabled={detailLoggingLoading}
|
||
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border transition-colors disabled:opacity-60 ${
|
||
detailLoggingEnabled
|
||
? "bg-amber-500/10 border-amber-500/30 text-amber-700 dark:text-amber-300"
|
||
: "bg-bg-subtle border-border text-text-muted"
|
||
}`}
|
||
title={t("capturePipeline")}
|
||
>
|
||
<span
|
||
className={`w-2 h-2 rounded-full ${detailLoggingEnabled ? "bg-amber-500" : "bg-text-muted"}`}
|
||
/>
|
||
{detailLoggingLoading
|
||
? t("updatingPipelineLogs")
|
||
: detailLoggingEnabled
|
||
? t("pipelineLogsOn")
|
||
: t("pipelineLogsOff")}
|
||
</button>
|
||
|
||
{/* Search */}
|
||
<div className="flex-1 min-w-[200px] relative">
|
||
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-text-muted text-[18px]">
|
||
search
|
||
</span>
|
||
<input
|
||
type="text"
|
||
placeholder={t("searchPlaceholder")}
|
||
value={search}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
|
||
{/* Correlation ID Filter */}
|
||
<div className="min-w-[180px] relative">
|
||
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-text-muted text-[16px]">
|
||
tag
|
||
</span>
|
||
<input
|
||
type="text"
|
||
placeholder="Correlation ID"
|
||
value={correlationIdFilter}
|
||
onChange={(e) => setCorrelationIdFilter(e.target.value)}
|
||
className="w-full pl-9 pr-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary font-mono placeholder:text-text-muted focus:outline-none focus:border-primary"
|
||
/>
|
||
</div>
|
||
|
||
{/* Group by CID toggle */}
|
||
<button
|
||
onClick={() => setGroupedView((v) => !v)}
|
||
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||
groupedView
|
||
? "bg-violet-500/15 border-violet-500/30 text-violet-700 dark:text-violet-300"
|
||
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary"
|
||
}`}
|
||
title={groupedView ? "Show all rows" : "Show latest per correlation ID"}
|
||
>
|
||
<span className="material-symbols-outlined text-[16px]">
|
||
{groupedView ? "unfold_less" : "unfold_more"}
|
||
</span>
|
||
{groupedView ? "Grouped" : "All"}
|
||
</button>
|
||
|
||
{/* Provider Dropdown */}
|
||
<select
|
||
value={selectedProvider}
|
||
onChange={(e) => setSelectedProvider(e.target.value)}
|
||
className="px-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary focus:outline-none focus:border-primary appearance-none cursor-pointer min-w-[140px]"
|
||
>
|
||
<option value="">{t("allProviders")}</option>
|
||
{uniqueProviders.map((p) => {
|
||
const compatLabel = getProviderDisplayLabel(p, providerNodes);
|
||
const pc = PROVIDER_COLORS[p];
|
||
return (
|
||
<option key={p} value={p}>
|
||
{compatLabel || pc?.label || p.toUpperCase()}
|
||
</option>
|
||
);
|
||
})}
|
||
</select>
|
||
|
||
{/* Model Dropdown */}
|
||
<select
|
||
value={selectedModel}
|
||
onChange={(e) => setSelectedModel(e.target.value)}
|
||
className="px-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary focus:outline-none focus:border-primary appearance-none cursor-pointer min-w-[180px]"
|
||
>
|
||
<option value="">{t("allModels")}</option>
|
||
{uniqueModels.map((model) => (
|
||
<option key={model} value={model}>
|
||
{model}
|
||
</option>
|
||
))}
|
||
</select>
|
||
|
||
{/* Account Dropdown */}
|
||
<select
|
||
value={selectedAccount}
|
||
onChange={(e) => setSelectedAccount(e.target.value)}
|
||
className="px-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary focus:outline-none focus:border-primary appearance-none cursor-pointer min-w-[140px]"
|
||
>
|
||
<option value="">{t("allAccounts")}</option>
|
||
{uniqueAccounts.map((a) => (
|
||
<option key={a} value={a}>
|
||
{emailsVisible ? a : `${maskAccount(a, false)} · #${stableAccountSuffix(a)}`}
|
||
</option>
|
||
))}
|
||
</select>
|
||
|
||
{/* API Key Dropdown */}
|
||
<select
|
||
value={selectedApiKey}
|
||
onChange={(e) => setSelectedApiKey(e.target.value)}
|
||
className="px-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary focus:outline-none focus:border-primary appearance-none cursor-pointer min-w-[160px]"
|
||
>
|
||
<option value="">{t("allApiKeys")}</option>
|
||
{uniqueApiKeys.map((value) => {
|
||
const matched = logs.find((l) => (l.apiKeyId || l.apiKeyName) === value);
|
||
const label = formatApiKeyLabel(matched?.apiKeyName, matched?.apiKeyId);
|
||
return (
|
||
<option key={value} value={value}>
|
||
{label}
|
||
</option>
|
||
);
|
||
})}
|
||
</select>
|
||
|
||
{/* Stats */}
|
||
<div className="flex items-center gap-2 text-xs text-text-muted">
|
||
<span className="px-2 py-1 rounded bg-bg-subtle border border-border font-mono">
|
||
{totalCount} {t("total")}
|
||
</span>
|
||
{runningCount > 0 && (
|
||
<span className="px-2 py-1 rounded bg-amber-500/10 text-amber-700 dark:text-amber-400 font-mono">
|
||
{runningCount} running
|
||
</span>
|
||
)}
|
||
<span className="px-2 py-1 rounded bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 font-mono">
|
||
{okCount} {t("ok")}
|
||
</span>
|
||
{errorCount > 0 && (
|
||
<span className="px-2 py-1 rounded bg-red-500/10 text-red-700 dark:text-red-400 font-mono">
|
||
{errorCount} {t("err")}
|
||
</span>
|
||
)}
|
||
{comboCount > 0 && (
|
||
<span className="px-2 py-1 rounded bg-violet-500/10 text-violet-700 dark:text-violet-400 font-mono">
|
||
{comboCount} {t("combo")}
|
||
</span>
|
||
)}
|
||
{apiKeyCount > 0 && (
|
||
<span className="px-2 py-1 rounded bg-primary/10 text-primary font-mono">
|
||
{apiKeyCount} {t("keys")}
|
||
</span>
|
||
)}
|
||
<span className="px-2 py-1 rounded bg-bg-subtle border border-border font-mono">
|
||
{sortedLogs.length} {t("shown")}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Sort Dropdown */}
|
||
<select
|
||
value={sortBy}
|
||
onChange={(e) => setSortBy(e.target.value)}
|
||
className="px-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary focus:outline-none focus:border-primary appearance-none cursor-pointer min-w-[150px]"
|
||
title={t("sortLogs")}
|
||
>
|
||
<option value="newest">{t("sortNewest")}</option>
|
||
<option value="oldest">{t("sortOldest")}</option>
|
||
<option value="tokens_desc">{t("sortTokensDesc")}</option>
|
||
<option value="tokens_asc">{t("sortTokensAsc")}</option>
|
||
<option value="duration_desc">{t("sortDurationDesc")}</option>
|
||
<option value="duration_asc">{t("sortDurationAsc")}</option>
|
||
<option value="status_desc">{t("sortStatusDesc")}</option>
|
||
<option value="status_asc">{t("sortStatusAsc")}</option>
|
||
<option value="model_asc">{t("sortModelAsc")}</option>
|
||
<option value="model_desc">{t("sortModelDesc")}</option>
|
||
</select>
|
||
|
||
{/* Refresh interval */}
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={() => updateRefreshIntervalSec((v) => v - 1)}
|
||
className="w-6 h-6 flex items-center justify-center rounded hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors text-sm font-bold"
|
||
title="Decrease interval"
|
||
>
|
||
−
|
||
</button>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={300}
|
||
value={refreshIntervalSec}
|
||
onChange={(e) => {
|
||
const v = Number.parseInt(e.target.value, 10);
|
||
if (!Number.isNaN(v)) updateRefreshIntervalSec(v);
|
||
}}
|
||
className="w-12 text-center text-[11px] bg-transparent border border-border rounded px-1 py-0.5 text-text-primary [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||
title="Auto-refresh interval in seconds"
|
||
/>
|
||
<button
|
||
onClick={() => updateRefreshIntervalSec((v) => v + 1)}
|
||
className="w-6 h-6 flex items-center justify-center rounded hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors text-sm font-bold"
|
||
title="Increase interval"
|
||
>
|
||
+
|
||
</button>
|
||
<span className="text-[10px] text-text-muted">s</span>
|
||
</div>
|
||
|
||
{/* Refresh */}
|
||
<button
|
||
onClick={() => fetchLogs(false)}
|
||
className="p-2 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
|
||
title={t("refresh")}
|
||
>
|
||
<span className="material-symbols-outlined text-[18px]">refresh</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Quick Filters */}
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{/* Status Filters */}
|
||
{statusFilters.map((f) => (
|
||
<button
|
||
key={f.key}
|
||
onClick={() => setActiveFilter(activeFilter === f.key ? "all" : f.key)}
|
||
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border transition-all ${
|
||
activeFilter === f.key
|
||
? f.key === "error"
|
||
? "bg-red-500/20 text-red-700 dark:text-red-400 border-red-500/40"
|
||
: f.key === "ok"
|
||
? "bg-emerald-500/20 text-emerald-700 dark:text-emerald-400 border-emerald-500/40"
|
||
: f.key === "combo"
|
||
? "bg-violet-500/20 text-violet-700 dark:text-violet-300 border-violet-500/40"
|
||
: "bg-primary text-white border-primary"
|
||
: "bg-bg-subtle border-border text-text-muted hover:border-text-muted"
|
||
}`}
|
||
>
|
||
{f.icon && <span className="material-symbols-outlined text-[14px]">{f.icon}</span>}
|
||
{f.label}
|
||
</button>
|
||
))}
|
||
|
||
{/* Divider */}
|
||
{uniqueProviders.length > 0 && <span className="w-px h-5 bg-border mx-1" />}
|
||
|
||
{/* 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 (
|
||
<button
|
||
key={p}
|
||
onClick={() => setSelectedProvider(isActive ? "" : p)}
|
||
className={`px-3 py-1 rounded-full text-xs font-bold uppercase border transition-all ${
|
||
isActive
|
||
? "border-white/40 ring-1 ring-white/20"
|
||
: "border-transparent opacity-70 hover:opacity-100"
|
||
}`}
|
||
style={{
|
||
backgroundColor: isActive ? pc.bg : `${pc.bg}33`,
|
||
color: isActive ? pc.text : pc.bg,
|
||
}}
|
||
>
|
||
{displayLabel}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Column Visibility Toggles */}
|
||
<div className="flex flex-wrap items-center gap-1.5">
|
||
<span className="text-[10px] text-text-muted uppercase tracking-wider mr-1">
|
||
{t("columnsLabel")}
|
||
</span>
|
||
{columns.map((col) => (
|
||
<button
|
||
key={col.key}
|
||
onClick={() => toggleColumn(col.key)}
|
||
className={`px-2 py-0.5 rounded text-[10px] font-medium border transition-all ${
|
||
visibleColumns[col.key]
|
||
? "bg-primary/15 text-primary border-primary/30"
|
||
: "bg-bg-subtle text-text-muted border-border opacity-50 hover:opacity-80"
|
||
}`}
|
||
>
|
||
{col.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Table */}
|
||
<Card padding="none" className="min-h-[460px] resize-y overflow-auto bg-surface">
|
||
<div
|
||
ref={scrollContainerRef}
|
||
className="p-0 overflow-x-auto overflow-y-auto h-full min-h-[460px]"
|
||
>
|
||
{loading && logs.length === 0 ? (
|
||
<div className="p-8 text-center text-text-muted">{t("loadingLogs")}</div>
|
||
) : logs.length === 0 ? (
|
||
<div className="p-8 text-center text-text-muted">
|
||
<span className="material-symbols-outlined text-[48px] mb-2 block opacity-40">
|
||
receipt_long
|
||
</span>
|
||
{t("noLogs")}
|
||
</div>
|
||
) : sortedLogs.length === 0 ? (
|
||
<div className="p-8 text-center text-text-muted">{t("noMatchingLogs")}</div>
|
||
) : (
|
||
<table className={LOG_TABLE_CLASS}>
|
||
<thead className={LOG_TABLE_HEAD_CLASS} style={LOG_TABLE_HEADER_BG_STYLE}>
|
||
<tr className={LOG_TABLE_ROW_CLASS} style={LOG_TABLE_HEADER_BG_STYLE}>
|
||
{visibleColumns.status && (
|
||
<th
|
||
className={`${LOG_TABLE_HEADER_CELL_CLASS} cursor-pointer select-none`}
|
||
onClick={() => toggleSort("status")}
|
||
>
|
||
{t("columns.status")}
|
||
{getSortIndicator("status")}
|
||
</th>
|
||
)}
|
||
{visibleColumns.cacheSource && (
|
||
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("columns.cacheSource")}</th>
|
||
)}
|
||
{visibleColumns.model && (
|
||
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("columns.model")}</th>
|
||
)}
|
||
{visibleColumns.requestedModel && (
|
||
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("columns.requested")}</th>
|
||
)}
|
||
{visibleColumns.provider && (
|
||
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("columns.provider")}</th>
|
||
)}
|
||
{visibleColumns.protocol && (
|
||
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("columns.protocol")}</th>
|
||
)}
|
||
{visibleColumns.account && (
|
||
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("columns.account")}</th>
|
||
)}
|
||
{visibleColumns.apiKey && (
|
||
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("columns.apiKey")}</th>
|
||
)}
|
||
{visibleColumns.combo && (
|
||
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("columns.combo")}</th>
|
||
)}
|
||
{visibleColumns.tokens && (
|
||
<th
|
||
className={`${LOG_TABLE_HEADER_CELL_RIGHT_CLASS} cursor-pointer select-none`}
|
||
onClick={() => toggleSort("tokens")}
|
||
>
|
||
{t("columns.tokens")}
|
||
{getSortIndicator("tokens")}
|
||
</th>
|
||
)}
|
||
{visibleColumns.tps && (
|
||
<th
|
||
className={`${LOG_TABLE_HEADER_CELL_RIGHT_CLASS} cursor-pointer select-none`}
|
||
onClick={() => toggleSort("tps")}
|
||
>
|
||
{t("columns.tps")}
|
||
{getSortIndicator("tps")}
|
||
</th>
|
||
)}
|
||
{visibleColumns.duration && (
|
||
<th
|
||
className={`${LOG_TABLE_HEADER_CELL_RIGHT_CLASS} cursor-pointer select-none`}
|
||
onClick={() => toggleSort("duration")}
|
||
>
|
||
{t("columns.duration")}
|
||
{getSortIndicator("duration")}
|
||
</th>
|
||
)}
|
||
{visibleColumns.time && (
|
||
<th
|
||
className={`${LOG_TABLE_HEADER_CELL_RIGHT_CLASS} cursor-pointer select-none`}
|
||
onClick={() => toggleSort("time")}
|
||
>
|
||
{t("columns.time")}
|
||
{getSortIndicator("time")}
|
||
</th>
|
||
)}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border/30">
|
||
{groupedLogs.map((log) => {
|
||
const isActive = log.active === true;
|
||
const statusStyle = isActive ? null : getStatusStyle(log.status);
|
||
const protocolKey = isActive ? null : log.sourceFormat || log.provider;
|
||
const protocol = protocolKey
|
||
? getProtocolColor(protocolKey, log.provider)
|
||
: null;
|
||
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 = !isActive && log.status >= 400;
|
||
const cacheSourceMeta = getCacheSourceMeta(log.cacheSource);
|
||
const isSemanticCache = cacheSourceMeta?.key === "semantic";
|
||
const accountLabel = maskAccount(log.account, emailsVisible);
|
||
|
||
return (
|
||
<tr
|
||
key={log.id}
|
||
onClick={() => openDetail(log)}
|
||
data-cid={log.correlationId || undefined}
|
||
onMouseEnter={() => log.correlationId && setHoveredCid(log.correlationId)}
|
||
onMouseLeave={() => setHoveredCid(null)}
|
||
className={
|
||
`cursor-pointer transition-colors ` +
|
||
`${
|
||
isError
|
||
? "bg-red-500/5 hover:bg-red-500/15 dark:hover:bg-red-400/15"
|
||
: "hover:bg-sky-500/10 dark:hover:bg-sky-400/10"
|
||
} ` +
|
||
`${log.isRetry ? "border-l-2 border-l-amber-500/50" : ""} ` +
|
||
`${hoveredCid && log.correlationId === hoveredCid ? "bg-violet-500/10 dark:bg-violet-400/10 ring-1 ring-violet-500/20" : ""}`
|
||
}
|
||
style={
|
||
log.isRetry
|
||
? {
|
||
backgroundColor:
|
||
hoveredCid && log.correlationId === hoveredCid
|
||
? undefined
|
||
: "rgba(245,158,11,0.03)",
|
||
}
|
||
: undefined
|
||
}
|
||
>
|
||
{visibleColumns.status && (
|
||
<td className="px-3 py-2">
|
||
{isActive ? (
|
||
<span
|
||
className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-amber-500/15 border border-amber-500/25"
|
||
title="In progress"
|
||
>
|
||
<span className="inline-block h-3 w-3 rounded-full border-2 border-amber-500 border-t-transparent animate-spin" />
|
||
</span>
|
||
) : (
|
||
<span className="inline-flex items-center gap-1">
|
||
<span
|
||
className="inline-block px-2 py-0.5 rounded text-[10px] font-bold min-w-[36px] text-center"
|
||
style={{
|
||
backgroundColor: statusStyle.bg,
|
||
color: statusStyle.text,
|
||
}}
|
||
>
|
||
{log.status || "..."}
|
||
</span>
|
||
{log.groupStatus === "healed" &&
|
||
!log.isRetry &&
|
||
log.status >= 400 && (
|
||
<span
|
||
className="text-emerald-500 text-[11px]"
|
||
title="Recovered by retry"
|
||
>
|
||
✓
|
||
</span>
|
||
)}
|
||
{log.isRetry && (
|
||
<button
|
||
className="inline-flex items-center text-amber-500 hover:text-amber-400 text-[11px] ml-0.5"
|
||
title="Go to parent request"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
const parent = groupedLogs.find(
|
||
(g) => g.correlationId === log.correlationId && !g.isRetry
|
||
);
|
||
if (parent) openDetail(parent);
|
||
}}
|
||
>
|
||
↳
|
||
</button>
|
||
)}
|
||
</span>
|
||
)}
|
||
</td>
|
||
)}
|
||
{visibleColumns.cacheSource && (
|
||
<td className="px-3 py-2">
|
||
{isActive ? (
|
||
<span className="text-text-muted text-[10px]">—</span>
|
||
) : (
|
||
<span
|
||
className={`inline-block px-2 py-0.5 rounded text-[9px] font-bold uppercase ${cacheSourceMeta?.className || ""}`}
|
||
title={
|
||
isSemanticCache ? t("semanticCacheHit") : t("upstreamResponse")
|
||
}
|
||
>
|
||
{isSemanticCache ? t("semantic") : t("upstream")}
|
||
</span>
|
||
)}
|
||
</td>
|
||
)}
|
||
{visibleColumns.model && (
|
||
<td className="px-3 py-2 font-medium text-primary font-mono text-[11px]">
|
||
<div className="flex items-center gap-1.5">
|
||
<span>{log.model}</span>
|
||
{log.groupStatus === "healed" && !log.isRetry && (
|
||
<span
|
||
className="inline-flex items-center gap-0.5 px-1 py-0 rounded text-[8px] font-bold bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/25"
|
||
title={`Failed, recovered after ${log.groupSize - 1} retry`}
|
||
>
|
||
healed
|
||
</span>
|
||
)}
|
||
{log.groupStatus === "failed" && !log.isRetry && (
|
||
<span
|
||
className="inline-flex items-center gap-0.5 px-1 py-0 rounded text-[8px] font-bold bg-red-500/15 text-red-600 dark:text-red-400 border border-red-500/25"
|
||
title={`All ${log.groupSize} attempts failed`}
|
||
>
|
||
failed
|
||
</span>
|
||
)}
|
||
{log.modelPinned && (
|
||
<span
|
||
className="inline-flex items-center gap-0.5 px-1 py-0 rounded text-[8px] font-bold bg-violet-500/15 text-violet-600 dark:text-violet-400 border border-violet-500/25"
|
||
title="Model selected via context-cache session pinning"
|
||
>
|
||
pinned
|
||
</span>
|
||
)}
|
||
</div>
|
||
{log.correlationId && !log.isRetry && log.groupSize > 1 && (
|
||
<div
|
||
className="text-[9px] text-text-muted font-normal truncate max-w-[120px]"
|
||
title={log.correlationId}
|
||
>
|
||
{log.correlationId.slice(0, 12)}… · {log.groupSize} attempts
|
||
</div>
|
||
)}
|
||
{log.correlationId && !log.isRetry && log.groupSize <= 1 && (
|
||
<div
|
||
className="text-[9px] text-text-muted font-normal truncate max-w-[120px]"
|
||
title={log.correlationId}
|
||
>
|
||
{log.correlationId.slice(0, 12)}…
|
||
</div>
|
||
)}
|
||
{log.correlationId && log.isRetry && (
|
||
<div
|
||
className="text-[9px] text-amber-500/70 font-normal truncate max-w-[120px]"
|
||
title={log.correlationId}
|
||
>
|
||
{log.correlationId.slice(0, 12)}…
|
||
</div>
|
||
)}
|
||
</td>
|
||
)}
|
||
{visibleColumns.requestedModel && (
|
||
<td className="px-3 py-2 font-mono text-[11px]">
|
||
{log.requestedModel ? (
|
||
<span
|
||
className={
|
||
log.requestedModel !== log.model
|
||
? "text-amber-600 dark:text-amber-400"
|
||
: "text-text-muted"
|
||
}
|
||
title={
|
||
log.requestedModel !== log.model
|
||
? t("requestedRoutedTitle", {
|
||
requested: log.requestedModel,
|
||
routed: log.model,
|
||
})
|
||
: log.requestedModel
|
||
}
|
||
>
|
||
{log.requestedModel}
|
||
</span>
|
||
) : (
|
||
<span className="text-text-muted text-[10px]">—</span>
|
||
)}
|
||
</td>
|
||
)}
|
||
{visibleColumns.provider && (
|
||
<td className="px-3 py-2">
|
||
<span
|
||
className="inline-block px-2 py-0.5 rounded text-[9px] font-bold uppercase"
|
||
style={{
|
||
backgroundColor: providerColor.bg,
|
||
color: providerColor.text,
|
||
}}
|
||
>
|
||
{providerLabel}
|
||
</span>
|
||
</td>
|
||
)}
|
||
{visibleColumns.protocol && (
|
||
<td className="px-3 py-2">
|
||
{isActive ? (
|
||
<span className="text-text-muted text-[10px]">—</span>
|
||
) : (
|
||
<span
|
||
className="inline-block px-2 py-0.5 rounded text-[9px] font-bold uppercase"
|
||
style={
|
||
protocol
|
||
? { backgroundColor: protocol.bg, color: protocol.text }
|
||
: {}
|
||
}
|
||
>
|
||
{protocol?.label || "—"}
|
||
</span>
|
||
)}
|
||
</td>
|
||
)}
|
||
{visibleColumns.account && (
|
||
<td
|
||
className="px-3 py-2 text-text-muted truncate max-w-[120px]"
|
||
title={accountLabel}
|
||
>
|
||
{accountLabel}
|
||
</td>
|
||
)}
|
||
{visibleColumns.apiKey && (
|
||
<td
|
||
className="px-3 py-2 text-text-muted truncate max-w-[140px]"
|
||
title={log.apiKeyName || log.apiKeyId || t("noApiKey")}
|
||
>
|
||
{isActive ? (
|
||
<span className="text-text-muted text-[10px]">—</span>
|
||
) : (
|
||
formatApiKeyLabel(log.apiKeyName, log.apiKeyId)
|
||
)}
|
||
</td>
|
||
)}
|
||
{visibleColumns.combo && (
|
||
<td className="px-3 py-2">
|
||
{isActive ? (
|
||
<span className="text-text-muted text-[10px]">—</span>
|
||
) : log.comboName ? (
|
||
<span className="inline-block px-2 py-0.5 rounded-full text-[9px] font-bold bg-violet-500/20 text-violet-800 dark:text-violet-300 border border-violet-500/40">
|
||
{log.comboName}
|
||
</span>
|
||
) : (
|
||
<span className="text-text-muted text-[10px]">—</span>
|
||
)}
|
||
</td>
|
||
)}
|
||
{visibleColumns.tokens && (
|
||
<td className="px-3 py-2 text-right whitespace-nowrap">
|
||
{isActive ? (
|
||
<span className="text-text-muted text-[10px]">—</span>
|
||
) : (
|
||
<>
|
||
<span className="text-text-muted">TI:</span>{" "}
|
||
<span className="text-primary">
|
||
{log.tokens?.in?.toLocaleString() || 0}
|
||
</span>
|
||
<span className="mx-1 text-border">|</span>
|
||
<span className="text-text-muted">TO:</span>{" "}
|
||
<span className="text-emerald-700 dark:text-emerald-400">
|
||
{log.tokens?.out?.toLocaleString() || 0}
|
||
</span>
|
||
{log.tokens?.compressed != null && log.tokens.compressed > 0 && (
|
||
<>
|
||
<span className="mx-1 text-border">|</span>
|
||
<span
|
||
className="text-purple-500 dark:text-purple-400 font-semibold"
|
||
title={`${log.tokens.compressed.toLocaleString()} tokens compressed`}
|
||
>
|
||
↓{log.tokens.compressed.toLocaleString()}
|
||
</span>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</td>
|
||
)}
|
||
{visibleColumns.tps && (
|
||
<td className="px-3 py-2 text-right whitespace-nowrap font-mono">
|
||
{isActive ? (
|
||
<span className="text-text-muted text-[10px]">—</span>
|
||
) : (
|
||
(() => {
|
||
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 (
|
||
<span className={color} title={`${tps.toFixed(2)} tokens/sec`}>
|
||
{formatTps(tps)}
|
||
</span>
|
||
);
|
||
})()
|
||
)}
|
||
</td>
|
||
)}
|
||
{visibleColumns.duration && (
|
||
<td className="px-3 py-2 text-right text-text-muted font-mono">
|
||
{formatDuration(log.duration)}
|
||
</td>
|
||
)}
|
||
{visibleColumns.time && (
|
||
<td className="px-3 py-2 text-right text-text-muted">
|
||
{formatTime(log.timestamp)}
|
||
</td>
|
||
)}
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
|
||
{hasMore && sortedLogs.length > 0 && (
|
||
<div
|
||
ref={loadMoreSentinelRef}
|
||
className="flex justify-center py-3 border-t border-border/30"
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={loadMore}
|
||
className="px-4 py-1.5 text-xs rounded-md border border-border bg-bg-subtle hover:bg-bg-muted text-text-muted transition-colors"
|
||
>
|
||
{loading ? t("loadingMore") : t("loadMore")}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
|
||
<div className="text-[10px] text-text-muted italic">
|
||
{t("callLogsInfo", {
|
||
dataDir: "{DATA_DIR}/call_logs/",
|
||
retentionDays: "CALL_LOG_RETENTION_DAYS",
|
||
maxEntries: "CALL_LOG_MAX_ENTRIES",
|
||
})}
|
||
</div>
|
||
|
||
{/* Detail Modal */}
|
||
{selectedLog && (
|
||
<RequestLoggerDetail
|
||
log={selectedLog}
|
||
detail={detailData}
|
||
loading={detailLoading}
|
||
debugEnabled={selectedLog.active ? true : detailLoggingEnabled}
|
||
emailsVisible={emailsVisible}
|
||
onClose={closeDetail}
|
||
onCopy={copyToClipboard}
|
||
onPrevious={handlePrev}
|
||
onNext={handleNext}
|
||
relatedLogs={
|
||
selectedLog.correlationId
|
||
? groupedLogs.filter((l) => l.correlationId === selectedLog.correlationId)
|
||
: []
|
||
}
|
||
onSelectRelated={(r) => {
|
||
closeDetail();
|
||
openDetail(r);
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
);
|
||
|
||
RequestLoggerV2.displayName = "RequestLoggerV2";
|
||
|
||
export default RequestLoggerV2;
|