mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(proxy): batch delete, auto-test, health scheduler + transitive alias fix (#5918)
Proxy-registry batch management (batch-delete, auto-test, background health scheduler) + fix resolveProviderAlias to follow the alias chain transitively (oc -> opencode -> opencode-zen). Probe target now operator-configurable via PROXY_HEALTH_TEST_URL. Scope-creep files from the original branch dropped. Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
This commit is contained in:
14
.env.example
14
.env.example
@@ -1495,6 +1495,20 @@ APP_LOG_TO_FILE=true
|
||||
# healthy-result cache window under high concurrency.
|
||||
# PROXY_HEALTH_UNHEALTHY_CACHE_TTL_MS=2000
|
||||
|
||||
# Background proxy health scheduler (src/lib/proxyHealth/scheduler.ts).
|
||||
# Periodically probes every registered proxy and (optionally) removes dead ones.
|
||||
# Set "false" to disable the scheduler entirely. Default: enabled.
|
||||
# PROXY_HEALTH_ENABLED=true
|
||||
# Sweep interval in ms (minimum 60000). Default: 600000 (10min).
|
||||
# PROXY_HEALTH_INTERVAL_MS=600000
|
||||
# Reachability probe target for the scheduler and the auto-test endpoint.
|
||||
# Point it at an internal/self-hosted URL to avoid the public default.
|
||||
# PROXY_HEALTH_TEST_URL=https://httpbin.org/ip
|
||||
# Set "true" to let the scheduler auto-remove proxies after repeated failures.
|
||||
# PROXY_AUTO_REMOVE=false
|
||||
# Consecutive failures before an auto-remove fires. Default: 3.
|
||||
# PROXY_AUTO_REMOVE_AFTER=3
|
||||
|
||||
# Allow OAuth and provider validation flows to bypass a pinned proxy and connect
|
||||
# directly when proxy reachability pre-checks fail. Default: false.
|
||||
# Also configurable from Dashboard > Settings > Feature Flags.
|
||||
|
||||
@@ -22,9 +22,12 @@
|
||||
- **feat(xai):** surface Grok usage on the quota dashboard via local usage-history aggregation. (thanks @DevEstacion)
|
||||
- **feat(services):** add **Mux** (`coder/mux`) as a managed embedded service — install/start/stop/restart/logs lifecycle + dashboard tab, loopback-only API, `127.0.0.1`-bound with the auth token passed via env (never argv). Ported from upstream 9router#1802. (thanks @Ansh7473)
|
||||
- **feat(services):** promote **Bifrost** (`@maximhq/bifrost`) to a supervised embedded service ([#5670](https://github.com/diegosouzapw/OmniRoute/issues/5670)) — full install/start/stop/restart/update/status/auto-start lifecycle + dashboard tab, loopback-only API (hard rule #17). When a supervised Bifrost is running and `BIFROST_BASE_URL` is unset, the relay route auto-selects it as the routing backend (`getBifrostRoutingConfig()`); an explicit `BIFROST_BASE_URL` always takes precedence.
|
||||
- **feat(proxy):** proxy-registry batch management ([#5918](https://github.com/diegosouzapw/OmniRoute/pull/5918)) — `POST /api/settings/proxies/batch-delete` (delete many proxies in one request) and `POST /api/settings/proxies/auto-test` (reachability-test proxies with optional auto-remove of dead ones), plus a background health scheduler (`src/lib/proxyHealth/scheduler.ts`) that periodically probes registered proxies and can auto-remove persistently-dead ones. Configurable via `PROXY_HEALTH_ENABLED` / `PROXY_HEALTH_INTERVAL_MS` / `PROXY_HEALTH_TEST_URL` / `PROXY_AUTO_REMOVE` / `PROXY_AUTO_REMOVE_AFTER` (the probe target is now operator-configurable instead of a hardcoded endpoint). Dashboard gains batch-select checkboxes, a "Test All" action, and colored proxy health indicators. Regression guard: `tests/unit/proxy-batch-routes-5918.test.ts`. (thanks @oyi77)
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **providers (alias resolution followed only one hop):** `resolveProviderAlias()` did a single-hop lookup, so a genuine two-hop alias chain (`oc` → `opencode` → `opencode-zen`, where the parent OpenCode provider registers `alias: "oc"` and a manual override maps `opencode` → the `opencode-zen` free tier) resolved `oc/<model>` to the intermediate `opencode` instead of the final provider. Resolution now follows the chain transitively, guarded by both a depth limit and a seen-set so cycles can't loop. Regression guard: `tests/unit/provider-alias-transitive-5918.test.ts`. ([#5918](https://github.com/diegosouzapw/OmniRoute/pull/5918) — thanks @oyi77)
|
||||
|
||||
- fix(providers): strip orphan tool_result blocks on the Antigravity MITM path before forwarding to Claude ([#6026](https://github.com/diegosouzapw/OmniRoute/issues/6026))
|
||||
|
||||
- **dashboard ("Update now" → Internal Server Error):** clicking **Update now** on the dashboard home could crash the page with a blank "Internal Server Error" screen (`Minified React error #31`). The handler POSTs the loopback-only `/api/system/version` auto-update endpoint and, on a non-OK JSON response (e.g. a `403` when the dashboard is reached through a reverse proxy / non-loopback origin), passed the raw error envelope object `{ error: { code, message, correlation_id } }` straight to `notify.error()`, which rendered the object as a React child and threw #31. The update-error path now funnels the body through `extractApiErrorMessage()` (the same safe extractor added in #5340), so a readable string always reaches the toast. Regression guard: `tests/unit/ui/home-update-error-render-5991.test.ts`. ([#5991](https://github.com/diegosouzapw/OmniRoute/issues/5991))
|
||||
|
||||
@@ -212,7 +212,7 @@
|
||||
"src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx": 974,
|
||||
"src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx": 898,
|
||||
"src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": 1012,
|
||||
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1089,
|
||||
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1117,
|
||||
"src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 1183,
|
||||
"src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1629,
|
||||
"src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1924,
|
||||
@@ -342,6 +342,7 @@
|
||||
"_rebaseline_2026_06_13_2743d_skipbreaker": "Re-baseline #2743 gap-d (testar consumer do skipProviderBreaker): combo.ts 5131→5162 (+31). Crescimento = extração do boolean inline da decisão de circuit-breaker para o predicado puro EXPORTADO shouldRecordProviderBreakerFailure() (byte-idêntico) + JSDoc, para torná-lo unit-testável sem o harness completo de combo. Shrink estrutural segue com #3501.",
|
||||
"_rebaseline_2026_06_13_v3825_prettier_reconcile": "Reconciliação tardia: o prettier do pre-commit reformatou 3 arquivos DEPOIS da medição de file-size dos PRs, inflando linhas além do baseline setado — OAuthModal.tsx 956→960 e providers.ts 3146→3147 (#3324), combo.ts 5162→5164 (#2743d). Bumps de reformatação automática (sem lógica nova). LIÇÃO: medir file-size pós-commit (pós-prettier), não antes.",
|
||||
"_rebaseline_2026_06_14_3826_release_drift": "Re-baseline release/v3.8.25 drift already documented from #3809 owner changes: ProxyRegistryManager.tsx 1072→1089, sidebarVisibility.ts 990→1006, schemas.ts 2519→2522. This PR does not touch those source files; updating the frozen values restores Fast Quality Gates on the current release branch.",
|
||||
"_rebaseline_2026_07_03_5918_proxy_batch": "PR #5918 own growth: ProxyRegistryManager.tsx 1089→1117 (+28 = wiring the new batch-select/Test-All proxy management components — checkboxes, batch actions bar, health cells). Cohesive UI wiring for the batch-delete/auto-test feature; the reusable pieces already live in separate leaf components (ProxyBatchActions/ProxyCheckboxCell/ProxyHealthCell/useProxyBatchOperations). Legitimate feature growth, not a quality regression.",
|
||||
"_rebaseline_2026_06_14_3825_combo_stickiness": "Re-baseline #3825 (sessionless combo stickiness + reasoning-aware readiness): combo.ts 5164→5198 (+34, pós-prettier). Crescimento = deriveComboSessionKey() + effectiveSessionId threading nos sites de read/write do pin server-side. streamReadinessPolicy.ts não-frozen (sob cap). Lógica coesa no handler de combo; não-extraível.",
|
||||
"_rebaseline_2026_06_14_r3_3835_kiro_pricing": "PR #3835 own growth: pricing.ts 1470→1508 (+38 = missing Kiro pricing rows, claude-sonnet-4.6 etc., pure data). Also carries inherited release/v3.8.25 drift not yet frozen by prior r2 merges: RequestLoggerV2.tsx 1276→1282 (#3820 resizable log table) and combo.ts 5198→5203 (#3811 round-robin replay-response fix). This PR does not touch RequestLoggerV2/combo.ts source; updating the frozen values restores Fast Quality Gates on the current release branch.",
|
||||
"_rebaseline_2026_06_14_r3_3849_transient_hide": "PR #3849 own growth: providerPageHelpers.ts 939→955 (+16 = expanded JSDoc on the auto-hide policy + transient-failure guard in evaluateTestAllEntry). Cohesive helper logic; not extractable.",
|
||||
|
||||
@@ -831,6 +831,11 @@ Anthropic-compatible provider instead.
|
||||
| `PROXY_FAST_FAIL_TIMEOUT_MS` | `2000` | `src/lib/proxyHealth.ts` | Fast-fail health check timeout. |
|
||||
| `PROXY_HEALTH_CACHE_TTL_MS` | `30000` | `src/lib/proxyHealth.ts` | Health check result cache TTL. |
|
||||
| `PROXY_HEALTH_UNHEALTHY_CACHE_TTL_MS` | `2000` | `src/lib/proxyHealth.ts` | Cache TTL for failed proxy health probes. Keep this shorter than `PROXY_HEALTH_CACHE_TTL_MS` so transient proxy timeouts under high concurrency retry quickly without disabling fast-fail for truly dead proxies. |
|
||||
| `PROXY_HEALTH_ENABLED` | `true` | `src/lib/proxyHealth/scheduler.ts` | Set `false` to disable the background proxy health scheduler that periodically probes registered proxies. |
|
||||
| `PROXY_HEALTH_INTERVAL_MS` | `600000` | `src/lib/proxyHealth/scheduler.ts` | Background health-scheduler sweep interval in ms (minimum `60000`). |
|
||||
| `PROXY_HEALTH_TEST_URL` | `https://httpbin.org/ip` | `src/lib/proxyHealth/scheduler.ts` | Reachability probe target used by the scheduler and the `/api/settings/proxies/auto-test` endpoint. Point it at an internal/self-hosted URL to avoid the public default. |
|
||||
| `PROXY_AUTO_REMOVE` | `false` | `src/lib/proxyHealth/scheduler.ts` | Set `true` to let the scheduler auto-remove proxies after repeated consecutive failures. |
|
||||
| `PROXY_AUTO_REMOVE_AFTER` | `3` | `src/lib/proxyHealth/scheduler.ts` | Consecutive failures before the scheduler auto-removes a proxy (when `PROXY_AUTO_REMOVE=true`). |
|
||||
| `OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK` | `false` | `src/shared/constants/featureFlagDefinitions.ts` | Allow OAuth and provider validation flows to bypass a pinned proxy and connect directly when proxy reachability pre-checks fail. Effective precedence is Feature Flags DB override > env var > default. |
|
||||
| `RATE_LIMIT_MAX_WAIT_MS` | `120000` (2 min) | `open-sse/services/rateLimitManager.ts` | Max time to wait on a 429 before failing the request. |
|
||||
| `RATE_LIMIT_AUTO_ENABLE` | _(unset)_ | `open-sse/services/rateLimitManager.ts` | Force the auto-enable rate limit safety net on/off regardless of the persisted Dashboard setting. Accepts `true`/`1`/`on` to force on, `false`/`0`/`off` to force off. |
|
||||
|
||||
@@ -32,11 +32,6 @@ for (const [id, alias] of Object.entries(PROVIDER_ID_TO_ALIAS)) {
|
||||
// or backward-compatible slug changes, not a single provider's display name.
|
||||
// opencode/ → opencode-zen (the main free/open tier; opencode-go is a separate paid tier)
|
||||
ALIAS_TO_PROVIDER_ID["opencode"] = "opencode-zen";
|
||||
|
||||
// Manual aliases for external compatibility not covered by PROVIDER_ID_TO_ALIAS.
|
||||
// OpenCode's Zen provider now uses the "opencode" slug, but OmniRoute registers
|
||||
// it as "opencode-zen". This alias ensures `opencode/<model>` resolves correctly.
|
||||
ALIAS_TO_PROVIDER_ID["opencode"] = "opencode-zen";
|
||||
// xiaomi/ is the user-visible prefix for MiMo models; register it so
|
||||
// parseModel("xiaomi/mimo-v2-flash") resolves provider = "xiaomi-mimo" instead
|
||||
// of falling through to the identity fallback ("xiaomi").
|
||||
@@ -147,7 +142,19 @@ interface ProviderConnectionLike {
|
||||
*/
|
||||
export function resolveProviderAlias(aliasOrId: string | null | undefined): string | null {
|
||||
if (typeof aliasOrId !== "string") return null;
|
||||
return ALIAS_TO_PROVIDER_ID[aliasOrId] || aliasOrId;
|
||||
// Follow the alias chain transitively so intermediate aliases
|
||||
// (e.g. "oc" -> "opencode" -> "opencode-zen") resolve to the final target.
|
||||
// Guarded against infinite loops with both a depth limit and a seen-set.
|
||||
let current = aliasOrId;
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const next = ALIAS_TO_PROVIDER_ID[current];
|
||||
if (!next || next === current) return current;
|
||||
if (seen.has(next)) return next;
|
||||
seen.add(next);
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/shared/components";
|
||||
|
||||
interface ProxyBatchActionsProps {
|
||||
selectedCount: number;
|
||||
batchDeleting: boolean;
|
||||
autoTesting: boolean;
|
||||
onBatchDelete: () => void;
|
||||
onAutoTestAll: () => void;
|
||||
}
|
||||
|
||||
export function ProxyBatchActions({
|
||||
selectedCount,
|
||||
batchDeleting,
|
||||
autoTesting,
|
||||
onBatchDelete,
|
||||
onAutoTestAll,
|
||||
}: ProxyBatchActionsProps) {
|
||||
const t = useTranslations("proxyRegistry");
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedCount > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-text-muted">
|
||||
{t("batchSelectedCount", { count: selectedCount })}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="delete"
|
||||
onClick={onBatchDelete}
|
||||
loading={batchDeleting}
|
||||
className="!text-red-400 !border-red-500/30"
|
||||
data-testid="proxy-registry-batch-delete"
|
||||
>
|
||||
{t("batchDeleteSelected", { count: selectedCount })}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="network_check"
|
||||
onClick={onAutoTestAll}
|
||||
loading={autoTesting}
|
||||
data-testid="proxy-registry-test-all"
|
||||
>
|
||||
{t("testAll")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Modal } from "@/shared/components";
|
||||
|
||||
type ParsedProxyEntry = {
|
||||
name: string;
|
||||
type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
region?: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type ParseError = { line: number; reason: string };
|
||||
|
||||
interface ProxyBulkImportModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onImported: () => Promise<void>;
|
||||
}
|
||||
|
||||
const BULK_IMPORT_TEMPLATE = `# Proxy Bulk Import
|
||||
# Format: name | type | host | port | username | region | status
|
||||
# Example:
|
||||
# My Proxy | http | 1.2.3.4 | 8080 | user | US | active
|
||||
`;
|
||||
|
||||
function parseBulkImportText(text: string): {
|
||||
parsed: ParsedProxyEntry[];
|
||||
errors: ParseError[];
|
||||
skipped: number;
|
||||
} {
|
||||
const lines = text.split("\n");
|
||||
const parsed: ParsedProxyEntry[] = [];
|
||||
const errors: ParseError[] = [];
|
||||
let skipped = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line || line.startsWith("#")) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const parts = line.split("|").map((p) => p.trim());
|
||||
if (parts.length < 4) {
|
||||
errors.push({ line: i + 1, reason: "bulkImportMinFields" });
|
||||
continue;
|
||||
}
|
||||
const [name, type, host, portStr, username, region, status] = parts;
|
||||
const port = parseInt(portStr, 10);
|
||||
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
||||
errors.push({ line: i + 1, reason: "bulkImportInvalidPort" });
|
||||
continue;
|
||||
}
|
||||
parsed.push({
|
||||
name: name || `${host}:${port}`,
|
||||
type: ["http", "https", "socks5"].includes(type) ? type : "http",
|
||||
host,
|
||||
port,
|
||||
username: username || undefined,
|
||||
region: region || undefined,
|
||||
status: status === "inactive" ? "inactive" : "active",
|
||||
});
|
||||
}
|
||||
|
||||
return { parsed, errors, skipped };
|
||||
}
|
||||
|
||||
export function ProxyBulkImportModal({ isOpen, onClose, onImported }: ProxyBulkImportModalProps) {
|
||||
const t = useTranslations("proxyRegistry");
|
||||
const [bulkImportText, setBulkImportText] = useState(BULK_IMPORT_TEMPLATE);
|
||||
const [bulkImportParsed, setBulkImportParsed] = useState<ParsedProxyEntry[]>([]);
|
||||
const [bulkImportErrors, setBulkImportErrors] = useState<ParseError[]>([]);
|
||||
const [bulkImportSkipped, setBulkImportSkipped] = useState(0);
|
||||
const [bulkImportParsedOnce, setBulkImportParsedOnce] = useState(false);
|
||||
const [bulkImporting, setBulkImporting] = useState(false);
|
||||
const [bulkImportResult, setBulkImportResult] = useState<{ created: number; updated: number; failed: number } | null>(null);
|
||||
|
||||
const handleParse = () => {
|
||||
const result = parseBulkImportText(bulkImportText);
|
||||
setBulkImportParsed(result.parsed);
|
||||
setBulkImportErrors(result.errors);
|
||||
setBulkImportSkipped(result.skipped);
|
||||
setBulkImportParsedOnce(true);
|
||||
setBulkImportResult(null);
|
||||
};
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (bulkImportParsed.length === 0) return;
|
||||
setBulkImporting(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings/proxies/bulk-import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ items: bulkImportParsed }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
setBulkImportResult(data);
|
||||
await onImported();
|
||||
}
|
||||
} finally {
|
||||
setBulkImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
if (!bulkImporting) onClose();
|
||||
}}
|
||||
title={t("bulkImportTitle")}
|
||||
maxWidth="xl"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-text-muted">{t("bulkImportDescription")}</p>
|
||||
|
||||
<div>
|
||||
<textarea
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border font-mono text-xs leading-relaxed"
|
||||
rows={14}
|
||||
value={bulkImportText}
|
||||
onChange={(e) => {
|
||||
setBulkImportText(e.target.value);
|
||||
setBulkImportParsedOnce(false);
|
||||
setBulkImportResult(null);
|
||||
}}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="sm" variant="secondary" icon="search" onClick={handleParse}>
|
||||
{t("bulkImportParse")}
|
||||
</Button>
|
||||
|
||||
{bulkImportParsedOnce && (
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="text-emerald-400">{t("bulkImportParsed", { count: bulkImportParsed.length })}</span>
|
||||
<span className="text-text-muted">{t("bulkImportSkipped", { count: bulkImportSkipped })}</span>
|
||||
{bulkImportErrors.length > 0 && (
|
||||
<span className="text-red-400">{t("bulkImportParseErrors", { count: bulkImportErrors.length })}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{bulkImportErrors.length > 0 && (
|
||||
<div className="max-h-28 overflow-y-auto rounded border border-red-500/30 bg-red-500/10 p-2">
|
||||
{bulkImportErrors.map((err, idx) => (
|
||||
<div key={idx} className="text-xs text-red-400">
|
||||
{t("bulkImportErrorLine", { line: err.line, reason: t(err.reason as "bulkImportMinFields" | "bulkImportInvalidPort") })}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bulkImportParsedOnce && bulkImportParsed.length > 0 && (
|
||||
<div className="overflow-x-auto max-h-48 overflow-y-auto rounded border border-border">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-text-muted border-b border-border bg-bg-subtle sticky top-0">
|
||||
<th className="py-1.5 px-2">{t("tableName")}</th>
|
||||
<th className="py-1.5 px-2">{t("labelType")}</th>
|
||||
<th className="py-1.5 px-2">{t("labelHost")}</th>
|
||||
<th className="py-1.5 px-2">{t("labelPort")}</th>
|
||||
<th className="py-1.5 px-2">{t("labelUsername")}</th>
|
||||
<th className="py-1.5 px-2">{t("labelRegion")}</th>
|
||||
<th className="py-1.5 px-2">{t("labelStatus")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bulkImportParsed.map((entry, idx) => (
|
||||
<tr key={idx} className="border-b border-border/40">
|
||||
<td className="py-1 px-2 font-medium text-text-main">{entry.name}</td>
|
||||
<td className="py-1 px-2">
|
||||
<span className="px-1.5 py-0.5 rounded bg-bg-subtle border border-border text-[10px]">{entry.type}</span>
|
||||
</td>
|
||||
<td className="py-1 px-2 font-mono text-text-muted">{entry.host}</td>
|
||||
<td className="py-1 px-2 font-mono text-text-muted">{entry.port}</td>
|
||||
<td className="py-1 px-2 text-text-muted">{entry.username || "—"}</td>
|
||||
<td className="py-1 px-2 text-text-muted">{entry.region || "—"}</td>
|
||||
<td className="py-1 px-2">
|
||||
<span className={entry.status === "active" ? "text-emerald-400" : "text-text-muted"}>
|
||||
{entry.status === "active" ? t("statusActive") : t("statusInactive")}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bulkImportParsedOnce && bulkImportParsed.length === 0 && bulkImportErrors.length === 0 && (
|
||||
<div className="text-sm text-amber-400">{t("bulkImportNoValidEntries")}</div>
|
||||
)}
|
||||
|
||||
{bulkImportResult && (
|
||||
<div className="px-3 py-2 rounded border border-emerald-500/30 bg-emerald-500/10 text-sm text-emerald-400">
|
||||
{t("bulkImportSuccess", { created: bulkImportResult.created, updated: bulkImportResult.updated, failed: bulkImportResult.failed })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
||||
<Button size="sm" variant="secondary" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
icon="upload"
|
||||
onClick={handleExecute}
|
||||
loading={bulkImporting}
|
||||
disabled={!bulkImportParsedOnce || bulkImportParsed.length === 0}
|
||||
>
|
||||
{bulkImporting ? t("bulkImportImporting") : bulkImportParsed.length > 0 ? t("bulkImportImport", { count: bulkImportParsed.length }) : t("bulkImport")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
interface ProxyCheckboxCellProps {
|
||||
checked: boolean;
|
||||
onChange: () => void;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function ProxyCheckboxCell({ checked, onChange, label }: ProxyCheckboxCellProps) {
|
||||
return (
|
||||
<td className="py-2 pr-2 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-blue-500 w-4 h-4 cursor-pointer"
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
aria-label={label}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface TestResult {
|
||||
success: boolean;
|
||||
publicIp?: string;
|
||||
latencyMs?: number | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface HealthInfo {
|
||||
successRate?: number;
|
||||
avgLatencyMs?: number;
|
||||
}
|
||||
|
||||
interface ProxyHealthCellProps {
|
||||
testResult?: TestResult | null;
|
||||
health?: HealthInfo | null;
|
||||
}
|
||||
|
||||
export function ProxyHealthCell({ testResult, health }: ProxyHealthCellProps) {
|
||||
const t = useTranslations("proxyRegistry");
|
||||
|
||||
if (testResult) {
|
||||
if (testResult.success) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-emerald-400">{t("testPassed")}</span>
|
||||
{testResult.latencyMs != null && (
|
||||
<span
|
||||
className={
|
||||
testResult.latencyMs < 1000
|
||||
? "text-emerald-400"
|
||||
: testResult.latencyMs < 3000
|
||||
? "text-amber-400"
|
||||
: "text-red-400"
|
||||
}
|
||||
>
|
||||
{testResult.latencyMs}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="text-red-400">✗ {testResult.error || t("failed")}</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (health) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span>{t("successRate", { rate: health.successRate ?? 0 })}</span>
|
||||
<span>{t("avgLatency", { latency: health.avgLatencyMs ?? "-" })}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>—</span>;
|
||||
}
|
||||
@@ -3,8 +3,11 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Card, Modal } from "@/shared/components";
|
||||
import { parseBulkImportText } from "./parseBulkProxyImport.ts";
|
||||
import type { ParsedProxyEntry, ParseError } from "./parseBulkProxyImport.ts";
|
||||
import { useProxyBatchOperations } from "./useProxyBatchOperations";
|
||||
import { ProxyStatusBadge } from "./ProxyStatusBadge";
|
||||
import { ProxyHealthCell } from "./ProxyHealthCell";
|
||||
import { ProxyBatchActions } from "./ProxyBatchActions";
|
||||
import { ProxyCheckboxCell } from "./ProxyCheckboxCell";
|
||||
|
||||
type ProxyItem = {
|
||||
id: string;
|
||||
@@ -41,6 +44,23 @@ type TestResult = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type ParsedProxyEntry = {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
type: string;
|
||||
region: string;
|
||||
status: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
type ParseError = {
|
||||
line: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
const EMPTY_FORM = {
|
||||
id: "",
|
||||
name: "",
|
||||
@@ -56,11 +76,9 @@ const EMPTY_FORM = {
|
||||
};
|
||||
|
||||
const BULK_IMPORT_TEMPLATE = `# Proxy Bulk Import
|
||||
# Format A (pipe-delimited): NAME|HOST|PORT|USERNAME|PASSWORD|TYPE|REGION|STATUS|NOTES
|
||||
# Required: NAME, HOST, PORT
|
||||
# Optional: USERNAME, PASSWORD, TYPE (http|https|socks5, default: socks5), REGION, STATUS (active|inactive, default: active), NOTES
|
||||
# Format B (auth-less shorthand): HOST:PORT
|
||||
# Imports an HTTP proxy without credentials; name is auto-generated.
|
||||
# Format: NAME|HOST|PORT|USERNAME|PASSWORD|TYPE|REGION|STATUS|NOTES
|
||||
# Required: NAME, HOST, PORT
|
||||
# Optional: USERNAME, PASSWORD, TYPE (http|https|socks5, default: socks5), REGION, STATUS (active|inactive, default: active), NOTES
|
||||
# Lines starting with # are ignored. Existing proxies (same host+port) will be updated.
|
||||
#
|
||||
# SOCKS5 examples:
|
||||
@@ -70,12 +88,72 @@ const BULK_IMPORT_TEMPLATE = `# Proxy Bulk Import
|
||||
# HTTP/HTTPS examples:
|
||||
# http-proxy|10.0.0.50|8080|||http||active|Internal HTTP proxy
|
||||
# https-proxy|proxy.example.com|443|admin|secret123|https|US|active
|
||||
#
|
||||
# Auth-less shorthand examples:
|
||||
# 127.0.0.1:7897
|
||||
# proxy.example.com:3128
|
||||
`;
|
||||
|
||||
const VALID_TYPES = new Set(["http", "https", "socks5"]);
|
||||
const VALID_STATUSES = new Set(["active", "inactive"]);
|
||||
|
||||
function parseBulkImportText(text: string): {
|
||||
entries: ParsedProxyEntry[];
|
||||
errors: ParseError[];
|
||||
skipped: number;
|
||||
} {
|
||||
const lines = text.split("\n");
|
||||
const entries: ParsedProxyEntry[] = [];
|
||||
const errors: ParseError[] = [];
|
||||
let skipped = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const raw = lines[i].trim();
|
||||
if (!raw || raw.startsWith("#")) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = raw.split("|").map((p) => p.trim());
|
||||
const [name, host, portStr, username, password, type, region, status, notes] = parts;
|
||||
const lineNum = i + 1;
|
||||
|
||||
if (!name) {
|
||||
errors.push({ line: lineNum, reason: "bulkImportErrorMissingName" });
|
||||
continue;
|
||||
}
|
||||
if (!host) {
|
||||
errors.push({ line: lineNum, reason: "bulkImportErrorMissingHost" });
|
||||
continue;
|
||||
}
|
||||
const port = Number(portStr);
|
||||
if (!portStr || isNaN(port) || port < 1 || port > 65535) {
|
||||
errors.push({ line: lineNum, reason: "bulkImportErrorInvalidPort" });
|
||||
continue;
|
||||
}
|
||||
const normalizedType = (type || "socks5").toLowerCase();
|
||||
if (!VALID_TYPES.has(normalizedType)) {
|
||||
errors.push({ line: lineNum, reason: "bulkImportErrorInvalidType" });
|
||||
continue;
|
||||
}
|
||||
const normalizedStatus = (status || "active").toLowerCase();
|
||||
if (!VALID_STATUSES.has(normalizedStatus)) {
|
||||
errors.push({ line: lineNum, reason: "bulkImportErrorInvalidStatus" });
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.push({
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
username: username || "",
|
||||
password: password || "",
|
||||
type: normalizedType,
|
||||
region: region || "",
|
||||
status: normalizedStatus,
|
||||
notes: notes || "",
|
||||
});
|
||||
}
|
||||
|
||||
return { entries, errors, skipped };
|
||||
}
|
||||
|
||||
export default function ProxyRegistryManager() {
|
||||
const t = useTranslations("proxyRegistry");
|
||||
const [items, setItems] = useState<ProxyItem[]>([]);
|
||||
@@ -111,6 +189,29 @@ export default function ProxyRegistryManager() {
|
||||
failed: number;
|
||||
} | null>(null);
|
||||
|
||||
const {
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
batchDeleting,
|
||||
autoTesting,
|
||||
toggleSelectAll: hookToggleSelectAll,
|
||||
toggleSelect,
|
||||
handleBatchDelete: hookHandleBatchDelete,
|
||||
handleAutoTestAll: hookHandleAutoTestAll,
|
||||
} = useProxyBatchOperations(load);
|
||||
|
||||
const allSelected = items.length > 0 && items.every((item) => selectedIds.has(item.id));
|
||||
|
||||
|
||||
const handleBatchDelete = useCallback(() => {
|
||||
hookHandleBatchDelete(setError);
|
||||
}, [hookHandleBatchDelete, setError]);
|
||||
|
||||
const handleAutoTestAll = useCallback(() => {
|
||||
hookHandleAutoTestAll(setError, setTestById);
|
||||
}, [hookHandleAutoTestAll, setError, setTestById]);
|
||||
|
||||
|
||||
const editingId = useMemo(() => form.id || "", [form.id]);
|
||||
|
||||
const loadHealth = useCallback(async () => {
|
||||
@@ -174,8 +275,9 @@ export default function ProxyRegistryManager() {
|
||||
const ids = loaded.map((p) => p.id).filter(Boolean);
|
||||
void loadHealth();
|
||||
void loadAllUsage(ids);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || t("errorLoadFailed"));
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setError(msg || t("errorLoadFailed"));
|
||||
setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -186,6 +288,7 @@ export default function ProxyRegistryManager() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length > 0 && !bulkProxyId) {
|
||||
setBulkProxyId(items[0].id);
|
||||
@@ -531,6 +634,13 @@ export default function ProxyRegistryManager() {
|
||||
>
|
||||
{t("bulkAssign")}
|
||||
</Button>
|
||||
<ProxyBatchActions
|
||||
selectedCount={selectedIds.size}
|
||||
batchDeleting={batchDeleting}
|
||||
autoTesting={autoTesting}
|
||||
onBatchDelete={handleBatchDelete}
|
||||
onAutoTestAll={handleAutoTestAll}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
icon="add"
|
||||
@@ -557,8 +667,19 @@ export default function ProxyRegistryManager() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-text-muted border-b border-border">
|
||||
<th className="py-2 pr-2 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-blue-500 w-4 h-4 cursor-pointer"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = !allSelected && items.some((item) => selectedIds.has(item.id));
|
||||
}}
|
||||
onChange={() => hookToggleSelectAll(allSelected, items)}
|
||||
aria-label="Select all proxies"
|
||||
/>
|
||||
</th>
|
||||
<th className="py-2 pr-3">{t("tableName")}</th>
|
||||
<th className="py-2 pr-3">{t("tableEndpoint")}</th>
|
||||
<th className="py-2 pr-3">{t("tableStatus")}</th>
|
||||
<th className="py-2 pr-3">{t("tableHealth")}</th>
|
||||
<th className="py-2 pr-3">{t("tableUsage")}</th>
|
||||
@@ -571,6 +692,11 @@ export default function ProxyRegistryManager() {
|
||||
const health = healthById[item.id];
|
||||
return (
|
||||
<tr key={item.id} className="border-b border-border/60">
|
||||
<ProxyCheckboxCell
|
||||
checked={selectedIds.has(item.id)}
|
||||
onChange={() => toggleSelect(item.id)}
|
||||
label={`Select ${item.name}`}
|
||||
/>
|
||||
<td className="py-2 pr-3">
|
||||
<div className="font-medium text-text-main">{item.name}</div>
|
||||
{item.region && (
|
||||
@@ -581,38 +707,13 @@ export default function ProxyRegistryManager() {
|
||||
{item.type}://{item.host}:{item.port}
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
<span className="text-xs px-2 py-1 rounded border border-border bg-bg-subtle">
|
||||
{item.status === "inactive" ? t("statusInactive") : t("statusActive")}
|
||||
</span>
|
||||
<ProxyStatusBadge status={item.status} />
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-xs text-text-muted">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{testById[item.id] ? (
|
||||
testById[item.id]!.success ? (
|
||||
<>
|
||||
<span className="text-emerald-400">
|
||||
✓ {testById[item.id]!.publicIp}
|
||||
</span>
|
||||
{testById[item.id]!.latencyMs && (
|
||||
<span>{testById[item.id]!.latencyMs}ms</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-red-400">
|
||||
✗ {testById[item.id]!.error || t("failed")}
|
||||
</span>
|
||||
)
|
||||
) : health ? (
|
||||
<>
|
||||
<span>{t("successRate", { rate: health.successRate ?? 0 })}</span>
|
||||
<span>
|
||||
{t("avgLatency", { latency: health.avgLatencyMs ?? "-" })}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>—</span>
|
||||
)}
|
||||
</div>
|
||||
<ProxyHealthCell
|
||||
testResult={testById[item.id] ?? undefined}
|
||||
health={health ?? undefined}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-xs text-text-muted">
|
||||
{usageById[item.id] != null
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
interface ProxyStatusBadgeProps {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export function ProxyStatusBadge({ status }: ProxyStatusBadgeProps) {
|
||||
const isInactive = status === "inactive";
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 text-xs px-2 py-1 rounded border ${
|
||||
isInactive
|
||||
? "border-red-500/30 bg-red-500/10 text-red-400"
|
||||
: "border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${isInactive ? "bg-red-400" : "bg-emerald-400"}`}
|
||||
/>
|
||||
{isInactive ? "Inactive" : "Active"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
interface BatchDeleteResult {
|
||||
id: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface AutoTestResult {
|
||||
proxyId: string;
|
||||
host: string;
|
||||
port: number;
|
||||
alive: boolean;
|
||||
latencyMs: number | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function useProxyBatchOperations(load: () => Promise<void>) {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [batchDeleting, setBatchDeleting] = useState(false);
|
||||
const [autoTesting, setAutoTesting] = useState(false);
|
||||
|
||||
const toggleSelectAll = useCallback(
|
||||
(allSelected: boolean, items: Array<{ id: string }>) => {
|
||||
setSelectedIds(allSelected ? new Set() : new Set(items.map((i) => i.id)));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleBatchDelete = useCallback(
|
||||
async (setError: (msg: string | null) => void) => {
|
||||
if (selectedIds.size === 0) return;
|
||||
setBatchDeleting(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings/proxies/batch-delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids: Array.from(selectedIds), force: true }),
|
||||
});
|
||||
const data: { error?: { message?: string }; results?: BatchDeleteResult[] } =
|
||||
await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
setSelectedIds(new Set());
|
||||
await load();
|
||||
} else {
|
||||
setError(data?.error?.message || "Batch delete failed");
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Batch delete failed");
|
||||
} finally {
|
||||
setBatchDeleting(false);
|
||||
}
|
||||
},
|
||||
[selectedIds, load]
|
||||
);
|
||||
|
||||
const handleAutoTestAll = useCallback(
|
||||
async (
|
||||
setError: (msg: string | null) => void,
|
||||
setTestById: React.Dispatch<React.SetStateAction<Record<string, { success: boolean; publicIp?: string; latencyMs?: number | null; error?: string } | null>>>
|
||||
) => {
|
||||
setAutoTesting(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings/proxies/auto-test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const data: { error?: { message?: string }; results?: AutoTestResult[] } =
|
||||
await res.json().catch(() => ({}));
|
||||
if (res.ok && data?.results) {
|
||||
const newTestResults: Record<string, { success: boolean; publicIp?: string; latencyMs?: number | null; error?: string } | null> = {};
|
||||
for (const r of data.results) {
|
||||
newTestResults[r.proxyId] = {
|
||||
success: r.alive,
|
||||
publicIp: r.alive ? r.host : undefined,
|
||||
latencyMs: r.latencyMs,
|
||||
error: r.error,
|
||||
};
|
||||
}
|
||||
setTestById((prev) => ({ ...prev, ...newTestResults }));
|
||||
} else if (data?.error?.message) {
|
||||
setError(data.error.message);
|
||||
}
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Auto-test failed");
|
||||
} finally {
|
||||
setAutoTesting(false);
|
||||
}
|
||||
},
|
||||
[load]
|
||||
);
|
||||
|
||||
return {
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
batchDeleting,
|
||||
autoTesting,
|
||||
toggleSelectAll,
|
||||
toggleSelect,
|
||||
handleBatchDelete,
|
||||
handleAutoTestAll,
|
||||
};
|
||||
}
|
||||
126
src/app/api/settings/proxies/auto-test/route.ts
Normal file
126
src/app/api/settings/proxies/auto-test/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { z } from "zod";
|
||||
import { deleteProxyById, listProxies, updateProxy } from "@/lib/localDb";
|
||||
import { createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { createProxyDispatcher } from "@omniroute/open-sse/utils/proxyDispatcher";
|
||||
import { fetch as undiciFetch } from "undici";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
|
||||
const TEST_TIMEOUT_MS = 5000;
|
||||
// Reachability probe target. Configurable so operators can point it at an
|
||||
// internal/self-hosted endpoint instead of the public default.
|
||||
const TEST_URL = process.env.PROXY_HEALTH_TEST_URL || "https://httpbin.org/ip";
|
||||
const CONCURRENCY = 10;
|
||||
|
||||
const autoTestSchema = z.object({
|
||||
ids: z.array(z.string()).optional(),
|
||||
autoRemove: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
interface TestResult {
|
||||
proxyId: string;
|
||||
host: string;
|
||||
port: number;
|
||||
alive: boolean;
|
||||
latencyMs: number | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function testSingleProxy(proxy: { id: string; type: string; host: string; port: number }): Promise<TestResult> {
|
||||
const proxyUrl = `${proxy.type}://${proxy.host}:${proxy.port}`;
|
||||
const start = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const dispatcher = createProxyDispatcher(proxyUrl);
|
||||
const resp = await undiciFetch(TEST_URL, {
|
||||
method: "HEAD",
|
||||
signal: controller.signal,
|
||||
dispatcher,
|
||||
headers: { "User-Agent": "OmniRoute/1.0" },
|
||||
});
|
||||
const latencyMs = Date.now() - start;
|
||||
const alive = resp.status < 500;
|
||||
await updateProxy(proxy.id, { status: alive ? "active" : "inactive" }).catch(() => {});
|
||||
return { proxyId: proxy.id, host: proxy.host, port: proxy.port, alive, latencyMs };
|
||||
} catch (err) {
|
||||
const latencyMs = Date.now() - start;
|
||||
await updateProxy(proxy.id, { status: "inactive" }).catch(() => {});
|
||||
return {
|
||||
proxyId: proxy.id,
|
||||
host: proxy.host,
|
||||
port: proxy.port,
|
||||
alive: false,
|
||||
latencyMs,
|
||||
error: err instanceof Error ? err.message : "Connection failed",
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/settings/proxies/auto-test
|
||||
* Tests proxy reachability. If autoRemove is true, removes dead proxies.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
rawBody = {};
|
||||
}
|
||||
|
||||
const validation = validateBody(autoTestSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return createErrorResponse({ status: 400, message: validation.error.message, type: "invalid_request" });
|
||||
}
|
||||
|
||||
const { ids: specificIds, autoRemove } = validation.data;
|
||||
|
||||
try {
|
||||
const allProxies = await listProxies({ includeSecrets: false });
|
||||
const proxiesToTest = specificIds
|
||||
? allProxies.filter((p) => specificIds.includes(p.id))
|
||||
: allProxies;
|
||||
|
||||
if (proxiesToTest.length === 0) {
|
||||
return Response.json({ results: [], removed: [] });
|
||||
}
|
||||
|
||||
const results: TestResult[] = [];
|
||||
for (let i = 0; i < proxiesToTest.length; i += CONCURRENCY) {
|
||||
const batch = proxiesToTest.slice(i, i + CONCURRENCY);
|
||||
const batchResults = await Promise.allSettled(batch.map((proxy) => testSingleProxy(proxy)));
|
||||
for (const result of batchResults) {
|
||||
if (result.status === "fulfilled") results.push(result.value);
|
||||
}
|
||||
}
|
||||
|
||||
const removed: string[] = [];
|
||||
if (autoRemove) {
|
||||
for (const r of results) {
|
||||
if (!r.alive) {
|
||||
try {
|
||||
if (await deleteProxyById(r.proxyId, { force: true })) removed.push(r.proxyId);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
tested: results.length,
|
||||
alive: results.filter((r) => r.alive).length,
|
||||
dead: results.filter((r) => !r.alive).length,
|
||||
removed: removed.length,
|
||||
results,
|
||||
});
|
||||
} catch (error) {
|
||||
return createErrorResponseFromUnknown(error, "Failed to auto-test proxies");
|
||||
}
|
||||
}
|
||||
60
src/app/api/settings/proxies/batch-delete/route.ts
Normal file
60
src/app/api/settings/proxies/batch-delete/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { z } from "zod";
|
||||
import { deleteProxyById } from "@/lib/localDb";
|
||||
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
const batchDeleteSchema = z.object({
|
||||
ids: z.array(z.string()).min(1).max(100),
|
||||
force: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/settings/proxies/batch-delete
|
||||
* Deletes multiple proxies in a single request.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return createErrorResponse({ status: 400, message: "Invalid JSON body", type: "invalid_request" });
|
||||
}
|
||||
|
||||
const validation = validateBody(batchDeleteSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return createErrorResponse({ status: 400, message: validation.error.message, type: "invalid_request" });
|
||||
}
|
||||
|
||||
const { ids, force } = validation.data;
|
||||
|
||||
try {
|
||||
const results: Array<{ id: string; success: boolean; error?: string }> = [];
|
||||
let deletedCount = 0;
|
||||
|
||||
for (const id of ids) {
|
||||
try {
|
||||
if (await deleteProxyById(id, { force })) {
|
||||
results.push({ id, success: true });
|
||||
deletedCount++;
|
||||
} else {
|
||||
results.push({ id, success: false, error: "Proxy not found" });
|
||||
}
|
||||
} catch (err) {
|
||||
results.push({ id, success: false, error: err instanceof Error ? err.message : "Unknown error" });
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedCount > 0) {
|
||||
try { clearDispatcherCache(); } catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
return Response.json({ success: deletedCount > 0, deleted: deletedCount, failed: ids.length - deletedCount, results });
|
||||
} catch (error) {
|
||||
return createErrorResponseFromUnknown(error, "Failed to batch delete proxies");
|
||||
}
|
||||
}
|
||||
@@ -7926,7 +7926,12 @@
|
||||
"bulkImportMaxExceeded": "Maximum 100 proxies per import",
|
||||
"bulkImportPreview": "Preview",
|
||||
"clearAssignment": "(clear assignment)",
|
||||
"bulkProxyAssignment": "Bulk Proxy Assignment"
|
||||
"bulkProxyAssignment": "Bulk Proxy Assignment",
|
||||
"testAll": "Test All",
|
||||
"errorTestFailed": "Failed to test proxies",
|
||||
"batchSelectedCount": "{count} selected",
|
||||
"batchDeleteSelected": "Delete {count} selected",
|
||||
"testPassed": "✓ OK"
|
||||
},
|
||||
"playground": {
|
||||
"title": "Model Playground",
|
||||
|
||||
@@ -151,6 +151,9 @@ export async function registerNodejs(): Promise<void> {
|
||||
import("@/lib/skills/builtins"),
|
||||
]);
|
||||
|
||||
// Proxy health scheduler (auto-removes dead proxies on interval)
|
||||
await import("@/lib/proxyHealth/scheduler");
|
||||
|
||||
initGracefulShutdown();
|
||||
initApiBridgeServer();
|
||||
startSpendBatchWriter();
|
||||
|
||||
171
src/lib/proxyHealth/scheduler.ts
Normal file
171
src/lib/proxyHealth/scheduler.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Proxy Health Check Scheduler
|
||||
*
|
||||
* Periodically tests all proxy registry entries and automatically
|
||||
* removes proxies that have been failing consecutively.
|
||||
*
|
||||
* Config via environment:
|
||||
* PROXY_HEALTH_INTERVAL_MS — sweep interval (default: 600000 = 10min)
|
||||
* PROXY_HEALTH_ENABLED — set "false" to disable
|
||||
* PROXY_AUTO_REMOVE — set "true" to auto-remove dead proxies
|
||||
* PROXY_AUTO_REMOVE_AFTER — consecutive failures before removal (default: 3)
|
||||
*/
|
||||
|
||||
import { deleteProxyById, listProxies, updateProxy } from "@/lib/localDb";
|
||||
import { createProxyDispatcher, clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
|
||||
import { fetch as undiciFetch } from "undici";
|
||||
|
||||
const TEST_TIMEOUT_MS = 5000;
|
||||
// Reachability probe target for proxy health checks. Configurable so operators
|
||||
// can point it at an internal/self-hosted endpoint instead of the public default.
|
||||
const TEST_URL = process.env.PROXY_HEALTH_TEST_URL || "https://httpbin.org/ip";
|
||||
const CONCURRENCY = 10;
|
||||
const INITIAL_DELAY_MS = 60_000;
|
||||
const DEFAULT_INTERVAL_MS = 600_000;
|
||||
const DEFAULT_REMOVE_AFTER = 3;
|
||||
const LOG_PREFIX = "[ProxyHealth]";
|
||||
|
||||
declare global {
|
||||
var __proxyHealthInterval: ReturnType<typeof setInterval> | undefined;
|
||||
var __proxyHealthConsecutiveFailures: Map<string, number> | undefined;
|
||||
}
|
||||
|
||||
function getFailureMap(): Map<string, number> {
|
||||
if (!globalThis.__proxyHealthConsecutiveFailures) {
|
||||
globalThis.__proxyHealthConsecutiveFailures = new Map();
|
||||
}
|
||||
return globalThis.__proxyHealthConsecutiveFailures;
|
||||
}
|
||||
|
||||
function isEnabled(): boolean {
|
||||
return process.env.PROXY_HEALTH_ENABLED !== "false";
|
||||
}
|
||||
|
||||
function getIntervalMs(): number {
|
||||
const raw = parseInt(process.env.PROXY_HEALTH_INTERVAL_MS ?? "", 10);
|
||||
return Number.isFinite(raw) && raw >= 60_000 ? raw : DEFAULT_INTERVAL_MS;
|
||||
}
|
||||
|
||||
function isAutoRemoveEnabled(): boolean {
|
||||
return process.env.PROXY_AUTO_REMOVE === "true";
|
||||
}
|
||||
|
||||
function getRemoveAfter(): number {
|
||||
const raw = parseInt(process.env.PROXY_AUTO_REMOVE_AFTER ?? "", 10);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_REMOVE_AFTER;
|
||||
}
|
||||
|
||||
function isBuildProcess(): boolean {
|
||||
return typeof process !== "undefined" && process.env.NEXT_PHASE === "phase-production-build";
|
||||
}
|
||||
|
||||
function isBackgroundServicesDisabled(): boolean {
|
||||
const raw = process.env.OMNIROUTE_DISABLE_BACKGROUND_SERVICES;
|
||||
if (!raw) return false;
|
||||
return ["1", "true", "yes", "on"].includes(raw.trim().toLowerCase());
|
||||
}
|
||||
|
||||
async function testOneProxy(proxy: { id: string; type: string; host: string; port: number }): Promise<boolean> {
|
||||
const proxyUrl = `${proxy.type}://${proxy.host}:${proxy.port}`;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS);
|
||||
try {
|
||||
const dispatcher = createProxyDispatcher(proxyUrl);
|
||||
const resp = await undiciFetch(TEST_URL, {
|
||||
method: "HEAD",
|
||||
signal: controller.signal,
|
||||
dispatcher,
|
||||
headers: { "User-Agent": "OmniRoute/1.0" },
|
||||
});
|
||||
return resp.status < 500;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function sweep(): Promise<void> {
|
||||
const proxies = await listProxies({ includeSecrets: false });
|
||||
if (proxies.length === 0) return;
|
||||
|
||||
const failureMap = getFailureMap();
|
||||
const removeAfter = getRemoveAfter();
|
||||
const autoRemove = isAutoRemoveEnabled();
|
||||
|
||||
let tested = 0;
|
||||
let alive = 0;
|
||||
let removed = 0;
|
||||
|
||||
for (let i = 0; i < proxies.length; i += CONCURRENCY) {
|
||||
const batch = proxies.slice(i, i + CONCURRENCY);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (proxy) => {
|
||||
const ok = await testOneProxy(proxy);
|
||||
return { id: proxy.id, ok };
|
||||
})
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status !== "fulfilled") continue;
|
||||
const { id, ok } = result.value;
|
||||
tested++;
|
||||
|
||||
if (ok) {
|
||||
alive++;
|
||||
failureMap.delete(id);
|
||||
await updateProxy(id, { status: "active" }).catch(() => {});
|
||||
} else {
|
||||
const failures = (failureMap.get(id) ?? 0) + 1;
|
||||
failureMap.set(id, failures);
|
||||
await updateProxy(id, { status: "inactive" }).catch(() => {});
|
||||
|
||||
if (autoRemove && failures >= removeAfter) {
|
||||
if (await deleteProxyById(id, { force: true }).catch(() => false)) {
|
||||
failureMap.delete(id);
|
||||
removed++;
|
||||
try { clearDispatcherCache(); } catch { /* non-critical */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${LOG_PREFIX} Sweep complete: ${tested} tested, ${alive} alive, ${removed} auto-removed`
|
||||
);
|
||||
}
|
||||
|
||||
function scheduleSweep(): void {
|
||||
const interval = getIntervalMs();
|
||||
globalThis.__proxyHealthInterval = setInterval(() => {
|
||||
void sweep().catch((err) => {
|
||||
console.error(`${LOG_PREFIX} Sweep error:`, err);
|
||||
});
|
||||
}, interval);
|
||||
}
|
||||
|
||||
export function initProxyHealthCheck(): void {
|
||||
if (!isEnabled() || isBuildProcess() || isBackgroundServicesDisabled()) return;
|
||||
if (globalThis.__proxyHealthInterval) return;
|
||||
|
||||
setTimeout(() => {
|
||||
console.log(`${LOG_PREFIX} Starting proxy health scheduler (interval: ${getIntervalMs()}ms)`);
|
||||
void sweep().catch(() => {});
|
||||
scheduleSweep();
|
||||
}, INITIAL_DELAY_MS);
|
||||
}
|
||||
|
||||
export function stopProxyHealthCheck(): void {
|
||||
if (globalThis.__proxyHealthInterval) {
|
||||
clearInterval(globalThis.__proxyHealthInterval);
|
||||
globalThis.__proxyHealthInterval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function forceProxyHealthSweep(): Promise<void> {
|
||||
await sweep();
|
||||
}
|
||||
|
||||
// Auto-initialize on first import
|
||||
initProxyHealthCheck();
|
||||
45
tests/unit/provider-alias-transitive-5918.test.ts
Normal file
45
tests/unit/provider-alias-transitive-5918.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Regression tests for #5918: resolveProviderAlias must follow the alias chain
|
||||
* transitively.
|
||||
*
|
||||
* Root cause: resolveProviderAlias() did a single-hop lookup
|
||||
* (`ALIAS_TO_PROVIDER_ID[alias] || alias`). The registry has genuine two-hop chains:
|
||||
* the parent OpenCode provider registers `id: "opencode", alias: "oc"` (so
|
||||
* `oc -> opencode`), and a manual override maps `opencode -> opencode-zen` (the
|
||||
* main free tier). With single-hop, `resolveProviderAlias("oc")` returned the
|
||||
* intermediate `"opencode"` instead of the final `"opencode-zen"`, so `oc/<model>`
|
||||
* resolved to the wrong provider.
|
||||
*
|
||||
* Fix: resolve transitively with a depth limit AND a seen-set so cycles cannot loop.
|
||||
* These assertions FAIL on the old single-hop implementation and pass on the fix.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveProviderAlias } from "../../open-sse/services/model.ts";
|
||||
|
||||
test("resolveProviderAlias follows the oc -> opencode -> opencode-zen chain transitively", () => {
|
||||
// The bug: single-hop returned "opencode" (intermediate) instead of "opencode-zen".
|
||||
assert.equal(resolveProviderAlias("oc"), "opencode-zen");
|
||||
});
|
||||
|
||||
test("resolveProviderAlias resolves a direct one-hop alias", () => {
|
||||
assert.equal(resolveProviderAlias("opencode"), "opencode-zen");
|
||||
});
|
||||
|
||||
test("resolveProviderAlias returns a terminal id unchanged (id === alias)", () => {
|
||||
// opencode-zen registers id === alias === "opencode-zen"; must not loop or drift.
|
||||
assert.equal(resolveProviderAlias("opencode-zen"), "opencode-zen");
|
||||
});
|
||||
|
||||
test("resolveProviderAlias falls back to identity for an unknown provider/id", () => {
|
||||
assert.equal(resolveProviderAlias("gpt-4"), "gpt-4");
|
||||
assert.equal(resolveProviderAlias("some-unregistered-provider"), "some-unregistered-provider");
|
||||
});
|
||||
|
||||
test("resolveProviderAlias returns null for non-string input", () => {
|
||||
assert.equal(resolveProviderAlias(null), null);
|
||||
assert.equal(resolveProviderAlias(undefined), null);
|
||||
// @ts-expect-error — exercising the runtime guard against non-string input
|
||||
assert.equal(resolveProviderAlias(123), null);
|
||||
});
|
||||
103
tests/unit/proxy-batch-routes-5918.test.ts
Normal file
103
tests/unit/proxy-batch-routes-5918.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Route coverage for #5918 proxy management endpoints:
|
||||
* POST /api/settings/proxies/batch-delete
|
||||
* POST /api/settings/proxies/auto-test
|
||||
*
|
||||
* DB-backed and network-free: batch-delete is exercised end-to-end against a
|
||||
* temp SQLite DB; auto-test is exercised only on its no-proxy early-return path
|
||||
* (which returns before any outbound probe), so no real network is touched.
|
||||
*/
|
||||
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-batch-5918-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-secret";
|
||||
delete process.env.INITIAL_PASSWORD; // auth not required in this test env
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const proxiesDb = await import("../../src/lib/db/proxies.ts");
|
||||
const { POST: batchDeletePost } = await import(
|
||||
"../../src/app/api/settings/proxies/batch-delete/route.ts"
|
||||
);
|
||||
const { POST: autoTestPost } = await import(
|
||||
"../../src/app/api/settings/proxies/auto-test/route.ts"
|
||||
);
|
||||
|
||||
function jsonRequest(body: unknown): Request {
|
||||
return new Request("http://localhost/api/settings/proxies/batch-delete", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function resetStorage() {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("batch-delete removes multiple existing proxies in one request", async () => {
|
||||
await resetStorage();
|
||||
const a = await proxiesDb.createProxy({ name: "A", type: "http", host: "127.0.0.1", port: 8080 });
|
||||
const b = await proxiesDb.createProxy({ name: "B", type: "http", host: "127.0.0.1", port: 8081 });
|
||||
assert.ok(a?.id && b?.id);
|
||||
|
||||
const res = await batchDeletePost(jsonRequest({ ids: [a.id, b.id] }));
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.deleted, 2);
|
||||
assert.equal(body.failed, 0);
|
||||
// Both are actually gone from the store.
|
||||
const remaining = await proxiesDb.listProxies({ includeSecrets: false });
|
||||
assert.equal(remaining.filter((p) => p.id === a.id || p.id === b.id).length, 0);
|
||||
});
|
||||
|
||||
test("batch-delete reports non-existent ids as failed without throwing", async () => {
|
||||
await resetStorage();
|
||||
const res = await batchDeletePost(jsonRequest({ ids: ["does-not-exist"] }));
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.deleted, 0);
|
||||
assert.equal(body.failed, 1);
|
||||
});
|
||||
|
||||
test("batch-delete rejects an empty ids array with 400 (validation, pre-DB)", async () => {
|
||||
await resetStorage();
|
||||
const res = await batchDeletePost(jsonRequest({ ids: [] }));
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
test("batch-delete rejects invalid JSON with 400", async () => {
|
||||
await resetStorage();
|
||||
const bad = new Request("http://localhost/x", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "{ not json",
|
||||
});
|
||||
const res = await batchDeletePost(bad);
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
test("auto-test returns an empty result set when there are no proxies (no network probe)", async () => {
|
||||
await resetStorage();
|
||||
const req = new Request("http://localhost/api/settings/proxies/auto-test", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await autoTestPost(req);
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.deepEqual(body.results, []);
|
||||
});
|
||||
Reference in New Issue
Block a user