From 9e3f5bbc578bd50e86487e1da657f7967197ebe9 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 22 May 2026 12:04:52 -0300 Subject: [PATCH 1/2] docs(env): document OMNIROUTE_SKIP_DB_HEALTHCHECK in .env.example and ENVIRONMENT.md --- .env.example | 4 ++++ docs/reference/ENVIRONMENT.md | 27 ++++++++++++++------------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index da143b2e136b..c2d5a99162f1 100644 --- a/.env.example +++ b/.env.example @@ -81,6 +81,10 @@ PORT=20128 # through the custom dev runner without this on Windows. OMNIROUTE_USE_TURBOPACK=1 +# Skip the SQLite integrity health check on startup (faster boot on large DBs). +# Used by: src/lib/db/core.ts, src/lib/db/healthCheck.ts. Set to 1 to skip. +# OMNIROUTE_SKIP_DB_HEALTHCHECK=1 + # Docker production port mappings (docker-compose.prod.yml only). # These set the HOST-side published ports. Container ports use PORT/API_PORT. # PROD_DASHBOARD_PORT=20130 diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index a322ccb7cb01..3ff17ebb2f60 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -106,19 +106,20 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari ## 3. Network & Ports -| Variable | Default | Source File | Description | -| ------------------------- | ------------------------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PORT` | `20128` | `src/lib/runtime/ports.ts` | Primary port for both Dashboard UI and API endpoints (single-port mode). | -| `API_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the `/v1/*` proxy API on this separate port. | -| `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | Bind address for the API port. | -| `DASHBOARD_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the Dashboard UI on this separate port. | -| `PROD_DASHBOARD_PORT` | `20130` | `docker-compose.prod.yml` | Host-side published port for the Dashboard in Docker production mode. | -| `PROD_API_PORT` | `20131` | `docker-compose.prod.yml` | Host-side published port for the API in Docker production mode. | -| `OMNIROUTE_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | Takes precedence over `PORT` when running inside Electron or other wrappers. | -| `NODE_ENV` | `production` | Next.js core | Controls logging verbosity, caching, error detail exposure, and Next.js optimizations. | -| `OMNIROUTE_USE_TURBOPACK` | `1` (default in `.env.example`) | `package.json` / Next.js 16 | Toggles the Next.js 16 Turbopack bundler in `npm run dev` and `npm run build`. Set to `0` on Windows or when running into native binding incompatibilities. | -| `HOST` | `0.0.0.0` | `scripts/dev/run-next.mjs` | Bind address for the Next.js dev/start server. Overrides the default `0.0.0.0` when set. | -| `HOSTNAME` | `127.0.0.1` | `scripts/dev/run-next-playwright.mjs` | Bind address used by the Playwright runner when launching Next.js. Defaults to `127.0.0.1` for hermetic tests. | +| Variable | Default | Source File | Description | +| ------------------------------- | ------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PORT` | `20128` | `src/lib/runtime/ports.ts` | Primary port for both Dashboard UI and API endpoints (single-port mode). | +| `API_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the `/v1/*` proxy API on this separate port. | +| `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | Bind address for the API port. | +| `DASHBOARD_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the Dashboard UI on this separate port. | +| `PROD_DASHBOARD_PORT` | `20130` | `docker-compose.prod.yml` | Host-side published port for the Dashboard in Docker production mode. | +| `PROD_API_PORT` | `20131` | `docker-compose.prod.yml` | Host-side published port for the API in Docker production mode. | +| `OMNIROUTE_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | Takes precedence over `PORT` when running inside Electron or other wrappers. | +| `NODE_ENV` | `production` | Next.js core | Controls logging verbosity, caching, error detail exposure, and Next.js optimizations. | +| `OMNIROUTE_USE_TURBOPACK` | `1` (default in `.env.example`) | `package.json` / Next.js 16 | Toggles the Next.js 16 Turbopack bundler in `npm run dev` and `npm run build`. Set to `0` on Windows or when running into native binding incompatibilities. | +| `OMNIROUTE_SKIP_DB_HEALTHCHECK` | _(unset)_ | `src/lib/db/core.ts` / `src/lib/db/healthCheck.ts` | Set to `1` to skip the SQLite integrity health check on startup. Useful for faster boot on large databases. | +| `HOST` | `0.0.0.0` | `scripts/dev/run-next.mjs` | Bind address for the Next.js dev/start server. Overrides the default `0.0.0.0` when set. | +| `HOSTNAME` | `127.0.0.1` | `scripts/dev/run-next-playwright.mjs` | Bind address used by the Playwright runner when launching Next.js. Defaults to `127.0.0.1` for hermetic tests. | ### Port Modes From 226493482ee1d952c2d79eafe318f5e8942e5f95 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 22 May 2026 12:05:52 -0300 Subject: [PATCH 2/2] fix(dashboard): paginate request-log viewer beyond 300 (#2565) The request-log viewer hardcoded a single 300-row fetch window, so users could never see logs older than the latest 300 even though call_logs are retained far longer (up to CALL_LOGS_TABLE_MAX_ROWS / CALL_LOG_RETENTION_DAYS). - getCallLogs now accepts an offset and binds limit/offset as parameters - /api/usage/call-logs parses offset - RequestLoggerV2 grows its window via Load more + IntersectionObserver infinite scroll, resetting on filter changes - adds pagination unit tests for getCallLogs --- src/app/api/usage/call-logs/route.ts | 1 + src/i18n/messages/en.json | 2 + src/lib/usage/callLogs.ts | 7 +- src/shared/components/RequestLoggerV2.tsx | 61 ++++++++++- tests/unit/call-logs-pagination.test.ts | 119 ++++++++++++++++++++++ 5 files changed, 185 insertions(+), 5 deletions(-) create mode 100644 tests/unit/call-logs-pagination.test.ts diff --git a/src/app/api/usage/call-logs/route.ts b/src/app/api/usage/call-logs/route.ts index 671e5d2e5e07..2b9f9b629e26 100644 --- a/src/app/api/usage/call-logs/route.ts +++ b/src/app/api/usage/call-logs/route.ts @@ -18,6 +18,7 @@ export async function GET(request: Request) { if (searchParams.get("combo")) filter.combo = searchParams.get("combo"); if (searchParams.get("search")) filter.search = searchParams.get("search"); if (searchParams.get("limit")) filter.limit = parseInt(searchParams.get("limit")); + if (searchParams.get("offset")) filter.offset = parseInt(searchParams.get("offset")); const logs = await getCallLogs(filter); return NextResponse.json(logs); diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index cfc123c6ccf1..988255e99ba9 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -6406,6 +6406,8 @@ "combo": "Combo", "keys": "Keys", "shown": "Shown", + "loadMore": "Load more", + "loadingMore": "Loading more...", "sortNewest": "Newest", "sortOldest": "Oldest", "sortTokensDesc": "Tokens ↓", diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index 1e97c686d211..ca3329110abc 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -844,8 +844,11 @@ export async function getCallLogs(filter: any = {}) { sql += " WHERE " + conditions.join(" AND "); } - const limit = filter.limit || 200; - sql += ` ORDER BY cl.timestamp DESC LIMIT ${limit}`; + const limit = Number.isInteger(filter.limit) && filter.limit > 0 ? filter.limit : 200; + const offset = Number.isInteger(filter.offset) && filter.offset > 0 ? filter.offset : 0; + sql += ` ORDER BY cl.timestamp DESC LIMIT @__limit OFFSET @__offset`; + params.__limit = limit; + params.__offset = offset; const rows = db.prepare(sql).all(params) as CallLogSummaryRow[]; return rows.map(mapSummaryRow); diff --git a/src/shared/components/RequestLoggerV2.tsx b/src/shared/components/RequestLoggerV2.tsx index 4631760a3821..fe8330c795d3 100644 --- a/src/shared/components/RequestLoggerV2.tsx +++ b/src/shared/components/RequestLoggerV2.tsx @@ -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)); 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); + }, []); + + // Infinite scroll: grow the window when the sentinel near the bottom of the + // scroll container becomes visible. + useEffect(() => { + const sentinel = loadMoreSentinelRef.current; + const root = scrollContainerRef.current; + if (!sentinel || !hasMore) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && !loading) loadMore(); + }, + { root, rootMargin: "200px" } + ); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [hasMore, loading, loadMore]); + const filteredLogs = useMemo(() => { if (activeFilter === "combo") return logs.filter((l) => l.comboName); return logs; @@ -588,7 +625,10 @@ export default function RequestLoggerV2() { {/* Table */} -
+
{loading && logs.length === 0 ? (
{t("loadingLogs")}
) : logs.length === 0 ? ( @@ -856,6 +896,21 @@ export default function RequestLoggerV2() { )} + + {hasMore && sortedLogs.length > 0 && ( +
+ +
+ )}
diff --git a/tests/unit/call-logs-pagination.test.ts b/tests/unit/call-logs-pagination.test.ts new file mode 100644 index 000000000000..99835b08f16b --- /dev/null +++ b/tests/unit/call-logs-pagination.test.ts @@ -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) { + 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); +});