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
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions src/lib/db/migrationRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 4 additions & 0 deletions src/lib/db/migrations/181_proxy_logs_proxy_name.sql
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions src/lib/db/schemaColumns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
16 changes: 12 additions & 4 deletions src/lib/proxyLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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}`
);
}

Expand Down Expand Up @@ -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(),
})
Expand Down Expand Up @@ -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)`
);
Expand All @@ -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,
Expand Down Expand Up @@ -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) ||
Expand Down
15 changes: 12 additions & 3 deletions src/shared/components/ProxyLogDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -109,9 +120,7 @@ export default function ProxyLogDetail({ log, onClose }) {
{t("proxy")}
</div>
<div className="text-sm font-medium font-mono text-primary">
{log.proxy
? `${log.proxy.type}://${log.proxy.host}:${log.proxy.port}`
: t("direct")}
{formatProxyLabel(log.proxy, t("direct"))}
</div>
</div>
<div>
Expand Down
4 changes: 3 additions & 1 deletion src/shared/components/ProxyLogger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,9 @@ export default function ProxyLogger() {
)}
{visibleColumns.proxy && (
<td className="px-3 py-2 font-mono text-[11px] text-primary">
{log.proxy ? `${log.proxy.host}:${log.proxy.port}` : "—"}
{log.proxy
? log.proxy.name || `${log.proxy.host}:${log.proxy.port}`
: "—"}
</td>
)}
{visibleColumns.tls && (
Expand Down
82 changes: 82 additions & 0 deletions tests/unit/migration-181-proxy-logs-proxy-name.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
91 changes: 91 additions & 0 deletions tests/unit/proxy-logger-name.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Loading