Files
OmniRoute/src/shared/components/RequestLoggerV2.tsx
Markus Hartung beb6ec857b feat(dashboard): agentic conversation tracking — v4, decoupled + storage-architecture concern resolved (#10263)
* feat(responses): virtualize previous_response_id continuation regardless of upstream support

OmniRoute now exposes OpenAI-compatible previous_response_id/store
continuation to clients unconditionally, even when the selected upstream
provider has no native Responses-API state support. Reconstruction happens
server-side in handleChatImplementation, before any downstream validation
or provider translation: OmniRoute resolves the response id back to the
full input/output it previously produced, prepends it to the client's
delta, and forwards the full reconstructed history upstream exactly as it
does today. Client<->OmniRoute traffic shrinks to the new delta only;
OmniRoute<->provider traffic is unchanged.

Storage reuses the existing call-log pipeline artifact (already gated by
call_log_pipeline_enabled, already retained/cleaned up by the existing
call-log lifecycle) instead of duplicating conversation content into a
second store -- only a lightweight call_logs.response_id index is new.
Every lookup is scoped by api_key_id so one client can never resolve
another client's stored conversation, and any unresolvable/missing/
size-limit-omitted state fails closed with OpenAI's own
previous_response_not_found contract.

Stacked on feat/openai-responses-store-toggle (#10121).

* feat(dashboard): agentic conversation tracking with live transcript view

Every agentic chat request now gets a conversation id (X-ConversationId
response header). OmniRoute detects when a follow-up request continues the
same conversation via fingerprint + bounded prefix-hash matching, with a
strict-growth invariant to prevent false merges between independent
single-shot requests that happen to share identical opening content.
Continuation detection excludes the system message from the identity
anchor, since real coding-agent CLIs commonly regenerate it every request
with live context (timestamp, cwd, git status) — without this, that
volatility alone broke every continuation check against real traffic.

- `/dashboard/logs`: new toggleable Conversation column.
- `/dashboard/logs/timeline`: requests sharing a conversation id share a
  timeline lane, connected by an arrow, with a configurable lane-reuse
  window.
- Request detail panel: new Full Conversation transcript above the raw SSE
  event stream — Markdown rendering, per-turn timestamps, turn-relative
  view, click-any-turn navigation, live auto-refresh building the
  transcript in real time from the in-flight SSE chunk buffer while a
  request is still streaming, auto-scroll-to-bottom as the live turn grows.
- New `/dashboard/conversations` page listing conversations with 2+ turns,
  no-forking model (an edited/duplicated mid-history turn mints its own
  independent conversation instead of merging), pagination, duplicate-
  anchor fix.
- Configurable auto-refresh intervals on both the timeline and
  conversations list pages.
- Responses API tool-call gap fix: turnsFromOpenAiMessages only handled
  role-based Chat Completions messages, so bare {type:"function_call"} /
  {type:"function_call_output"} / {type:"reasoning"} items (real Responses
  API traffic) silently vanished from the Conversation Context panel.
- truncateForLog now counts input[] (Responses API), not just messages[]
  (Chat Completions), so a truncated /v1/responses request still shows a
  placeholder instead of nothing.
- RequestTimeline.tsx now reads the same debugEnabled/emailsVisible
  settings RequestLoggerV2.tsx already used, instead of hardcoding both
  false — the timeline view never showed SSE/stream-chunk events or
  respected email-masking, regardless of the actual setting.

Migrations 147/148 (agentic_conversations, conversation_turn_nodes) — 135
and 136 are now taken upstream; 143-145 are documented KNOWN_GAPS, so this
uses the next free slot past upstream's current highest.

Test plan:
- npm run typecheck:core — clean
- npm run lint — clean
- node --import tsx/esm scripts/check/check-migration-numbering.mjs — OK, 0 collisions
- 109 unit tests across the conversation-tracking, migration-renumber, and
  dashboard-wiring surface — 0 failures

* refactor(dashboard): reuse call-log artifacts for conversation transcript content

conversation_turn_nodes no longer stores turn text/tool-call content
(text_preview/block_kind/tool_name) -- it's identity-only now (id/parent/
content_hash), matching agentic_conversations' existing lightweight-index
shape. Every node's originating request is already fully captured by the
call-log pipeline artifact its last_correlation_id points at, so the
/dashboard/conversations tree view resolves each node's actual display
content on demand from there (open-sse/services/conversationTurnContent.ts),
re-running the same extractCanonicalTurns/hashTurnContent the write path
used and matching by content_hash, instead of duplicating conversation
content into a second store under a separate retention/gating policy. This
also drops the old 8000-char text_preview truncation entirely -- resolved
content is always full and untruncated.

The frontend contract is unchanged (tree API still returns
{textPreview, blockKind, toolName} per node), so the dashboard UI itself
(page.tsx, RequestLoggerDetail/RequestTimeline, sidebar, i18n) needed no
changes.

Renumbered the cherry-picked 147/148 migrations to 153/154 -- 147 now
collides with 147_api_keys_model_access_mode.sql, which landed on
release/v3.8.50 after this work was originally built.

Also includes a standalone, unrelated fix carried along from this rebase:
close isProviderModelHidden's missing function-body brace in
modelSelectModalHelpers.ts (separately landed as #10206).

Stacked on feat/responses-previous-response-id-virtualization (#3), which
is itself stacked on feat/openai-responses-store-toggle (#10121).

* fix(dashboard): resync conversation list on open so the live-text poll starts immediately

openConversation() seeded activeConversation (and therefore activeCallLogId,
which gates the live-partial-text poll effect) from whatever row snapshot the
list's own fixed-interval poll last produced. A conversation opened right
after a reply started streaming -- after that tick, before the next -- had
activeCallLogId still null, so the live-text poll never started; only a
subsequent background list-poll resync (already existed) picked it up,
which is why closing and reopening the same conversation "just worked".

loadConversations() is now a shared callback so openConversation can force
one immediately on open instead of waiting on pollSeconds.

Live-verified against omniroute-dev: opening a conversation mid-stream now
shows live reasoning on the first open.

* style: prettier formatting for conversationTurnContent.test.ts

* fix(db): close migration numbering gap left by decoupling from #3/#10262

153/154 (originally 154/155) were chosen back when this branch stacked on
top of the previous_response_id migration (153_call_logs_response_id.sql).
Decoupling removed that migration from this branch's history, leaving an
unused 153 slot that check-migration-numbering.test.ts correctly flags as
a gap.

* refactor(dashboard): split RequestTimeline/RequestLoggerDetail under the 1000-line file-size cap

Both files exceeded check-file-size's new-file cap after this PR's own
additions (RequestTimeline 1048, RequestLoggerDetail 1163). Extracted pure
non-component logic (types, constants, allocateLanes and its helpers) out
of RequestTimeline.tsx into RequestTimeline.utils.ts, and the two
self-contained presentational sub-components (PayloadSection,
ConversationContextSection + its private helper) out of
RequestLoggerDetail.tsx into RequestLoggerDetail.sections.tsx. No behavior
change; existing external imports (default exports, allocateLanes,
TimelineLog, CONVERSATION_LANE_REUSE_STORAGE_KEY) still resolve from the
original file paths.

* fix(db): renumber agentic-conversation migrations to clear 153 collision + sync migration-count docs

The refresh-merge of release/v3.8.50 exposed that the feature's three
migrations collided at slot 153 with the base's radar_local_model_state
(153) and its own call_logs_response_id. Migration runner enforces unique
numeric prefixes -> every DB init threw, red-ing Vitest, all Unit shards and
the DB-backed quality gates. Renumber the feature's pair to
155_agentic_conversations / 156_conversation_turn_nodes and move
call_logs_response_id to 154 (keeps 153_radar base-owned, preserves
agentic-before-turn_nodes ordering). Update SQL headers and the
154/156 references in feature code + tests.

Migration count is now 151 (was 148 stale in README/AGENTS/llm.txt) — sync
the doc counts to clear the docs-accuracy gate.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

* fix(ui): drop unused CONVERSATION_LANE_REUSE_STORAGE_KEY re-export from RequestTimeline

Knip 6.32 (baseline 415) flags the public re-export of
CONVERSATION_LANE_REUSE_STORAGE_KEY from RequestTimeline.tsx as dead: no
external consumer imports it through that re-export (it is imported and
used directly from RequestTimeline.utils.ts inside the component). Removed
the unused re-export; the internal import stays. DEAD_TOTAL 416 -> 415,
back to the frozen baseline.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

* fix(agentic-conversations): guard resolveConversationId, drop dead whole-chain export

- Wrap resolveConversationId() in try/catch in chat.ts, matching the
  defensive pattern used by every other best-effort side call nearby, so a
  DB hiccup in conversation tracking can't turn a working chat request into
  a hard failure.
- Remove getConversationTurnTree: knip's project scope excludes tests/**,
  so an export used only by tests can never register as used there. Swap
  its 8 test call sites to the paginated getConversationTurnPage (already
  the dashboard's canonical query) with a generous limit, collapsing to one
  query path instead of keeping a second whole-chain export alive solely
  for test convenience.
- Regenerate i18n llm.txt mirrors from root (pre-existing drift on this
  branch, unrelated to the above, caught by the docs-sync pre-commit gate).

Addresses PR review feedback.

* fix(i18n): close requestLogger conversation-column gap, fix domain-modules count drift

- fr.json, vi.json were missing requestLogger.columns.conversation (added
  in the conversation-tracking feature), failing i18n-vi-completeness.test.ts.
- docs/i18n/*/llm.txt mirrors still said 117 domain-specific files after an
  earlier rebase fixed the migration count but missed this companion number,
  failing check-docs-sync.mjs across all 42 locales.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(docs): restore PROXY_LOG_INCLUDE_IPS env/doc entries (env-doc-sync red)

.env.example and docs/reference/ENVIRONMENT.md were both missing the
PROXY_LOG_INCLUDE_IPS entry that src/lib/proxyLogger.ts already reads
(confirmed present at this branch's merge-base too, so this predates
the conversation-tracking work and is unrelated to it) -- the entry
was added on release/v3.8.50 after this branch's last sync and this
branch never picked it up. That gap red-lines
tests/unit/check-env-doc-sync.test.ts and
tests/unit/issue-7793-env-doc-sync-repro.test.ts (Unit Tests
fast-path 2/4 in CI). Restore both entries verbatim from the current
release/v3.8.50 tip -- no feature-code change.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: hartmark <hartmark@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 11:32:33 -03:00

1712 lines
72 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 tCache = useTranslations("cache");
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") },
{ key: "conversation", label: t("columns.conversation") },
],
[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);
// Set when handlePrev/handleNext hits the edge of the (possibly stale —
// list polling pauses while a detail modal is open) in-memory list, so we
// can tell a genuine "no more items" from "more items landed in the
// background while the modal was open and we just haven't fetched them
// yet" before giving up and closing the modal.
const pendingBoundaryNavRef = useRef<null | "prev" | "next">(null);
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, { scroll: false });
} 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, { scroll: false });
} 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 {
// List polling pauses while the modal is open (#background list can
// go stale), so hitting the edge of the in-memory array doesn't mean
// there's really nothing newer — resync once and let the effect below
// decide, instead of assuming this is the last item and closing.
pendingBoundaryNavRef.current = "prev";
fetchLogs(false);
}
}, [currentLogIndex, sortedLogsForNav, fetchLogs]);
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 {
pendingBoundaryNavRef.current = "next";
fetchLogs(false);
}
}, [currentLogIndex, sortedLogsForNav, fetchLogs]);
// Resolves a pending boundary nav (see handlePrev/handleNext) once a
// triggered fetchLogs() resync has landed in sortedLogsForNav. Only fires
// when a boundary nav is actually pending, so this is a no-op on the
// normal (paused-while-modal-open) list-update cadence.
useEffect(() => {
const direction = pendingBoundaryNavRef.current;
if (!direction || !selectedLog) return;
pendingBoundaryNavRef.current = null;
const idx = sortedLogsForNav.findIndex((l) => l.id === selectedLog.id);
const target =
direction === "prev"
? idx > 0
? sortedLogsForNav[idx - 1]
: null
: 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 adjacent log id:", error_);
});
} else {
closeDetail();
}
// openDetail/closeDetail are plain functions re-created every render
// (same as handlePrev/handleNext above and the rest of this file) —
// listing them would re-fire this effect on every render instead of
// only when sortedLogsForNav/selectedLog actually change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sortedLogsForNav, selectedLog]);
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={t("correlationId")}
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 ? t("group.showAllRows") : t("group.showLatestPerCorrelation")}
>
<span className="material-symbols-outlined text-[16px]">
{groupedView ? "unfold_less" : "unfold_more"}
</span>
{groupedView ? t("group.grouped") : t("statusFilters.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} {t("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={t("interval.decrease")}
>
</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={t("interval.title")}
/>
<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={t("interval.increase")}
>
+
</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>
)}
{visibleColumns.conversation && (
<th className={LOG_TABLE_HEADER_CELL_CLASS}>{t("columns.conversation")}</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 = log.providerDisplay || 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={t("status.inProgress")}
>
<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={t("status.recoveredByRetry")}
>
</span>
)}
{log.isRetry && (
<button
className="inline-flex items-center text-amber-500 hover:text-amber-400 text-[11px] ml-0.5"
title={t("status.goToParent")}
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={t("status.healedTitle", { count: log.groupSize - 1 })}
>
{t("status.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={t("status.failedTitle", { count: log.groupSize })}
>
{t("status.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={t("status.pinnedTitle")}
>
{t("status.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} {t("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?.cacheRead != null && log.tokens.cacheRead > 0 && (
<>
<span className="mx-1 text-border">|</span>
<span className="text-text-muted">CR:</span>{" "}
<span
className="text-sky-700 dark:text-sky-400"
title={tCache("cachedTokensCol")}
>
{log.tokens.cacheRead.toLocaleString()}
</span>
</>
)}
{log.tokens?.cacheWrite != null && log.tokens.cacheWrite > 0 && (
<>
<span className="mx-1 text-border">|</span>
<span className="text-text-muted">CW:</span>{" "}
<span
className="text-amber-700 dark:text-amber-400"
title={tCache("cacheCreation")}
>
{log.tokens.cacheWrite.toLocaleString()}
</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>
)}
{visibleColumns.conversation && (
<td className="px-3 py-2 font-mono text-[10px] text-text-muted">
{log.sessionTag ? (
<span title={log.sessionTag}>{log.sessionTag.slice(0, 12)}</span>
) : (
<span className="text-text-muted"></span>
)}
</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;