diff --git a/changelog.d/features/12814-proxy-registry-name-in-logs.md b/changelog.d/features/12814-proxy-registry-name-in-logs.md new file mode 100644 index 0000000000..6d611d7ee2 --- /dev/null +++ b/changelog.d/features/12814-proxy-registry-name-in-logs.md @@ -0,0 +1 @@ +- **feat(proxylogs):** proxy log columns and detail pane now show the registry proxy name instead of a bare `host:port` when several registry entries share the same gateway ([#12814](https://github.com/diegosouzapw/OmniRoute/pull/12814)) — thanks @tiangao88 diff --git a/src/lib/db/migrationRunner.ts b/src/lib/db/migrationRunner.ts index 6c38f6cc37..43ede79b7c 100644 --- a/src/lib/db/migrationRunner.ts +++ b/src/lib/db/migrationRunner.ts @@ -598,6 +598,11 @@ function isSchemaAlreadyApplied( // a bare ADD COLUMN would then throw. Renumbering the migration means renaming this case // (keyed by version only: a stale "177" here would skip 177_provider_connection_synced_models_at). return hasColumn(db, "proxy_logs", "upstream_status"); + case "181": + // Same shape as 179: ensureProxyLogsColumns may have added proxy_name at boot, so the + // bare ADD COLUMN would throw. Keyed by version — a stale number here would answer for + // another migration's schema and skip it. + return hasColumn(db, "proxy_logs", "proxy_name"); default: return false; } diff --git a/src/lib/db/migrations/181_proxy_logs_proxy_name.sql b/src/lib/db/migrations/181_proxy_logs_proxy_name.sql new file mode 100644 index 0000000000..96d9237670 --- /dev/null +++ b/src/lib/db/migrations/181_proxy_logs_proxy_name.sql @@ -0,0 +1,4 @@ +-- proxy_name: registry name of the proxy that served the logged request, so the +-- Proxy Logs page can tell entries apart when several registry entries share one +-- gateway (host:port). NULL for direct/legacy rows. No index: not a query dimension. +ALTER TABLE proxy_logs ADD COLUMN proxy_name TEXT; diff --git a/src/lib/db/schemaColumns.ts b/src/lib/db/schemaColumns.ts index f422d64270..6c14b3b93c 100644 --- a/src/lib/db/schemaColumns.ts +++ b/src/lib/db/schemaColumns.ts @@ -296,6 +296,10 @@ export function ensureProxyLogsColumns(db: SqliteDatabase) { db.exec("ALTER TABLE proxy_logs ADD COLUMN upstream_status INTEGER"); console.log("[DB] Added proxy_logs.upstream_status column"); } + if (!columnNames.has("proxy_name")) { + db.exec("ALTER TABLE proxy_logs ADD COLUMN proxy_name TEXT"); + console.log("[DB] Added proxy_logs.proxy_name column"); + } } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); console.warn("[DB] Failed to verify proxy_logs schema:", message); diff --git a/src/lib/proxyLogger.ts b/src/lib/proxyLogger.ts index 21786c839e..053e1b71da 100644 --- a/src/lib/proxyLogger.ts +++ b/src/lib/proxyLogger.ts @@ -19,6 +19,9 @@ interface ProxyInfo { type: string; host: string; port: number | string; + /** Registry name (e.g. `murphy-eu-fr`) — carried by registry resolution so the + * proxy log can identify a leg even when many entries share host:port. */ + name?: string; } interface ProxyLogEntry { @@ -81,7 +84,7 @@ function loadFromDb() { timestamp: row.timestamp, status: row.status || "success", proxy: row.proxy_host - ? { type: row.proxy_type, host: row.proxy_host, port: row.proxy_port } + ? { type: row.proxy_type, host: row.proxy_host, port: row.proxy_port, name: row.proxy_name || undefined } : null, level: row.level || "direct", levelId: row.level_id || null, @@ -137,6 +140,7 @@ export function formatProxyEgressConsoleLine(params: { egressIp: string | null; level: string; proxyHost: string | null | undefined; + proxyName?: string | null | undefined; status: string; includeDetails?: boolean; }): string { @@ -146,10 +150,11 @@ export function formatProxyEgressConsoleLine(params: { return `[ProxyEgress] ${provider} status=${status}`; } const proxy = params.proxyHost ? `:${params.proxyHost}` : ""; + const name = params.proxyName ? ` name=${params.proxyName}` : ""; return ( `[ProxyEgress] ${provider}/${params.account || "-"} ` + `in=${params.clientIp || "?"} out=${params.egressIp || "?"} ` + - `proxy=${params.level}${proxy} status=${status}` + `proxy=${params.level}${proxy}${name} status=${status}` ); } @@ -191,6 +196,7 @@ export function logProxyEvent(entry: ProxyLogInput) { egressIp: log.egressIp, level: log.level, proxyHost: log.proxy?.host, + proxyName: log.proxy?.name, status: log.status, includeDetails: isProxyLogIncludeIps(), }) @@ -265,10 +271,10 @@ export function flushProxyLogsSync() { try { const db = getDbInstance(); const insertStmt = db.prepare( - `INSERT INTO proxy_logs (id, timestamp, status, proxy_type, proxy_host, proxy_port, + `INSERT INTO proxy_logs (id, timestamp, status, proxy_type, proxy_host, proxy_port, proxy_name, level, level_id, provider, target_url, public_ip, egress_ip, latency_ms, error, connection_id, combo_id, account, tls_fingerprint, upstream_status) - VALUES (@id, @timestamp, @status, @proxyType, @proxyHost, @proxyPort, + VALUES (@id, @timestamp, @status, @proxyType, @proxyHost, @proxyPort, @proxyName, @level, @levelId, @provider, @targetUrl, @clientIp, @egressIp, @latencyMs, @error, @connectionId, @comboId, @account, @tlsFingerprint, @upstreamStatus)` ); @@ -282,6 +288,7 @@ export function flushProxyLogsSync() { proxyType: item.proxy?.type || null, proxyHost: item.proxy?.host || null, proxyPort: item.proxy?.port ? Number(item.proxy.port) : null, + proxyName: item.proxy?.name || null, level: item.level, levelId: item.levelId, provider: item.provider, @@ -342,6 +349,7 @@ export function getProxyLogs(filters: ProxyLogFilters = {}) { logs = logs.filter( (l) => (l.proxy?.host || "").toLowerCase().includes(q) || + (l.proxy?.name || "").toLowerCase().includes(q) || (l.provider || "").toLowerCase().includes(q) || (l.targetUrl || "").toLowerCase().includes(q) || (l.clientIp || "").toLowerCase().includes(q) || diff --git a/src/shared/components/ProxyLogDetail.tsx b/src/shared/components/ProxyLogDetail.tsx index c0ca15649a..ee1679eb9d 100644 --- a/src/shared/components/ProxyLogDetail.tsx +++ b/src/shared/components/ProxyLogDetail.tsx @@ -14,6 +14,17 @@ import { formatDuration as formatLatency } from "@/shared/utils/formatting"; * Proxy log detail modal — shows full proxy event metadata, error info, and config. * Extracted from ProxyLogger.js for maintainability. */ +/** + * Proxy label for the detail pane: the registry name when the log carries one + * (`murphy-eu-fr (http://host:port)`), else `type://host:port`, else the direct label. + * Module-scope so the component's cyclomatic complexity stays inside the ratchet. + */ +function formatProxyLabel(proxy, directLabel) { + if (!proxy) return directLabel; + const endpoint = `${proxy.type}://${proxy.host}:${proxy.port}`; + return proxy.name ? `${proxy.name} (${endpoint})` : endpoint; +} + export default function ProxyLogDetail({ log, onClose }) { const t = useTranslations("proxyLog"); useEffect(() => { @@ -109,9 +120,7 @@ export default function ProxyLogDetail({ log, onClose }) { {t("proxy")}
- {log.proxy - ? `${log.proxy.type}://${log.proxy.host}:${log.proxy.port}` - : t("direct")} + {formatProxyLabel(log.proxy, t("direct"))}
diff --git a/src/shared/components/ProxyLogger.tsx b/src/shared/components/ProxyLogger.tsx index 32f6d7caea..d187427477 100644 --- a/src/shared/components/ProxyLogger.tsx +++ b/src/shared/components/ProxyLogger.tsx @@ -476,7 +476,9 @@ export default function ProxyLogger() { )} {visibleColumns.proxy && ( - {log.proxy ? `${log.proxy.host}:${log.proxy.port}` : "—"} + {log.proxy + ? log.proxy.name || `${log.proxy.host}:${log.proxy.port}` + : "—"} )} {visibleColumns.tls && ( diff --git a/tests/unit/migration-181-proxy-logs-proxy-name.test.ts b/tests/unit/migration-181-proxy-logs-proxy-name.test.ts new file mode 100644 index 0000000000..2093436c04 --- /dev/null +++ b/tests/unit/migration-181-proxy-logs-proxy-name.test.ts @@ -0,0 +1,82 @@ +// proxy_logs.proxy_name ships as migration 181, with its idempotency check keyed by version in +// migrationRunner's switch. The dangerous shape is cross-talk: if 181's check were registered +// under a neighbouring version, it would answer for that migration's schema and skip it on any +// database where ensureProxyLogsColumns had already added the column at boot. Runs the real +// runner against the real SQL files. +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"; +import { fileURLToPath } from "node:url"; +import Database from "better-sqlite3"; + +const repoMigrations = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "../../src/lib/db/migrations" +); +const migrationsDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-migration-181-")); +for (const file of [ + "179_proxy_logs_upstream_status.sql", + "181_proxy_logs_proxy_name.sql", +]) { + fs.copyFileSync(path.join(repoMigrations, file), path.join(migrationsDir, file)); +} +const originalMigrationsDir = process.env.OMNIROUTE_MIGRATIONS_DIR; +process.env.OMNIROUTE_MIGRATIONS_DIR = migrationsDir; + +const { runMigrations } = await import("../../src/lib/db/migrationRunner.ts"); + +test.after(() => { + fs.rmSync(migrationsDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + if (originalMigrationsDir === undefined) delete process.env.OMNIROUTE_MIGRATIONS_DIR; + else process.env.OMNIROUTE_MIGRATIONS_DIR = originalMigrationsDir; +}); + +function columns(db: Database.Database, table: string): string[] { + return (db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).map( + (c) => c.name + ); +} + +function ledger(db: Database.Database) { + return db.prepare("SELECT version, name FROM _omniroute_migrations ORDER BY version").all(); +} + +function legacyDb(withProxyName: boolean): Database.Database { + const db = new Database(":memory:"); + db.exec( + `CREATE TABLE proxy_logs (id TEXT PRIMARY KEY${ + withProxyName ? ", proxy_name TEXT" : "" + });` + ); + return db; +} + +test("proxy_name already added at boot: 179 still runs, 181 is recorded without re-adding", () => { + const db = legacyDb(true); + try { + runMigrations(db, { isNewDb: true }); + assert.ok( + columns(db, "proxy_logs").includes("upstream_status"), + "179_proxy_logs_upstream_status must not be skipped by the 181 idempotency check" + ); + assert.deepEqual(ledger(db), [ + { version: "179", name: "proxy_logs_upstream_status" }, + { version: "181", name: "proxy_logs_proxy_name" }, + ]); + } finally { + db.close(); + } +}); + +test("a database without the column gets proxy_name from migration 181", () => { + const db = legacyDb(false); + try { + runMigrations(db, { isNewDb: true }); + assert.ok(columns(db, "proxy_logs").includes("proxy_name")); + assert.ok(columns(db, "proxy_logs").includes("upstream_status")); + } finally { + db.close(); + } +}); diff --git a/tests/unit/proxy-logger-name.test.ts b/tests/unit/proxy-logger-name.test.ts new file mode 100644 index 0000000000..ce5af753ca --- /dev/null +++ b/tests/unit/proxy-logger-name.test.ts @@ -0,0 +1,91 @@ +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"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-logger-name-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const proxyLogger = await import("../../src/lib/proxyLogger.ts"); + +function resetStorage() { + // Batched persistence: logProxyEvent() enqueues and flushes on a timer, so a + // queued entry from a previous test would otherwise be written into the fresh + // DB after the reset. Drain the queue (and stop the timer) before clearing. + proxyLogger.flushProxyLogsSync(); + proxyLogger.clearProxyLogs(); + core.closeDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(() => { + resetStorage(); +}); + +test.after(() => { + resetStorage(); +}); + +test("proxy logs carry the registry name on the proxy entry", () => { + proxyLogger.logProxyEvent({ + status: "success", + provider: "nous-research", + targetUrl: "nous/deepseek/deepseek-v4-flash-0731", + level: "provider", + levelId: "nous-research", + proxy: { type: "http", host: "gw-eu.murphyproxies.com", port: 7777, name: "murphy-eu-fr" }, + }); + + const [log] = proxyLogger.getProxyLogs(); + assert.equal(log.proxy.name, "murphy-eu-fr"); + assert.equal(log.proxy.host, "gw-eu.murphyproxies.com"); +}); + +test("registry name survives SQLite persist + hydrate", () => { + proxyLogger.logProxyEvent({ + status: "success", + provider: "nous-research", + proxy: { type: "http", host: "gw-eu.murphyproxies.com", port: 7777, name: "murphy-eu-de" }, + }); + + // logProxyEvent() enqueues writes on the batched persistence path; force the + // flush to SQLite before inspecting the raw row. + proxyLogger.flushProxyLogsSync(); + core.closeDbInstance(); + const db = core.getDbInstance(); + const rows = db.prepare("SELECT proxy_name, proxy_host FROM proxy_logs").all(); + assert.equal(rows.length, 1); + assert.equal(rows[0].proxy_name, "murphy-eu-de"); + assert.equal(rows[0].proxy_host, "gw-eu.murphyproxies.com"); +}); + +test("search matches the registry name", () => { + proxyLogger.logProxyEvent({ + status: "success", + provider: "nous-research", + proxy: { type: "http", host: "gw-eu.murphyproxies.com", port: 7777, name: "murphy-eu-fr" }, + }); + proxyLogger.logProxyEvent({ + status: "success", + provider: "openrouter", + }); + + const hits = proxyLogger.getProxyLogs({ search: "murphy-eu-fr" }); + assert.equal(hits.length, 1); + assert.equal(hits[0].proxy.name, "murphy-eu-fr"); +}); + +test("legacy rows without a name hydrate cleanly (host:port only)", () => { + proxyLogger.logProxyEvent({ + status: "success", + provider: "openrouter", + proxy: { type: "http", host: "203.0.113.50", port: 8080 }, + }); + + const [log] = proxyLogger.getProxyLogs(); + assert.equal(log.proxy.name, undefined); + assert.equal(log.proxy.host, "203.0.113.50"); +});