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");
+});