Repository navigation
fix(dashboard): paginate request-log viewer beyond 300 (#2565) #2576
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -19,6 +19,11 @@ import { | |||||||||||||||
| } from "@/shared/utils/formatting"; | ||||||||||||||||
| import useEmailPrivacyStore from "@/store/emailPrivacyStore"; | ||||||||||||||||
|
|
||||||||||||||||
| // Number of call-log rows fetched per page. The viewer grows its window by this | ||||||||||||||||
| // amount on "Load more" / infinite scroll so users can browse past the first | ||||||||||||||||
| // page (previously hardcoded to a single 300-row window). See #2565. | ||||||||||||||||
| const PAGE_SIZE = 300; | ||||||||||||||||
|
|
||||||||||||||||
| /** | ||||||||||||||||
| * Get a friendly display label for compatible providers. | ||||||||||||||||
| * Converts long IDs like "openai-compatible-chat-02669115-2545-4896-b003-cb4dac09d441" | ||||||||||||||||
|
|
@@ -134,9 +139,13 @@ export default function RequestLoggerV2() { | |||||||||||||||
| const [detailData, setDetailData] = useState(null); | ||||||||||||||||
| const [detailLoggingEnabled, setDetailLoggingEnabled] = useState(false); | ||||||||||||||||
| const [detailLoggingLoading, setDetailLoggingLoading] = useState(false); | ||||||||||||||||
| const [limit, setLimit] = useState(PAGE_SIZE); | ||||||||||||||||
| const [hasMore, setHasMore] = useState(false); | ||||||||||||||||
| const intervalRef = useRef(null); | ||||||||||||||||
| const hasLoadedRef = useRef(false); | ||||||||||||||||
| const logsSignatureRef = useRef(""); | ||||||||||||||||
| const scrollContainerRef = useRef(null); | ||||||||||||||||
| const loadMoreSentinelRef = useRef(null); | ||||||||||||||||
| const [providerNodes, setProviderNodes] = useState([]); | ||||||||||||||||
|
|
||||||||||||||||
| // Column visibility with localStorage persistence | ||||||||||||||||
|
|
@@ -174,11 +183,13 @@ export default function RequestLoggerV2() { | |||||||||||||||
| if (selectedProvider) params.set("provider", selectedProvider); | ||||||||||||||||
| if (selectedAccount) params.set("account", selectedAccount); | ||||||||||||||||
| if (selectedApiKey) params.set("apiKey", selectedApiKey); | ||||||||||||||||
| params.set("limit", "300"); | ||||||||||||||||
| params.set("limit", String(limit)); | ||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fetching the entire window (from 0 to |
||||||||||||||||
|
|
||||||||||||||||
| const res = await fetch(`/api/usage/call-logs?${params}`); | ||||||||||||||||
| if (res.ok) { | ||||||||||||||||
| const data = await res.json(); | ||||||||||||||||
| // If the server returned a full window, more rows may exist beyond it. | ||||||||||||||||
| setHasMore(Array.isArray(data) && data.length >= limit); | ||||||||||||||||
| // Skip re-render if data hasn't changed (#1369 GPU perf) | ||||||||||||||||
| const sig = JSON.stringify(data.map?.((l: any) => l.id) ?? []); | ||||||||||||||||
| if (sig !== logsSignatureRef.current) { | ||||||||||||||||
|
|
@@ -192,7 +203,7 @@ export default function RequestLoggerV2() { | |||||||||||||||
| if (showLoading) setLoading(false); | ||||||||||||||||
| } | ||||||||||||||||
| }, | ||||||||||||||||
| [search, activeFilter, selectedModel, selectedAccount, selectedProvider, selectedApiKey] | ||||||||||||||||
| [search, activeFilter, selectedModel, selectedAccount, selectedProvider, selectedApiKey, limit] | ||||||||||||||||
| ); | ||||||||||||||||
|
|
||||||||||||||||
| useEffect(() => { | ||||||||||||||||
|
|
@@ -233,6 +244,32 @@ export default function RequestLoggerV2() { | |||||||||||||||
| }; | ||||||||||||||||
| }, [recording, fetchLogs]); | ||||||||||||||||
|
|
||||||||||||||||
| // Reset the window back to the first page whenever the active filters change, | ||||||||||||||||
| // so switching filters doesn't keep fetching a large expanded window. | ||||||||||||||||
| useEffect(() => { | ||||||||||||||||
| setLimit(PAGE_SIZE); | ||||||||||||||||
| }, [search, activeFilter, selectedModel, selectedAccount, selectedProvider, selectedApiKey]); | ||||||||||||||||
|
|
||||||||||||||||
| const loadMore = useCallback(() => { | ||||||||||||||||
| setLimit((prev) => prev + PAGE_SIZE); | ||||||||||||||||
| }, []); | ||||||||||||||||
|
Comment on lines
+253
to
+255
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Note: To fix this completely, the
Suggested change
|
||||||||||||||||
|
|
||||||||||||||||
| // Infinite scroll: grow the window when the sentinel near the bottom of the | ||||||||||||||||
| // scroll container becomes visible. | ||||||||||||||||
| useEffect(() => { | ||||||||||||||||
| const sentinel = loadMoreSentinelRef.current; | ||||||||||||||||
| const root = scrollContainerRef.current; | ||||||||||||||||
| if (!sentinel || !hasMore) return; | ||||||||||||||||
| const observer = new IntersectionObserver( | ||||||||||||||||
| (entries) => { | ||||||||||||||||
| if (entries[0]?.isIntersecting && !loading) loadMore(); | ||||||||||||||||
| }, | ||||||||||||||||
|
Comment on lines
+264
to
+266
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||||||||||||||||
| { root, rootMargin: "200px" } | ||||||||||||||||
| ); | ||||||||||||||||
| observer.observe(sentinel); | ||||||||||||||||
| return () => observer.disconnect(); | ||||||||||||||||
| }, [hasMore, loading, loadMore]); | ||||||||||||||||
|
|
||||||||||||||||
| const filteredLogs = useMemo(() => { | ||||||||||||||||
| if (activeFilter === "combo") return logs.filter((l) => l.comboName); | ||||||||||||||||
| return logs; | ||||||||||||||||
|
|
@@ -588,7 +625,10 @@ export default function RequestLoggerV2() { | |||||||||||||||
|
|
||||||||||||||||
| {/* Table */} | ||||||||||||||||
| <Card className="overflow-hidden bg-black/5 dark:bg-black/20"> | ||||||||||||||||
| <div className="p-0 overflow-x-auto max-h-[calc(100vh-320px)] overflow-y-auto"> | ||||||||||||||||
| <div | ||||||||||||||||
| ref={scrollContainerRef} | ||||||||||||||||
| className="p-0 overflow-x-auto max-h-[calc(100vh-320px)] overflow-y-auto" | ||||||||||||||||
| > | ||||||||||||||||
| {loading && logs.length === 0 ? ( | ||||||||||||||||
| <div className="p-8 text-center text-text-muted">{t("loadingLogs")}</div> | ||||||||||||||||
| ) : logs.length === 0 ? ( | ||||||||||||||||
|
|
@@ -856,6 +896,21 @@ export default function RequestLoggerV2() { | |||||||||||||||
| </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> | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| import test from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import fs from "node:fs"; | ||
| import os from "node:os"; | ||
| import path from "node:path"; | ||
|
|
||
| // Regression for #2565: the dashboard log viewer paginates by growing its | ||
| // fetch window. getCallLogs must honor `limit` and `offset` and keep a stable | ||
| // `timestamp DESC` ordering so each page returns the expected rows. | ||
|
|
||
| const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-calllogs-pagination-")); | ||
| process.env.DATA_DIR = TEST_DATA_DIR; | ||
| process.env.CALL_LOG_RETENTION_DAYS = "3650"; | ||
| process.env.CALL_LOG_MAX_ENTRIES = "100000"; | ||
|
|
||
| const core = await import("../../src/lib/db/core.ts"); | ||
| const callLogs = await import("../../src/lib/usage/callLogs.ts"); | ||
|
|
||
| function insertCallLog(row: Record<string, unknown>) { | ||
| const db = core.getDbInstance(); | ||
| db.prepare( | ||
| ` | ||
| INSERT INTO call_logs ( | ||
| id, timestamp, method, path, status, model, requested_model, provider, account, | ||
| connection_id, duration, tokens_in, tokens_out, cache_source, source_format, target_format, | ||
| api_key_id, api_key_name, combo_name, combo_step_id, combo_execution_key, | ||
| error_summary, detail_state, artifact_relpath, artifact_size_bytes, artifact_sha256, | ||
| has_request_body, has_response_body, has_pipeline_details, request_summary | ||
| ) | ||
| VALUES ( | ||
| @id, @timestamp, @method, @path, @status, @model, @requested_model, @provider, @account, | ||
| @connection_id, @duration, @tokens_in, @tokens_out, @cache_source, @source_format, @target_format, | ||
| @api_key_id, @api_key_name, @combo_name, @combo_step_id, @combo_execution_key, | ||
| @error_summary, @detail_state, @artifact_relpath, @artifact_size_bytes, @artifact_sha256, | ||
| @has_request_body, @has_response_body, @has_pipeline_details, @request_summary | ||
| ) | ||
| ` | ||
| ).run({ | ||
| method: "POST", | ||
| path: "/v1/chat/completions", | ||
| status: 200, | ||
| model: "openai/gpt-4.1", | ||
| requested_model: null, | ||
| provider: "openai", | ||
| account: null, | ||
| connection_id: null, | ||
| duration: 0, | ||
| tokens_in: 0, | ||
| tokens_out: 0, | ||
| cache_source: "upstream", | ||
| source_format: null, | ||
| target_format: null, | ||
| api_key_id: null, | ||
| api_key_name: null, | ||
| combo_name: null, | ||
| combo_step_id: null, | ||
| combo_execution_key: null, | ||
| error_summary: null, | ||
| detail_state: "none", | ||
| artifact_relpath: null, | ||
| artifact_size_bytes: null, | ||
| artifact_sha256: null, | ||
| has_request_body: 0, | ||
| has_response_body: 0, | ||
| has_pipeline_details: 0, | ||
| request_summary: null, | ||
| ...row, | ||
| }); | ||
| } | ||
|
|
||
| test.before(async () => { | ||
| core.resetDbInstance(); | ||
| fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); | ||
| fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); | ||
| // Seed 25 rows with strictly increasing timestamps (id N -> minute N). | ||
| for (let i = 0; i < 25; i++) { | ||
| const mm = String(i).padStart(2, "0"); | ||
| insertCallLog({ id: `log_${mm}`, timestamp: `2026-05-22T10:${mm}:00.000Z` }); | ||
| } | ||
| }); | ||
|
|
||
| test.after(() => { | ||
| core.resetDbInstance(); | ||
| fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| test("#2565: getCallLogs honors limit and returns newest-first", async () => { | ||
| const page = await callLogs.getCallLogs({ limit: 10 }); | ||
| assert.equal(page.length, 10); | ||
| // Newest first: log_24 down to log_15. | ||
| assert.equal(page[0].id, "log_24"); | ||
| assert.equal(page[9].id, "log_15"); | ||
| }); | ||
|
|
||
| test("#2565: getCallLogs offset returns the next page without overlap", async () => { | ||
| const first = await callLogs.getCallLogs({ limit: 10, offset: 0 }); | ||
| const second = await callLogs.getCallLogs({ limit: 10, offset: 10 }); | ||
|
|
||
| assert.equal(second.length, 10); | ||
| // Second page continues where the first ended. | ||
| assert.equal(second[0].id, "log_14"); | ||
| assert.equal(second[9].id, "log_05"); | ||
|
|
||
| // No overlap between pages. | ||
| const firstIds = new Set(first.map((l: { id: string }) => l.id)); | ||
| assert.ok(second.every((l: { id: string }) => !firstIds.has(l.id))); | ||
| }); | ||
|
|
||
| test("#2565: growing window includes everything the paged windows cover", async () => { | ||
| const grown = await callLogs.getCallLogs({ limit: 20 }); | ||
| assert.equal(grown.length, 20); | ||
| assert.equal(grown[0].id, "log_24"); | ||
| assert.equal(grown[19].id, "log_05"); | ||
| }); | ||
|
|
||
| test("#2565: offset past the end yields an empty page", async () => { | ||
| const beyond = await callLogs.getCallLogs({ limit: 10, offset: 100 }); | ||
| assert.equal(beyond.length, 0); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Repository Style Guide (Rule 28) requires all inputs to be validated with Zod schemas from
src/shared/validation/schemas.ts. Theoffsetparameter (and the existinglimitparameter) should be validated using a schema rather than manual parsing. Additionally,parseIntshould always include a radix to avoid ambiguity.References