mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 14:42:20 +03:00
Compare commits
1 Commits
fix/10597-
...
fix/8450-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a30c28abb6 |
1
changelog.d/features/10897-home-recent-requests.md
Normal file
1
changelog.d/features/10897-home-recent-requests.md
Normal file
@@ -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
|
||||
@@ -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 && (
|
||||
<HomeProviderTopologySection
|
||||
providers={topologyProviders}
|
||||
lastProvider={lastProvider}
|
||||
errorProvider={errorProvider}
|
||||
enabled={showProviderTopologyOnHome}
|
||||
/>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-3">
|
||||
<HomeProviderTopologySection
|
||||
providers={topologyProviders}
|
||||
lastProvider={lastProvider}
|
||||
errorProvider={errorProvider}
|
||||
enabled={showProviderTopologyOnHome}
|
||||
/>
|
||||
<HomeRecentRequests enabled={showProviderTopologyOnHome} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider Models Modal */}
|
||||
|
||||
207
src/app/(dashboard)/home/HomeRecentRequests.tsx
Normal file
207
src/app/(dashboard)/home/HomeRecentRequests.tsx
Normal file
@@ -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<RequestState, string> = {
|
||||
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<CallLogRow[]>([]);
|
||||
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<typeof setTimeout> | 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 (
|
||||
<Card padding="sm" className="flex min-w-0 flex-col overflow-hidden h-[300px] sm:h-[420px]">
|
||||
<div className="pb-2 mb-1 border-b border-border shrink-0">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-text-muted">
|
||||
{t("recentRequests")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loaded && rows.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center text-sm text-text-muted">
|
||||
{t("recentRequestsEmpty")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto -mx-1 px-1">
|
||||
<table className="w-full min-w-0 border-collapse text-xs">
|
||||
<thead className="sticky top-0 z-10 bg-surface">
|
||||
<tr className="border-b border-border text-text-muted">
|
||||
<th className="w-2 py-1.5" />
|
||||
<th className="py-1.5 text-left font-semibold">{t("recentRequestsModel")}</th>
|
||||
<th className="py-1.5 text-right font-semibold whitespace-nowrap">
|
||||
{t("recentRequestsTokens")}
|
||||
</th>
|
||||
<th className="py-1.5 text-right font-semibold">{t("recentRequestsWhen")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{rows.map((row, i) => {
|
||||
const state = requestState(row);
|
||||
return (
|
||||
<tr key={row.id || i} className="hover:bg-bg-subtle transition-colors">
|
||||
<td className="py-1.5">
|
||||
<span className={`block size-1.5 rounded-full ${STATE_DOT[state]}`} />
|
||||
</td>
|
||||
<td
|
||||
className="py-1.5 font-mono truncate max-w-[140px]"
|
||||
title={row.model || ""}
|
||||
>
|
||||
{row.model || "—"}
|
||||
</td>
|
||||
<td className="py-1.5 text-right whitespace-nowrap">
|
||||
<span className="text-primary">{fmtCompact(row.tokens?.in)}↑</span>{" "}
|
||||
<span className="text-green-500">{fmtCompact(row.tokens?.out)}↓</span>
|
||||
</td>
|
||||
<td className="py-1.5 text-right whitespace-nowrap text-text-muted">
|
||||
{state === "active" ? (
|
||||
<span className="text-primary">•••</span>
|
||||
) : (
|
||||
timeAgo(row.timestamp, nowMs)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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);
|
||||
|
||||
132
tests/unit/call-logs-exclude-tests-allowlist.test.ts
Normal file
132
tests/unit/call-logs-exclude-tests-allowlist.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user