From a30c28abb6b99d4f8864605883782fe7f0b69d33 Mon Sep 17 00:00:00 2001 From: nguyenha935 <208228297+nguyenha935@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:25:23 -0300 Subject: [PATCH] feat(home): add Recent Requests panel + excludeTests allowlist fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reimplementation of the non-conflicting value from #8450: a live Recent Requests panel beside the home Provider Topology (polls GET /api/usage/call-logs every ~3s, gated by the topology appearance toggle + page visibility), plus the excludeTests allowlist fix so connection-test/model-sync/management rows can never leak into the feed (only /v1/* and /api/v1/* real-inference rows survive, applied before LIMIT at the SQL layer). Deliberately excludes #8450's calm-at-rest topology UX rework (logo-only router core, KameBeamEdge, no persistent glow on healthy-idle nodes), which contradicts the already-shipped #8428 ("decouple healthy and last-routed states", merged 2026-07-25) — a product decision outside this extraction's scope. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .../features/10897-home-recent-requests.md | 1 + .../(dashboard)/dashboard/HomePageClient.tsx | 16 +- .../(dashboard)/home/HomeRecentRequests.tsx | 207 ++++++++++++++++++ src/app/api/usage/call-logs/route.ts | 3 + src/i18n/messages/en.json | 5 + src/lib/usage/callLogs.ts | 8 + .../call-logs-exclude-tests-allowlist.test.ts | 132 +++++++++++ 7 files changed, 366 insertions(+), 6 deletions(-) create mode 100644 changelog.d/features/10897-home-recent-requests.md create mode 100644 src/app/(dashboard)/home/HomeRecentRequests.tsx create mode 100644 tests/unit/call-logs-exclude-tests-allowlist.test.ts diff --git a/changelog.d/features/10897-home-recent-requests.md b/changelog.d/features/10897-home-recent-requests.md new file mode 100644 index 0000000000..fd6bcc9abe --- /dev/null +++ b/changelog.d/features/10897-home-recent-requests.md @@ -0,0 +1 @@ +- **feat(home):** add a live **Recent Requests** panel beside the home Provider Topology (polls `GET /api/usage/call-logs?excludeTests=1` every ~3s, gated by the topology appearance toggle + page visibility). `excludeTests` is now an allowlist of real provider inference (`/v1/%` or `/api/v1/%`), applied before `LIMIT`, so connection-test/model-sync/management rows can never leak into the feed ([#10897](https://github.com/diegosouzapw/OmniRoute/pull/10897), extracted from [#8450](https://github.com/diegosouzapw/OmniRoute/pull/8450)) — thanks @nguyenha935 diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index d427f4fcc4..897445fd0c 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -19,6 +19,7 @@ import { getProviderDisplayLabel } from "@/shared/utils/providerDisplayLabel"; import { useIsElectron, useOpenExternal } from "@/shared/hooks/useElectron"; import { HomeProviderTopologySection } from "./HomeProviderTopologySection"; import { shouldShowProviderTopologyOnHome } from "./homeAppearance"; +import HomeRecentRequests from "../home/HomeRecentRequests"; type UpdateStep = { step: string; @@ -1126,12 +1127,15 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { )} {showProviderTopologyOnHome && ( - +
+ + +
)} {/* Provider Models Modal */} diff --git a/src/app/(dashboard)/home/HomeRecentRequests.tsx b/src/app/(dashboard)/home/HomeRecentRequests.tsx new file mode 100644 index 0000000000..abe6f57ede --- /dev/null +++ b/src/app/(dashboard)/home/HomeRecentRequests.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Card } from "@/shared/components"; +import { fmtCompact } from "@/shared/utils/formatting"; + +/** + * Home-page "Recent Requests" panel — the live request feed that sits beside the + * Provider Topology graph (parity with 9Router's Usage view). + * + * ## Data source + * Fed by POLLING `GET /api/usage/call-logs?limit=N` every ~3s, NOT by the live + * WebSocket. The WS is used elsewhere for the in-flight beam (it emits + * `request.started` and, since the stuck-latch fix, `request.completed`/`request.failed`), + * but its payload carries no tokens/latency/status and no persisted history — so it + * can't back a "recent requests" table on its own. The call-logs endpoint merges the + * in-memory active/completed entries with persisted rows and returns them newest-first + * (active on top), which is exactly this feed. + * + * The poll is gated by `enabled` (the same `showProviderTopologyOnHome` flag that + * gates the topology section) AND page visibility, so a backgrounded tab pauses. + */ + +const POLL_INTERVAL_MS = 3000; +// Rows shown after client-side filtering of connection-test rows. +const RECENT_LIMIT = 20; +// Fetch a wider window than we display so filtering out connection-test rows +// (a burst of "Test connection" clicks) can't starve the feed below RECENT_LIMIT. +const FETCH_LIMIT = 60; + +type CallLogRow = { + id?: string; + timestamp?: string; + status?: number; + model?: string; + provider?: string; + providerDisplay?: string | null; + path?: string; + sourceFormat?: string | null; + targetFormat?: string | null; + tokens?: { in?: number; out?: number }; + error?: string | null; + active?: boolean; + completed?: boolean; +}; + +/** + * Connection tests write real `call_logs` rows (provider "Test connection" button → + * `/api/providers/[id]/test`) with fixed markers: model `connection-test`, path + * `/api/providers/test`, sourceFormat/targetFormat `test`. Those are health probes, + * not user traffic, so they must not clutter the Recent Requests feed (matching how + * 9Router keeps its Usage list to real calls). Drop any row carrying a test marker. + */ +function isConnectionTestRow(row: CallLogRow): boolean { + return ( + row.model === "connection-test" || + row.sourceFormat === "test" || + row.targetFormat === "test" || + row.path === "/api/providers/test" + ); +} + +type RequestState = "active" | "error" | "ok"; + +function requestState(row: CallLogRow): RequestState { + if (row.active || row.status === 0) return "active"; + if (row.error || (typeof row.status === "number" && row.status >= 400)) return "error"; + return "ok"; +} + +function timeAgo(timestamp: string | undefined, nowMs: number): string { + if (!timestamp) return ""; + const then = Date.parse(timestamp); + if (!Number.isFinite(then)) return ""; + const diff = Math.max(0, Math.floor((nowMs - then) / 1000)); + if (diff < 60) return `${diff}s`; + if (diff < 3600) return `${Math.floor(diff / 60)}m`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h`; + return `${Math.floor(diff / 86400)}d`; +} + +const STATE_DOT: Record = { + active: "bg-primary animate-pulse", + error: "bg-red-500", + ok: "bg-green-500", +}; + +export default function HomeRecentRequests({ enabled = true }: { enabled?: boolean }) { + const t = useTranslations("home"); + const [rows, setRows] = useState([]); + const [loaded, setLoaded] = useState(false); + // A ticking clock so the relative "When" column updates without re-fetching. + const [nowMs, setNowMs] = useState(() => Date.now()); + + useEffect(() => { + if (!enabled) return; + const id = setInterval(() => setNowMs(Date.now()), 1000); + return () => clearInterval(id); + }, [enabled]); + + const load = useCallback(async (signal: AbortSignal) => { + try { + const res = await fetch(`/api/usage/call-logs?limit=${FETCH_LIMIT}&excludeTests=1`, { + cache: "no-store", + signal, + }); + if (!res.ok) return; + const data = await res.json(); + if (signal.aborted) return; + const filtered = Array.isArray(data) + ? (data as CallLogRow[]).filter((row) => !isConnectionTestRow(row)).slice(0, RECENT_LIMIT) + : []; + setRows(filtered); + setLoaded(true); + } catch (error) { + const isAbort = error instanceof DOMException && error.name === "AbortError"; + if (!isAbort) console.error("Failed to load recent requests:", error); + } + }, []); + + useEffect(() => { + if (!enabled) return; + + let cancelled = false; + let timeoutId: ReturnType | null = null; + let controller: AbortController | null = null; + + const tick = async () => { + // Pause polling while the tab is backgrounded; resume on next tick. + if (document.visibilityState === "visible") { + const currentController = new AbortController(); + controller = currentController; + await load(currentController.signal); + if (controller === currentController) controller = null; + } + if (!cancelled) timeoutId = setTimeout(tick, POLL_INTERVAL_MS); + }; + + tick(); + return () => { + cancelled = true; + if (timeoutId) clearTimeout(timeoutId); + controller?.abort(); + }; + }, [enabled, load]); + + return ( + +
+ + {t("recentRequests")} + +
+ + {loaded && rows.length === 0 ? ( +
+ {t("recentRequestsEmpty")} +
+ ) : ( +
+ + + + + + + + + + {rows.map((row, i) => { + const state = requestState(row); + return ( + + + + + + + ); + })} + +
+ {t("recentRequestsModel")} + {t("recentRequestsTokens")} + {t("recentRequestsWhen")}
+ + + {row.model || "—"} + + {fmtCompact(row.tokens?.in)}↑{" "} + {fmtCompact(row.tokens?.out)}↓ + + {state === "active" ? ( + ••• + ) : ( + timeAgo(row.timestamp, nowMs) + )} +
+
+ )} +
+ ); +} diff --git a/src/app/api/usage/call-logs/route.ts b/src/app/api/usage/call-logs/route.ts index 3b3b8ddcc0..0a737ea1f9 100644 --- a/src/app/api/usage/call-logs/route.ts +++ b/src/app/api/usage/call-logs/route.ts @@ -143,6 +143,9 @@ export async function GET(request: Request) { if (searchParams.get("correlationId")) filter.correlationId = searchParams.get("correlationId"); if (searchParams.get("limit")) filter.limit = parseInt(searchParams.get("limit")); if (searchParams.get("offset")) filter.offset = parseInt(searchParams.get("offset")); + // Home Recent Requests feed sets excludeTests=1 so connection-test probe rows + // are dropped at the SQL layer (before LIMIT), not client-side after slicing. + if (searchParams.get("excludeTests") === "1") filter.excludeTests = true; const [logs, connections, providerNodes] = await Promise.all([ getCallLogs(filter), diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 8147dfcb65..bdccf17e23 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1800,6 +1800,11 @@ "updateStarted": "Update started...", "reloadingPageAutomatically": "Reloading page automatically...", "providerTopology": "Provider Topology", + "recentRequests": "Recent Requests", + "recentRequestsEmpty": "No requests yet.", + "recentRequestsModel": "Model", + "recentRequestsTokens": "In / Out", + "recentRequestsWhen": "When", "downloadDmg": "Download DMG (macOS)", "downloadDmgDescription": "A new version of the OmniRoute desktop app is available. Please download and install the macOS DMG installer to update (current: v{version}).", "downloadExe": "Download EXE (Windows)", diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index cea52ba196..5e3c878c0c 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -700,6 +700,14 @@ export async function getCallLogs(filter: any = {}) { if (filter.combo) { conditions.push("cl.combo_name IS NOT NULL"); } + if (filter.excludeTests) { + // Home "Recent Requests" is an allowlist of real provider inference, not a + // blacklist of known backend log types. Persisted provider requests enter via + // the public gateway namespaces (/v1/* or /api/v1/*); internal management work + // (connection tests, model sync, and future /api/providers/* jobs) does not. + // Apply this before LIMIT so backend rows can never displace real traffic. + conditions.push(`(cl.path LIKE '/v1/%' OR cl.path LIKE '/api/v1/%')`); + } if (filter.since) { conditions.push("cl.timestamp >= @since"); params.since = filter.since instanceof Date ? filter.since.toISOString() : String(filter.since); diff --git a/tests/unit/call-logs-exclude-tests-allowlist.test.ts b/tests/unit/call-logs-exclude-tests-allowlist.test.ts new file mode 100644 index 0000000000..78aa5728af --- /dev/null +++ b/tests/unit/call-logs-exclude-tests-allowlist.test.ts @@ -0,0 +1,132 @@ +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"; + +/** + * Home "Recent Requests" feed passes `excludeTests` to getCallLogs so the panel shows + * ONLY real provider inference — never backend/management log rows. The filter is an + * ALLOWLIST of the public gateway namespaces (`/v1/%` and `/api/v1/%`), applied before + * LIMIT, rather than a blacklist of individual known noise types. This guards against + * the reported regression where model-sync rows (request_type 'model-sync', path + * `/api/providers/*`) leaked into the feed because the old blacklist only dropped + * connection-test rows. + */ + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-calllogs-allowlist-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.CALL_LOG_RETENTION_DAYS = "3650"; + +const core = await import("../../src/lib/db/core.ts"); +const callLogs = await import("../../src/lib/usage/callLogs.ts"); + +type SeedRow = { + id: string; + timestamp: string; + path: string; + model: string; + provider: string; + source_format?: string; + request_type?: string | null; +}; + +function insertCallLog(row: SeedRow) { + const db = core.getDbInstance(); + db.prepare( + ` + INSERT INTO call_logs ( + id, timestamp, method, path, status, model, provider, source_format, request_type, detail_state + ) + VALUES ( + @id, @timestamp, 'POST', @path, 200, @model, @provider, @source_format, @request_type, 'none' + ) + ` + ).run({ + source_format: row.source_format ?? null, + request_type: row.request_type ?? null, + ...row, + }); +} + +test.beforeEach(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("excludeTests keeps only /v1 and /api/v1 inference rows, drops all backend/management rows", async () => { + const base = Date.parse("2026-01-01T00:00:00.000Z"); + const iso = (i: number) => new Date(base + i * 1000).toISOString(); + + // Two REAL provider inference rows — the only rows the feed should keep. + insertCallLog({ + id: "real-v1", + timestamp: iso(4), + path: "/v1/chat/completions", + model: "openai/gpt-4.1", + provider: "openai", + }); + insertCallLog({ + id: "real-api-v1", + timestamp: iso(3), + path: "/api/v1/chat/completions", + model: "anthropic/claude-opus-4-8", + provider: "anthropic", + }); + + // Backend/management NOISE — must never appear in the feed. + insertCallLog({ + id: "noise-model-sync", + timestamp: iso(2), + path: "/api/providers/openai/models", + model: "model-sync", + provider: "openai", + source_format: "-", + request_type: "model-sync", + }); + insertCallLog({ + id: "noise-connection-test", + timestamp: iso(1), + path: "/api/providers/test", + model: "connection-test", + provider: "openai", + source_format: "test", + }); + + const rows = await callLogs.getCallLogs({ excludeTests: true, limit: 50 }); + const ids = rows.map((row) => row.id).sort(); + + assert.deepEqual( + ids, + ["real-api-v1", "real-v1"], + "only the /v1 and /api/v1 inference rows survive the allowlist" + ); +}); + +test("without excludeTests every row is returned (allowlist is opt-in)", async () => { + const base = Date.parse("2026-02-01T00:00:00.000Z"); + insertCallLog({ + id: "real", + timestamp: new Date(base).toISOString(), + path: "/v1/chat/completions", + model: "openai/gpt-4.1", + provider: "openai", + }); + insertCallLog({ + id: "sync", + timestamp: new Date(base + 1000).toISOString(), + path: "/api/providers/openai/models", + model: "model-sync", + provider: "openai", + request_type: "model-sync", + }); + + const rows = await callLogs.getCallLogs({ limit: 50 }); + assert.equal(rows.length, 2, "no allowlist → backend rows are not filtered out"); +});