Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 14 additions & 13 deletions docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/app/api/usage/call-logs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The Repository Style Guide (Rule 28) requires all inputs to be validated with Zod schemas from src/shared/validation/schemas.ts. The offset parameter (and the existing limit parameter) should be validated using a schema rather than manual parsing. Additionally, parseInt should always include a radix to avoid ambiguity.

Suggested change
if (searchParams.get("offset")) filter.offset = parseInt(searchParams.get("offset"));
if (searchParams.get("offset")) filter.offset = parseInt(searchParams.get("offset"), 10);
References
  1. Always validate inputs with Zod schemas from src/shared/validation/schemas.ts. (link)


const logs = await getCallLogs(filter);
return NextResponse.json(logs);
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -6406,6 +6406,8 @@
"combo": "Combo",
"keys": "Keys",
"shown": "Shown",
"loadMore": "Load more",
"loadingMore": "Loading more...",
"sortNewest": "Newest",
"sortOldest": "Oldest",
"sortTokensDesc": "Tokens ↓",
Expand Down
7 changes: 5 additions & 2 deletions src/lib/usage/callLogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
61 changes: 58 additions & 3 deletions src/shared/components/RequestLoggerV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Fetching the entire window (from 0 to limit) every 3 seconds during live-tail polling is inefficient and will cause performance issues as the user loads more pages. For example, if the user has loaded 3,000 logs, the dashboard will fetch and process all 3,000 items every 3 seconds. Consider restricting the live-tail to only the first page or implementing a more efficient update mechanism that only fetches new logs.


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) {
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The loading state is not updated during pagination. When loadMore is called, it triggers fetchLogs(false) via the useEffect at line 209 (because hasLoadedRef.current is already true). This means the "Load more" button will never show the "Loading more..." state.

Note: To fix this completely, the finally block in fetchLogs (line 203) should also be updated to always call setLoading(false) regardless of the showLoading flag.

Suggested change
const loadMore = useCallback(() => {
setLimit((prev) => prev + PAGE_SIZE);
}, []);
const loadMore = useCallback(() => {
setLoading(true);
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();
},
Comment on lines +264 to +266

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate infinite-scroll increments while a page fetch is in flight

IntersectionObserver triggers loadMore() whenever the sentinel is visible, but this check uses !loading even though loading is only set during the very first fetch (showLoading=true). In normal load-more/polling paths loading stays false, so while the sentinel remains intersecting (e.g., at the bottom during scroll jitter), the callback can enqueue repeated setLimit(+300) updates and cause multiple oversized refetches, which degrades UI responsiveness and adds avoidable backend load.

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;
Expand Down Expand Up @@ -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 ? (
Expand Down Expand Up @@ -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>

Expand Down
119 changes: 119 additions & 0 deletions tests/unit/call-logs-pagination.test.ts
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);
});