feat(proxylogs): show registry proxy name in proxy log columns (#12814)

* feat(proxylogs): show registry proxy name in proxy log columns

Registry resolution already attaches name to the runtime proxy object; the
name was dropped at the persistence boundary (ProxyInfo had no name field)
and never rendered. Add proxy_name column (base schema + ALTER heal for
existing DBs), persist/hydrate it, render it in the ProxyLogger table and
ProxyLogDetail pane with host:port fallback, and search by name.

Local-only (PMO City): not submitted upstream. Re-apply after upgrades via
patch file (see pmo-city-builds omniroute/Operator/runbooks/upgrade.md).

* test(proxylogs): flush batched writes before asserting persisted row

The v3.8.50 rebase kept upstream's batched proxy-log persistence
(enqueueProxyLogs/flushProxyLogsSync); logProxyEvent no longer writes
synchronously, so the persist+hydrate test closed the DB before the row
was flushed. Flush explicitly first.

* test(proxylogs): drain batched queue in resetStorage to avoid cross-test row bleed

* docs(changelog): add changelog fragment for proxy registry name in proxy logs

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Tiangao (hermes) <montigaud@aikumi.pro>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Tiangao
2026-09-18 18:14:14 +02:00
committed by GitHub
parent 95b2e53727
commit fda9ef78b1
9 changed files with 214 additions and 8 deletions

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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) ||

View File

@@ -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")}
</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>

View File

@@ -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 && (

View File

@@ -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();
}
});

View File

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