Files
OmniRoute/src/lib/proxyPoolEgressObservation.ts
Dizzle ca312d57aa feat(proxies): show how many egress IPs actually served a proxy pool (#13581)
Behind the new `PROXY_POOL_EGRESS_OBSERVATION` flag (default off): a line under each proxy pool showing how many distinct egress IPs actually served it over 24h, backed by `GET /api/settings/proxies/pool/egress-observation`.

Maintainer rework before merge (kept the idea, no default behavior change):
- The route validates its query with Zod (unknown `scope` → 400 instead of silently `global`), error bodies go through `errorResponse()`, the OpenAPI entry documents security, parameters and responses, and the three UI strings exist in every locale.

Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests.

Thanks @maxmad64bis!
2026-09-15 19:48:52 -03:00

61 lines
2.2 KiB
TypeScript

/**
* Observed egress spread of a proxy pool, for the dashboard pool editor. Read-only and
* never on the routing path: a failure returns null so it cannot break the pool screen.
* Opt-in through the PROXY_POOL_EGRESS_OBSERVATION feature flag (default off: null, the
* line stays hidden). The scope is normalized exactly like the pool read (key -> account,
* global -> "__global__"), and a result is cached for 30 seconds per normalized scope.
*/
import {
EGRESS_IP_LOOKUP_WINDOW_MS,
getPoolEgressObservation,
type PoolEgressObservationCounts,
} from "@/lib/db/proxyLogs";
import { normalizeAssignmentScopeId, normalizeScope } from "@/lib/db/proxies/mappers";
import { flushProxyLogsSync } from "@/lib/proxyLogger";
import { isPoolEgressObservationEnabled } from "@/shared/utils/featureFlags";
export type PoolEgressObservation = PoolEgressObservationCounts & { windowHours: number };
const CACHE_TTL_MS = 30_000;
const CACHE_MAX_ENTRIES = 200;
const cache = new Map<string, { at: number; value: PoolEgressObservation }>();
export function readPoolEgressObservation(
scope: string,
scopeId: string | null,
nowMs: number = Date.now()
): PoolEgressObservation | null {
if (!isPoolEgressObservationEnabled()) return null;
const normalizedScope = normalizeScope(scope);
const normalizedScopeId = normalizeAssignmentScopeId(normalizedScope, scopeId);
const key = `${normalizedScope}:${normalizedScopeId ?? ""}`;
const hit = cache.get(key);
if (hit && nowMs - hit.at < CACHE_TTL_MS) return hit.value;
let value: PoolEgressObservation;
try {
flushProxyLogsSync();
const since = new Date(nowMs - EGRESS_IP_LOOKUP_WINDOW_MS).toISOString();
value = {
...getPoolEgressObservation(normalizedScope, normalizedScopeId, since),
windowHours: EGRESS_IP_LOOKUP_WINDOW_MS / (60 * 60 * 1000),
};
} catch {
// Observer only: a failed read hides the line instead of failing the pool screen.
return null;
}
if (!cache.has(key) && cache.size >= CACHE_MAX_ENTRIES) {
const oldest = cache.keys().next().value;
if (oldest !== undefined) cache.delete(oldest);
}
cache.set(key, { at: nowMs, value });
return value;
}
export function resetPoolEgressObservationCache(): void {
cache.clear();
}