mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
feat(proxy): add proxy distribution UI with per-connection toggles (#3172)
Integrated into release/v3.8.11
This commit is contained in:
@@ -738,6 +738,8 @@ interface ConnectionRowConnection {
|
||||
tokenExpiresAt?: string;
|
||||
maxConcurrent?: number | null;
|
||||
authType?: string;
|
||||
proxyEnabled?: boolean;
|
||||
perKeyProxyEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface ConnectionRowProps {
|
||||
@@ -770,6 +772,10 @@ interface ConnectionRowProps {
|
||||
hasProxy?: boolean;
|
||||
proxySource?: string;
|
||||
proxyHost?: string;
|
||||
proxyEnabled?: boolean;
|
||||
perKeyProxyEnabled?: boolean;
|
||||
onToggleProxyEnabled?: (enabled: boolean) => void;
|
||||
onTogglePerKeyProxyEnabled?: (enabled: boolean) => void;
|
||||
onRefreshToken?: () => void;
|
||||
isRefreshing?: boolean;
|
||||
onApplyCodexAuthLocal?: () => void;
|
||||
@@ -1393,6 +1399,7 @@ export default function ProviderDetailPage() {
|
||||
const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible);
|
||||
const notify = useNotificationStore();
|
||||
const [proxyTarget, setProxyTarget] = useState(null);
|
||||
const [distributingProxies, setDistributingProxies] = useState(false);
|
||||
const [proxyConfig, setProxyConfig] = useState(null);
|
||||
const [connProxyMap, setConnProxyMap] = useState<
|
||||
Record<string, { proxy: any; level: string } | null>
|
||||
@@ -2389,6 +2396,248 @@ export default function ProviderDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleProxyEnabled = async (connectionId, proxyEnabled) => {
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${connectionId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ proxyEnabled }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setConnections((prev) =>
|
||||
prev.map((c) => (c.id === connectionId ? { ...c, proxyEnabled } : c))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error toggling proxy enabled:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePerKeyProxyEnabled = async (connectionId, perKeyProxyEnabled) => {
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${connectionId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ perKeyProxyEnabled }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setConnections((prev) =>
|
||||
prev.map((c) => (c.id === connectionId ? { ...c, perKeyProxyEnabled } : c))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error toggling per-key proxy enabled:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDistributeProxies = async (tagFilter?: string) => {
|
||||
const targetConnections = tagFilter
|
||||
? connections.filter(
|
||||
(c: any) =>
|
||||
(c.providerSpecificData?.tag as string | undefined)?.trim() === tagFilter
|
||||
)
|
||||
: connections;
|
||||
if (targetConnections.length === 0) return;
|
||||
setDistributingProxies(true);
|
||||
try {
|
||||
const proxiesRes = await fetch("/api/settings/proxies");
|
||||
if (!proxiesRes.ok) throw new Error("Failed to fetch proxies");
|
||||
const proxiesData = await proxiesRes.json();
|
||||
const savedProxies = (proxiesData?.items || []).filter(
|
||||
(p: any) => p.status === "active"
|
||||
);
|
||||
if (savedProxies.length === 0) {
|
||||
notify.error("No saved proxies found. Add proxies in Settings → Proxy first.");
|
||||
return;
|
||||
}
|
||||
|
||||
let assigned = 0;
|
||||
const sorted = [...targetConnections].sort(
|
||||
(a: any, b: any) => (a.priority || 0) - (b.priority || 0)
|
||||
);
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const conn = sorted[i] as any;
|
||||
const proxy = savedProxies[i % savedProxies.length];
|
||||
|
||||
try {
|
||||
await fetch("/api/settings/proxies/assignments", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
scope: "account",
|
||||
scopeId: conn.id,
|
||||
proxyId: null,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
/* clear old assignment */
|
||||
}
|
||||
|
||||
const patchRes = await fetch(`/api/providers/${conn.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ proxyEnabled: true, perKeyProxyEnabled: true }),
|
||||
});
|
||||
|
||||
if (!patchRes.ok) {
|
||||
console.error(`Failed to update connection ${conn.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Assign new proxy
|
||||
const assignRes = await fetch("/api/settings/proxies/assignments", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
scope: "account",
|
||||
scopeId: conn.id,
|
||||
proxyId: proxy.id,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!assignRes.ok) {
|
||||
console.error(`Failed to assign proxy to ${conn.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
assigned++;
|
||||
}
|
||||
|
||||
await fetchConnections();
|
||||
const tagLabel = tagFilter ? `"${tagFilter}" ` : "";
|
||||
notify.success(
|
||||
`Distributed ${assigned} proxy assignment(s) across ${tagLabel}${sorted.length} connection(s).`
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error distributing proxies:", err);
|
||||
notify.error("Failed to distribute proxies.");
|
||||
} finally {
|
||||
setDistributingProxies(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleProxyEnabled = async (connectionId, proxyEnabled) => {
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${connectionId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ proxyEnabled }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setConnections((prev) =>
|
||||
prev.map((c) => (c.id === connectionId ? { ...c, proxyEnabled } : c))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error toggling proxy enabled:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePerKeyProxyEnabled = async (connectionId, perKeyProxyEnabled) => {
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${connectionId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ perKeyProxyEnabled }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setConnections((prev) =>
|
||||
prev.map((c) => (c.id === connectionId ? { ...c, perKeyProxyEnabled } : c))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error toggling per-key proxy enabled:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDistributeProxies = async (tagFilter?: string) => {
|
||||
const targetConnections = tagFilter
|
||||
? connections.filter(
|
||||
(c: any) =>
|
||||
(c.providerSpecificData?.tag as string | undefined)?.trim() === tagFilter
|
||||
)
|
||||
: connections;
|
||||
if (targetConnections.length === 0) return;
|
||||
setDistributingProxies(true);
|
||||
try {
|
||||
const proxiesRes = await fetch("/api/settings/proxies");
|
||||
if (!proxiesRes.ok) throw new Error("Failed to fetch proxies");
|
||||
const proxiesData = await proxiesRes.json();
|
||||
const savedProxies = (proxiesData?.items || []).filter(
|
||||
(p: any) => p.status === "active"
|
||||
);
|
||||
if (savedProxies.length === 0) {
|
||||
notify.error("No saved proxies found. Add proxies in Settings → Proxy first.");
|
||||
return;
|
||||
}
|
||||
|
||||
let assigned = 0;
|
||||
const sorted = [...targetConnections].sort(
|
||||
(a: any, b: any) => (a.priority || 0) - (b.priority || 0)
|
||||
);
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const conn = sorted[i] as any;
|
||||
const proxy = savedProxies[i % savedProxies.length];
|
||||
|
||||
try {
|
||||
await fetch("/api/settings/proxies/assignments", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
scope: "account",
|
||||
scopeId: conn.id,
|
||||
proxyId: null,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
/* clear old assignment */
|
||||
}
|
||||
|
||||
const patchRes = await fetch(`/api/providers/${conn.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ proxyEnabled: true, perKeyProxyEnabled: true }),
|
||||
});
|
||||
|
||||
if (!patchRes.ok) {
|
||||
console.error(`Failed to update connection ${conn.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Assign new proxy
|
||||
const assignRes = await fetch("/api/settings/proxies/assignments", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
scope: "account",
|
||||
scopeId: conn.id,
|
||||
proxyId: proxy.id,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!assignRes.ok) {
|
||||
console.error(`Failed to assign proxy to ${conn.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
assigned++;
|
||||
}
|
||||
|
||||
await fetchConnections();
|
||||
const tagLabel = tagFilter ? `"${tagFilter}" ` : "";
|
||||
notify.success(
|
||||
`Distributed ${assigned} proxy assignment(s) across ${tagLabel}${sorted.length} connection(s).`
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error distributing proxies:", err);
|
||||
notify.error("Failed to distribute proxies.");
|
||||
} finally {
|
||||
setDistributingProxies(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleRateLimit = async (connectionId, enabled) => {
|
||||
try {
|
||||
const res = await fetch("/api/rate-limits", {
|
||||
@@ -3289,8 +3538,9 @@ export default function ProviderDetailPage() {
|
||||
const [clearingModels, setClearingModels] = useState(false);
|
||||
const providerAliasEntries = useMemo(
|
||||
() =>
|
||||
Object.entries(modelAliases).filter(([, model]) =>
|
||||
(model as string).startsWith(`${providerStorageAlias}/`)
|
||||
Object.entries(modelAliases).filter(
|
||||
([, model]) =>
|
||||
typeof model === "string" && model.startsWith(`${providerStorageAlias}/`)
|
||||
),
|
||||
[modelAliases, providerStorageAlias]
|
||||
);
|
||||
@@ -4073,6 +4323,24 @@ export default function ProviderDetailPage() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{connections.length > 0 && (
|
||||
<button
|
||||
onClick={() => handleDistributeProxies()}
|
||||
disabled={distributingProxies || batchTesting || !!retestingId}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
distributingProxies
|
||||
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
|
||||
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
|
||||
}`}
|
||||
title={t("distributeProxies")}
|
||||
aria-label={t("distributeProxies")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{distributingProxies ? "sync" : "swap_horiz"}
|
||||
</span>
|
||||
{distributingProxies ? t("distributing") : t("distributeProxies")}
|
||||
</button>
|
||||
)}
|
||||
{connections.length > 1 && (
|
||||
<button
|
||||
onClick={handleBatchTestAll}
|
||||
@@ -4410,6 +4678,10 @@ export default function ProviderDetailPage() {
|
||||
hasProxy={!!connProxyMap[conn.id]?.proxy}
|
||||
proxySource={connProxyMap[conn.id]?.level || null}
|
||||
proxyHost={connProxyMap[conn.id]?.proxy?.host || null}
|
||||
proxyEnabled={conn.proxyEnabled !== false}
|
||||
onToggleProxyEnabled={(enabled) => handleToggleProxyEnabled(conn.id, enabled)}
|
||||
perKeyProxyEnabled={conn.perKeyProxyEnabled === true}
|
||||
onTogglePerKeyProxyEnabled={(enabled) => handleTogglePerKeyProxyEnabled(conn.id, enabled)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -4451,17 +4723,30 @@ export default function ProviderDetailPage() {
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{selectedIds.size > 0 && (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
icon="delete"
|
||||
loading={batchDeleting}
|
||||
onClick={handleBatchDelete}
|
||||
>
|
||||
{t("batchDeleteSelected", { count: selectedIds.size })}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedIds.size === 0 && connections.length > 0 && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="shield"
|
||||
loading={distributingProxies}
|
||||
onClick={() => handleDistributeProxies()}
|
||||
>
|
||||
Distribute Proxies
|
||||
</Button>
|
||||
)}
|
||||
{selectedIds.size > 0 && (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
icon="delete"
|
||||
loading={batchDeleting}
|
||||
onClick={handleBatchDelete}
|
||||
>
|
||||
{t("batchDeleteSelected", { count: selectedIds.size })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-0 border border-t-0 border-border rounded-b-lg overflow-hidden">
|
||||
@@ -4485,6 +4770,15 @@ export default function ProviderDetailPage() {
|
||||
{tag}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-black/[0.04] dark:bg-white/[0.04]" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="shield"
|
||||
loading={distributingProxies}
|
||||
onClick={() => handleDistributeProxies(tag)}
|
||||
>
|
||||
Distribute Proxies
|
||||
</Button>
|
||||
<span className="text-[10px] text-text-muted/40">
|
||||
{groupConns.length}
|
||||
</span>
|
||||
@@ -4597,6 +4891,10 @@ export default function ProviderDetailPage() {
|
||||
hasProxy={!!connProxyMap[conn.id]?.proxy}
|
||||
proxySource={connProxyMap[conn.id]?.level || null}
|
||||
proxyHost={connProxyMap[conn.id]?.proxy?.host || null}
|
||||
proxyEnabled={conn.proxyEnabled !== false}
|
||||
onToggleProxyEnabled={(enabled) => handleToggleProxyEnabled(conn.id, enabled)}
|
||||
perKeyProxyEnabled={conn.perKeyProxyEnabled === true}
|
||||
onTogglePerKeyProxyEnabled={(enabled) => handleTogglePerKeyProxyEnabled(conn.id, enabled)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -5050,11 +5348,13 @@ export default function ProviderDetailPage() {
|
||||
{importProgress.logs.length > 0 && (
|
||||
<div className="max-h-48 overflow-y-auto rounded-lg bg-black/5 dark:bg-white/5 p-3 border border-black/5 dark:border-white/5">
|
||||
<div className="flex flex-col gap-1">
|
||||
{importProgress.logs.map((log, i) => (
|
||||
{importProgress.logs.map((log, i) => (
|
||||
<p
|
||||
key={i}
|
||||
className={`text-xs font-mono ${
|
||||
log.startsWith("✓") ? "text-green-500 font-semibold" : "text-text-muted"
|
||||
typeof log === "string" && log.startsWith("✓")
|
||||
? "text-green-500 font-semibold"
|
||||
: "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{log}
|
||||
@@ -6846,6 +7146,10 @@ function ConnectionRow({
|
||||
isApplyingGeminiAuthLocal,
|
||||
onExportGeminiAuthFile,
|
||||
isExportingGeminiAuthFile,
|
||||
perKeyProxyEnabled,
|
||||
onTogglePerKeyProxyEnabled,
|
||||
proxyEnabled,
|
||||
onToggleProxyEnabled,
|
||||
}: ConnectionRowProps) {
|
||||
const t = useTranslations("providers");
|
||||
const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible);
|
||||
@@ -7167,6 +7471,74 @@ function ConnectionRow({
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{onToggleProxyEnabled && (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
<button
|
||||
onClick={() => onToggleProxyEnabled(!proxyEnabled)}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
|
||||
proxyEnabled
|
||||
? "bg-emerald-500/15 text-emerald-500 hover:bg-emerald-500/25"
|
||||
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
|
||||
}`}
|
||||
title={proxyEnabled ? t("proxyEnabledTitle") : t("proxyDisabledTitle")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">vpn_lock</span>
|
||||
{proxyEnabled ? t("proxyOn") : t("proxyOff")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{onTogglePerKeyProxyEnabled && (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
<button
|
||||
onClick={() => onTogglePerKeyProxyEnabled(!perKeyProxyEnabled)}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
|
||||
perKeyProxyEnabled
|
||||
? "bg-violet-500/15 text-violet-500 hover:bg-violet-500/25"
|
||||
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
|
||||
}`}
|
||||
title={perKeyProxyEnabled ? t("perKeyProxyEnabledTitle") : t("perKeyProxyDisabledTitle")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">key</span>
|
||||
{perKeyProxyEnabled ? t("perKeyProxyOn") : t("perKeyProxyOff")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{onToggleProxyEnabled && (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
<button
|
||||
onClick={() => onToggleProxyEnabled(!proxyEnabled)}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
|
||||
proxyEnabled
|
||||
? "bg-emerald-500/15 text-emerald-500 hover:bg-emerald-500/25"
|
||||
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
|
||||
}`}
|
||||
title={proxyEnabled ? t("proxyEnabledTitle") : t("proxyDisabledTitle")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">vpn_lock</span>
|
||||
{proxyEnabled ? t("proxyOn") : t("proxyOff")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{onTogglePerKeyProxyEnabled && (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
<button
|
||||
onClick={() => onTogglePerKeyProxyEnabled(!perKeyProxyEnabled)}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
|
||||
perKeyProxyEnabled
|
||||
? "bg-violet-500/15 text-violet-500 hover:bg-violet-500/25"
|
||||
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
|
||||
}`}
|
||||
title={perKeyProxyEnabled ? t("perKeyProxyEnabledTitle") : t("perKeyProxyDisabledTitle")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">key</span>
|
||||
{perKeyProxyEnabled ? t("perKeyProxyOn") : t("perKeyProxyOff")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{hasProxy &&
|
||||
(() => {
|
||||
const colorClass =
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
"use client";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, Button, ProxyConfigModal } from "@/shared/components";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Card, Button, Toggle, ProxyConfigModal } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type GlobalProxyConfig = { type: string; host: string; port: number } | null;
|
||||
|
||||
type HealthcheckResult = {
|
||||
proxyUrl: string;
|
||||
ok: boolean;
|
||||
latencyMs: number | null;
|
||||
};
|
||||
|
||||
type HealthcheckSummary = {
|
||||
total: number;
|
||||
working: number;
|
||||
failed: number;
|
||||
};
|
||||
|
||||
export default function GlobalConfigTab() {
|
||||
const [proxyModalOpen, setProxyModalOpen] = useState(false);
|
||||
const [globalProxy, setGlobalProxy] = useState<GlobalProxyConfig>(null);
|
||||
const [perKeyProxyEnabled, setPerKeyProxyEnabled] = useState(false);
|
||||
const [perKeyLoading, setPerKeyLoading] = useState(true);
|
||||
const [targetUrl, setTargetUrl] = useState("https://api.openai.com/v1/models");
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [results, setResults] = useState<HealthcheckResult[] | null>(null);
|
||||
const [summary, setSummary] = useState<HealthcheckSummary | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
const t = useTranslations("settings");
|
||||
const tc = useTranslations("common");
|
||||
|
||||
@@ -21,10 +41,73 @@ export default function GlobalConfigTab() {
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
const loadPerKeyProxyEnabled = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings", { cache: "no-store" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (mountedRef.current) setPerKeyProxyEnabled(data.perKeyProxyEnabled === true);
|
||||
}
|
||||
} catch {
|
||||
/* leave default */
|
||||
} finally {
|
||||
if (mountedRef.current) setPerKeyLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- async data fetch on mount
|
||||
mountedRef.current = true;
|
||||
loadGlobalProxy();
|
||||
}, [loadGlobalProxy]);
|
||||
loadPerKeyProxyEnabled();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, [loadGlobalProxy, loadPerKeyProxyEnabled]);
|
||||
|
||||
const handleTogglePerKeyProxyEnabled = async () => {
|
||||
const newValue = !perKeyProxyEnabled;
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ perKeyProxyEnabled: newValue }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setPerKeyProxyEnabled(newValue);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to update per-key proxy setting:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const runHealthcheck = async () => {
|
||||
setTesting(true);
|
||||
setResults(null);
|
||||
setSummary(null);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/proxy-fallback/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targetUrl }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: "Request failed" }));
|
||||
setError(err.error || `HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setResults(data.results);
|
||||
setSummary(data.summary);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t("healthcheckFailed"));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -59,6 +142,118 @@ export default function GlobalConfigTab() {
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-0 overflow-hidden">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-xl text-violet-500" aria-hidden="true">
|
||||
key
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">{t("perKeyProxyEnabled")}</h2>
|
||||
<p className="text-sm text-text-muted">{t("perKeyProxyEnabledDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={perKeyProxyEnabled}
|
||||
disabled={perKeyLoading}
|
||||
onChange={handleTogglePerKeyProxyEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-0 overflow-hidden">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
|
||||
network_check
|
||||
</span>
|
||||
<h2 className="text-lg font-bold">Bulk Healthcheck</h2>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
Test all configured proxies against a target URL to find which ones work.
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={targetUrl}
|
||||
onChange={(e) => setTargetUrl(e.target.value)}
|
||||
placeholder="https://api.openai.com/v1/models"
|
||||
className="flex-1 px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
icon={testing ? "refresh" : "play_arrow"}
|
||||
disabled={testing}
|
||||
onClick={runHealthcheck}
|
||||
>
|
||||
{testing ? "Testing..." : "Healthcheck All"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testing && !results && (
|
||||
<div className="flex items-center gap-2 text-sm text-text-muted py-2">
|
||||
<span className="material-symbols-outlined animate-spin text-lg">refresh</span>
|
||||
Testing all proxies in parallel...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary && (
|
||||
<div className="flex items-center gap-4 mb-3 text-sm">
|
||||
<span className="text-text-muted">
|
||||
Total: <strong>{summary.total}</strong>
|
||||
</span>
|
||||
<span className="text-emerald-400">
|
||||
Working: <strong>{summary.working}</strong>
|
||||
</span>
|
||||
<span className="text-red-400">
|
||||
Failed: <strong>{summary.failed}</strong>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results && results.length > 0 && (
|
||||
<div className="max-h-64 overflow-y-auto rounded-lg border border-black/10 dark:border-white/10">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-black/5 dark:bg-white/5">
|
||||
<th className="text-left px-3 py-2 font-medium text-text-muted">Status</th>
|
||||
<th className="text-left px-3 py-2 font-medium text-text-muted">Proxy URL</th>
|
||||
<th className="text-right px-3 py-2 font-medium text-text-muted">Latency</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((r, i) => (
|
||||
<tr key={i} className="border-t border-black/5 dark:border-white/5">
|
||||
<td className="px-3 py-1.5">
|
||||
{r.ok ? (
|
||||
<span className="text-emerald-400 text-sm">✓</span>
|
||||
) : (
|
||||
<span className="text-red-400 text-sm">✗</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 font-mono truncate max-w-xs">{r.proxyUrl}</td>
|
||||
<td className="px-3 py-1.5 text-right text-text-muted">
|
||||
{r.latencyMs !== null ? `${r.latencyMs}ms` : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<ProxyConfigModal
|
||||
isOpen={proxyModalOpen}
|
||||
onClose={() => setProxyModalOpen(false)}
|
||||
|
||||
@@ -132,6 +132,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
group,
|
||||
maxConcurrent,
|
||||
quotaWindowThresholds: incomingWindowThresholds,
|
||||
proxyEnabled,
|
||||
perKeyProxyEnabled,
|
||||
projectId,
|
||||
providerSpecificData: incomingPsd,
|
||||
} = body;
|
||||
@@ -184,6 +186,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
}
|
||||
}
|
||||
if (projectId !== undefined) updateData.projectId = projectId;
|
||||
if (proxyEnabled !== undefined) updateData.proxyEnabled = proxyEnabled;
|
||||
if (perKeyProxyEnabled !== undefined) updateData.perKeyProxyEnabled = perKeyProxyEnabled;
|
||||
|
||||
// Merge providerSpecificData (partial update — preserve existing keys not sent by caller)
|
||||
if (incomingPsd !== undefined && incomingPsd !== null && typeof incomingPsd === "object") {
|
||||
|
||||
Reference in New Issue
Block a user