mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 21:32:20 +03:00
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:
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
4
src/lib/db/migrations/181_proxy_logs_proxy_name.sql
Normal file
4
src/lib/db/migrations/181_proxy_logs_proxy_name.sql
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
82
tests/unit/migration-181-proxy-logs-proxy-name.test.ts
Normal file
82
tests/unit/migration-181-proxy-logs-proxy-name.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
91
tests/unit/proxy-logger-name.test.ts
Normal file
91
tests/unit/proxy-logger-name.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user