mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix: proxy UI bugs, connection tag grouping, and function_call prefix stripping
## Proxy UI Bug Fixes - fix: proxy badge on connection cards now uses resolveProxyForConnection() per-connection (covers registry + config-file assignments) - fix: Test Connection button now works in 'saved' proxy mode by resolving proxy config from savedProxies list - fix: ProxyConfigModal now calls onClose() after save/clear (fixes UI freeze) - fix: ProxyRegistryManager loads usage eagerly on mount with deduplication by scope+scopeId to prevent double-counting; adds per-row Test button ## Connection Tag Grouping (new feature) - feat: add Tag/Group field to EditConnectionModal (stored in providerSpecificData.tag, no DB schema change) - feat: connections list groups by tag with visual dividers when any account has a tag; untagged accounts appear first without header ## Post-merge fix from PR #607 review - fix: function_call blocks in translateNonStreamingResponse now also strip Claude OAuth proxy_ prefix via toolNameMap (kilo-code-bot #607 warning) Affects OpenAI Responses API format path — tool_use was fixed in PR #607 but function_call was missed
This commit is contained in:
@@ -125,11 +125,14 @@ export function translateNonStreamingResponse(
|
||||
typeof itemObj.arguments === "string"
|
||||
? itemObj.arguments
|
||||
: JSON.stringify(itemObj.arguments || {});
|
||||
const rawName = toString(itemObj.name);
|
||||
// Strip Claude OAuth proxy_ prefix using toolNameMap (mirrors tool_use fix for #605)
|
||||
const resolvedName = toolNameMap?.get(rawName) ?? rawName;
|
||||
toolCalls.push({
|
||||
id: callId,
|
||||
type: "function",
|
||||
function: {
|
||||
name: toString(itemObj.name),
|
||||
name: resolvedName,
|
||||
arguments: fnArgs,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -802,6 +802,9 @@ export default function ProviderDetailPage() {
|
||||
const userDismissed = useRef(false);
|
||||
const [proxyTarget, setProxyTarget] = useState(null);
|
||||
const [proxyConfig, setProxyConfig] = useState(null);
|
||||
const [connProxyMap, setConnProxyMap] = useState<
|
||||
Record<string, { proxy: any; level: string } | null>
|
||||
>({});
|
||||
const [importingModels, setImportingModels] = useState(false);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [importProgress, setImportProgress] = useState({
|
||||
@@ -938,18 +941,48 @@ export default function ProviderDetailPage() {
|
||||
useEffect(() => {
|
||||
fetchConnections();
|
||||
fetchAliases();
|
||||
// Load proxy config for visual indicators
|
||||
// Load proxy config for visual indicators (provider-level button)
|
||||
fetch("/api/settings/proxy")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((c) => setProxyConfig(c))
|
||||
.catch(() => {});
|
||||
}, [fetchConnections, fetchAliases]);
|
||||
|
||||
const loadConnProxies = useCallback(async (conns: { id?: string }[]) => {
|
||||
if (!conns.length) return;
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
conns
|
||||
.filter((c) => c.id)
|
||||
.map((c) =>
|
||||
fetch(`/api/settings/proxy?resolve=${encodeURIComponent(c.id!)}`, { cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => [c.id!, data] as [string, any])
|
||||
.catch(() => [c.id!, null] as [string, any])
|
||||
)
|
||||
);
|
||||
const map: Record<string, { proxy: any; level: string } | null> = {};
|
||||
for (const [id, data] of results) {
|
||||
map[id] = data?.proxy ? data : null;
|
||||
}
|
||||
setConnProxyMap(map);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || isSearchProvider) return;
|
||||
fetchProviderModelMeta();
|
||||
}, [loading, isSearchProvider, fetchProviderModelMeta]);
|
||||
|
||||
// Load per-connection effective proxy (handles registry assignments)
|
||||
useEffect(() => {
|
||||
if (!loading && connections.length > 0) {
|
||||
void loadConnProxies(connections);
|
||||
}
|
||||
}, [loading, connections, loadConnProxies]);
|
||||
|
||||
// Auto-open Add Connection modal when no connections exist (better UX)
|
||||
// Only fires once on initial load, not on HMR remounts or after user dismissal
|
||||
useEffect(() => {
|
||||
@@ -1930,68 +1963,153 @@ export default function ProviderDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
||||
{connections
|
||||
.sort((a, b) => (a.priority || 0) - (b.priority || 0))
|
||||
.map((conn, index) => (
|
||||
<ConnectionRow
|
||||
key={conn.id}
|
||||
connection={conn}
|
||||
isOAuth={isOAuth}
|
||||
isFirst={index === 0}
|
||||
isLast={index === connections.length - 1}
|
||||
onMoveUp={() => handleSwapPriority(conn, connections[index - 1])}
|
||||
onMoveDown={() => handleSwapPriority(conn, connections[index + 1])}
|
||||
onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
|
||||
onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)}
|
||||
isCodex={providerId === "codex"}
|
||||
onToggleCodex5h={(enabled) => handleToggleCodexLimit(conn.id, "use5h", enabled)}
|
||||
onToggleCodexWeekly={(enabled) =>
|
||||
handleToggleCodexLimit(conn.id, "useWeekly", enabled)
|
||||
}
|
||||
onRetest={() => handleRetestConnection(conn.id)}
|
||||
isRetesting={retestingId === conn.id}
|
||||
onEdit={() => {
|
||||
setSelectedConnection(conn);
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
onDelete={() => handleDelete(conn.id)}
|
||||
onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined}
|
||||
onRefreshToken={isOAuth ? () => handleRefreshToken(conn.id) : undefined}
|
||||
isRefreshing={refreshingId === conn.id}
|
||||
onProxy={() =>
|
||||
setProxyTarget({
|
||||
level: "key",
|
||||
id: conn.id,
|
||||
label: conn.name || conn.email || conn.id,
|
||||
})
|
||||
}
|
||||
hasProxy={
|
||||
!!(
|
||||
proxyConfig?.keys?.[conn.id] ||
|
||||
proxyConfig?.providers?.[providerId] ||
|
||||
proxyConfig?.global
|
||||
)
|
||||
}
|
||||
proxySource={
|
||||
proxyConfig?.keys?.[conn.id]
|
||||
? "key"
|
||||
: proxyConfig?.providers?.[providerId]
|
||||
? "provider"
|
||||
: proxyConfig?.global
|
||||
? "global"
|
||||
: null
|
||||
}
|
||||
proxyHost={
|
||||
(
|
||||
proxyConfig?.keys?.[conn.id] ||
|
||||
proxyConfig?.providers?.[providerId] ||
|
||||
proxyConfig?.global
|
||||
)?.host || null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
(() => {
|
||||
// Group connections by tag (providerSpecificData.tag)
|
||||
const sorted = [...connections].sort((a, b) => (a.priority || 0) - (b.priority || 0));
|
||||
const hasAnyTag = sorted.some((c) => c.providerSpecificData?.tag as string | undefined);
|
||||
|
||||
if (!hasAnyTag) {
|
||||
// No tags — render flat list as before
|
||||
return (
|
||||
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
||||
{sorted.map((conn, index) => (
|
||||
<ConnectionRow
|
||||
key={conn.id}
|
||||
connection={conn}
|
||||
isOAuth={isOAuth}
|
||||
isFirst={index === 0}
|
||||
isLast={index === sorted.length - 1}
|
||||
onMoveUp={() => handleSwapPriority(conn, sorted[index - 1])}
|
||||
onMoveDown={() => handleSwapPriority(conn, sorted[index + 1])}
|
||||
onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
|
||||
onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)}
|
||||
isCodex={providerId === "codex"}
|
||||
onToggleCodex5h={(enabled) =>
|
||||
handleToggleCodexLimit(conn.id, "use5h", enabled)
|
||||
}
|
||||
onToggleCodexWeekly={(enabled) =>
|
||||
handleToggleCodexLimit(conn.id, "useWeekly", enabled)
|
||||
}
|
||||
onRetest={() => handleRetestConnection(conn.id)}
|
||||
isRetesting={retestingId === conn.id}
|
||||
onEdit={() => {
|
||||
setSelectedConnection(conn);
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
onDelete={() => handleDelete(conn.id)}
|
||||
onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined}
|
||||
onRefreshToken={isOAuth ? () => handleRefreshToken(conn.id) : undefined}
|
||||
isRefreshing={refreshingId === conn.id}
|
||||
onProxy={() =>
|
||||
setProxyTarget({
|
||||
level: "key",
|
||||
id: conn.id,
|
||||
label: conn.name || conn.email || conn.id,
|
||||
})
|
||||
}
|
||||
hasProxy={!!connProxyMap[conn.id]?.proxy}
|
||||
proxySource={connProxyMap[conn.id]?.level || null}
|
||||
proxyHost={connProxyMap[conn.id]?.proxy?.host || null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Build ordered tag groups: untagged first, then alphabetically
|
||||
const groupMap = new Map<string, typeof sorted>();
|
||||
for (const conn of sorted) {
|
||||
const tag = (conn.providerSpecificData?.tag as string | undefined)?.trim() || "";
|
||||
if (!groupMap.has(tag)) groupMap.set(tag, []);
|
||||
groupMap.get(tag)!.push(conn);
|
||||
}
|
||||
const groupKeys = Array.from(groupMap.keys()).sort((a, b) => {
|
||||
if (a === "") return -1;
|
||||
if (b === "") return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0">
|
||||
{groupKeys.map((tag, gi) => {
|
||||
const groupConns = groupMap.get(tag)!;
|
||||
return (
|
||||
<div
|
||||
key={tag || "__untagged__"}
|
||||
className={
|
||||
gi > 0
|
||||
? "border-t border-black/[0.06] dark:border-white/[0.06] mt-1 pt-1"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{tag && (
|
||||
<div className="flex items-center gap-2 px-3 pt-2 pb-1">
|
||||
<span className="material-symbols-outlined text-[13px] text-text-muted/50">
|
||||
label
|
||||
</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-text-muted/60 select-none">
|
||||
{tag}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-black/[0.04] dark:bg-white/[0.04]" />
|
||||
<span className="text-[10px] text-text-muted/40">
|
||||
{groupConns.length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
||||
{groupConns.map((conn, index) => (
|
||||
<ConnectionRow
|
||||
key={conn.id}
|
||||
connection={conn}
|
||||
isOAuth={isOAuth}
|
||||
isFirst={gi === 0 && index === 0}
|
||||
isLast={gi === groupKeys.length - 1 && index === groupConns.length - 1}
|
||||
onMoveUp={() =>
|
||||
handleSwapPriority(conn, sorted[sorted.indexOf(conn) - 1])
|
||||
}
|
||||
onMoveDown={() =>
|
||||
handleSwapPriority(conn, sorted[sorted.indexOf(conn) + 1])
|
||||
}
|
||||
onToggleActive={(isActive) =>
|
||||
handleUpdateConnectionStatus(conn.id, isActive)
|
||||
}
|
||||
onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)}
|
||||
isCodex={providerId === "codex"}
|
||||
onToggleCodex5h={(enabled) =>
|
||||
handleToggleCodexLimit(conn.id, "use5h", enabled)
|
||||
}
|
||||
onToggleCodexWeekly={(enabled) =>
|
||||
handleToggleCodexLimit(conn.id, "useWeekly", enabled)
|
||||
}
|
||||
onRetest={() => handleRetestConnection(conn.id)}
|
||||
isRetesting={retestingId === conn.id}
|
||||
onEdit={() => {
|
||||
setSelectedConnection(conn);
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
onDelete={() => handleDelete(conn.id)}
|
||||
onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined}
|
||||
onRefreshToken={isOAuth ? () => handleRefreshToken(conn.id) : undefined}
|
||||
isRefreshing={refreshingId === conn.id}
|
||||
onProxy={() =>
|
||||
setProxyTarget({
|
||||
level: "key",
|
||||
id: conn.id,
|
||||
label: conn.name || conn.email || conn.id,
|
||||
})
|
||||
}
|
||||
hasProxy={!!connProxyMap[conn.id]?.proxy}
|
||||
proxySource={connProxyMap[conn.id]?.level || null}
|
||||
proxyHost={connProxyMap[conn.id]?.proxy?.host || null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -2188,6 +2306,7 @@ export default function ProviderDetailPage() {
|
||||
level={proxyTarget.level}
|
||||
levelId={proxyTarget.id}
|
||||
levelLabel={proxyTarget.label}
|
||||
onSaved={() => void loadConnProxies(connections)}
|
||||
/>
|
||||
)}
|
||||
{/* Import Progress Modal */}
|
||||
@@ -4130,6 +4249,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
baseUrl: "",
|
||||
region: "",
|
||||
validationModelId: "",
|
||||
tag: "",
|
||||
});
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState(null);
|
||||
@@ -4159,6 +4279,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
baseUrl: existingBaseUrl || (isBailian ? defaultBailianUrl : ""),
|
||||
region: existingRegion || (isVertex ? defaultRegion : ""),
|
||||
validationModelId: (connection.providerSpecificData?.validationModelId as string) || "",
|
||||
tag: (connection.providerSpecificData?.tag as string) || "",
|
||||
});
|
||||
// Load existing extra keys from providerSpecificData
|
||||
const existing = connection.providerSpecificData?.extraApiKeys;
|
||||
@@ -4282,6 +4403,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
updates.providerSpecificData = {
|
||||
...(connection.providerSpecificData || {}),
|
||||
extraApiKeys: extraApiKeys.filter((k) => k.trim().length > 0),
|
||||
tag: formData.tag.trim() || undefined,
|
||||
};
|
||||
if (formData.validationModelId) {
|
||||
updates.providerSpecificData.validationModelId = formData.validationModelId;
|
||||
@@ -4292,6 +4414,12 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
} else if (isVertex) {
|
||||
updates.providerSpecificData.region = formData.region;
|
||||
}
|
||||
} else {
|
||||
// Also persist tag for OAuth accounts
|
||||
updates.providerSpecificData = {
|
||||
...(connection.providerSpecificData || {}),
|
||||
tag: formData.tag.trim() || undefined,
|
||||
};
|
||||
}
|
||||
const error = (await onSave(updates)) as void | unknown;
|
||||
if (error) {
|
||||
@@ -4322,6 +4450,13 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={isOAuth ? t("accountName") : t("productionKey")}
|
||||
/>
|
||||
<Input
|
||||
label="Tag / Group"
|
||||
value={formData.tag}
|
||||
onChange={(e) => setFormData({ ...formData, tag: e.target.value })}
|
||||
placeholder="e.g. personal, work, team-a"
|
||||
hint="Used to group accounts in the provider view"
|
||||
/>
|
||||
{isOAuth && connection.email && (
|
||||
<div className="bg-sidebar/50 p-3 rounded-lg">
|
||||
<p className="text-sm text-text-muted mb-1">{t("email")}</p>
|
||||
|
||||
@@ -27,6 +27,14 @@ type HealthInfo = {
|
||||
lastSeenAt: string | null;
|
||||
};
|
||||
|
||||
type TestResult = {
|
||||
success: boolean;
|
||||
publicIp?: string;
|
||||
latencyMs?: number;
|
||||
country?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const EMPTY_FORM = {
|
||||
id: "",
|
||||
name: "",
|
||||
@@ -51,6 +59,8 @@ export default function ProxyRegistryManager() {
|
||||
|
||||
const [usageById, setUsageById] = useState<Record<string, UsageInfo>>({});
|
||||
const [healthById, setHealthById] = useState<Record<string, HealthInfo>>({});
|
||||
const [testById, setTestById] = useState<Record<string, TestResult | null>>({});
|
||||
const [testingId, setTestingId] = useState<string | null>(null);
|
||||
const [migrating, setMigrating] = useState(false);
|
||||
const [bulkOpen, setBulkOpen] = useState(false);
|
||||
const [bulkSaving, setBulkSaving] = useState(false);
|
||||
@@ -75,6 +85,36 @@ export default function ProxyRegistryManager() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAllUsage = useCallback(async (proxyIds: string[]) => {
|
||||
if (!proxyIds.length) return;
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
proxyIds.map((id) =>
|
||||
fetch(`/api/settings/proxies/assignments?proxyId=${encodeURIComponent(id)}`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
const rawAssignments: Array<{ scope: string; scopeId: string | null }> =
|
||||
Array.isArray(data?.items) ? data.items : [];
|
||||
// Deduplicate by scope+scopeId — prevents double-counting when both
|
||||
// a provider-scope and account-scope row exist for the same proxy
|
||||
const seen = new Set<string>();
|
||||
const assignments = rawAssignments.filter((a) => {
|
||||
const key = `${a.scope}:${a.scopeId ?? ""}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
return [id, { count: assignments.length, assignments }] as [string, UsageInfo];
|
||||
})
|
||||
.catch(() => [id, { count: 0, assignments: [] }] as [string, UsageInfo])
|
||||
)
|
||||
);
|
||||
setUsageById(Object.fromEntries(results));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -86,15 +126,18 @@ export default function ProxyRegistryManager() {
|
||||
setItems([]);
|
||||
return;
|
||||
}
|
||||
setItems(Array.isArray(data?.items) ? data.items : []);
|
||||
const loaded: ProxyItem[] = Array.isArray(data?.items) ? data.items : [];
|
||||
setItems(loaded);
|
||||
const ids = loaded.map((p) => p.id).filter(Boolean);
|
||||
void loadHealth();
|
||||
void loadAllUsage(ids);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load proxy registry");
|
||||
setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadHealth]);
|
||||
}, [loadHealth, loadAllUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -130,22 +173,63 @@ export default function ProxyRegistryManager() {
|
||||
const loadUsage = async (proxyId: string) => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/settings/proxies?id=${encodeURIComponent(proxyId)}&whereUsed=1`
|
||||
`/api/settings/proxies/assignments?proxyId=${encodeURIComponent(proxyId)}`
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) return;
|
||||
const rawAssignments: Array<{ scope: string; scopeId: string | null }> = Array.isArray(
|
||||
data?.items
|
||||
)
|
||||
? data.items
|
||||
: [];
|
||||
const seen = new Set<string>();
|
||||
const assignments = rawAssignments.filter((a) => {
|
||||
const key = `${a.scope}:${a.scopeId ?? ""}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
setUsageById((prev) => ({
|
||||
...prev,
|
||||
[proxyId]: {
|
||||
count: Number(data?.count || 0),
|
||||
assignments: Array.isArray(data?.assignments) ? data.assignments : [],
|
||||
},
|
||||
[proxyId]: { count: assignments.length, assignments },
|
||||
}));
|
||||
} catch {
|
||||
// ignore usage loading errors in UI
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestProxy = async (item: ProxyItem) => {
|
||||
if (testingId) return;
|
||||
setTestingId(item.id);
|
||||
setTestById((prev) => ({ ...prev, [item.id]: null }));
|
||||
try {
|
||||
const res = await fetch("/api/settings/proxy/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
proxy: {
|
||||
type: item.type || "http",
|
||||
host: item.host,
|
||||
port: String(item.port || 8080),
|
||||
},
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setTestById((prev) => ({
|
||||
...prev,
|
||||
[item.id]: { success: false, error: data?.error?.message || "Test failed" },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
setTestById((prev) => ({ ...prev, [item.id]: { success: true, ...data } }));
|
||||
} catch (e: any) {
|
||||
setTestById((prev) => ({ ...prev, [item.id]: { success: false, error: e?.message } }));
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim() || !form.host.trim()) {
|
||||
setError("Name and host are required");
|
||||
@@ -378,27 +462,47 @@ export default function ProxyRegistryManager() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-xs text-text-muted">
|
||||
{health ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span>{health.successRate ?? 0}% success</span>
|
||||
<span>{health.avgLatencyMs ?? "-"} ms avg</span>
|
||||
</div>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{health ? (
|
||||
<>
|
||||
<span>{health.successRate ?? 0}% success</span>
|
||||
<span>{health.avgLatencyMs ?? "-"} ms avg</span>
|
||||
</>
|
||||
) : 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 || "failed"}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span>—</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-xs text-text-muted">
|
||||
{usage ? `${usage.count} assignment(s)` : "-"}
|
||||
{usageById[item.id] != null
|
||||
? `${usageById[item.id].count} assignment(s)`
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="visibility"
|
||||
onClick={() => void loadUsage(item.id)}
|
||||
icon="speed"
|
||||
onClick={() => void handleTestProxy(item)}
|
||||
loading={testingId === item.id}
|
||||
>
|
||||
Usage
|
||||
Test
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -245,6 +245,7 @@ export default function ProxyConfigModal({
|
||||
setSelectedProxyId("");
|
||||
}
|
||||
onSaved?.();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Error saving proxy:", error);
|
||||
setFormError(error.message || "Failed to save proxy configuration");
|
||||
@@ -281,6 +282,7 @@ export default function ProxyConfigModal({
|
||||
setSelectedProxyId("");
|
||||
setTestResult(null);
|
||||
onSaved?.();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Error clearing proxy:", error);
|
||||
setFormError(error.message || "Failed to clear proxy configuration");
|
||||
@@ -290,22 +292,49 @@ export default function ProxyConfigModal({
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
if (mode === "saved") {
|
||||
setFormError("Use custom mode to run manual connection test.");
|
||||
return;
|
||||
}
|
||||
if (!host.trim()) return;
|
||||
setFormError(null);
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const proxy = {
|
||||
type: proxyType,
|
||||
host: host.trim(),
|
||||
port: port.trim() || getDefaultPort(proxyType),
|
||||
username: username.trim(),
|
||||
password: password.trim(),
|
||||
};
|
||||
let proxy: {
|
||||
type: string;
|
||||
host: string;
|
||||
port: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
} | null = null;
|
||||
|
||||
if (mode === "saved") {
|
||||
if (!selectedProxyId) {
|
||||
setFormError("Select a saved proxy first.");
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
const found = (savedProxies as any[]).find((p: any) => p.id === selectedProxyId);
|
||||
if (!found) {
|
||||
setFormError("Selected proxy not found.");
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
proxy = {
|
||||
type: found.type || "http",
|
||||
host: found.host || "",
|
||||
port: String(found.port || 8080),
|
||||
};
|
||||
} else {
|
||||
if (!host.trim()) {
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
proxy = {
|
||||
type: proxyType,
|
||||
host: host.trim(),
|
||||
port: port.trim() || getDefaultPort(proxyType),
|
||||
username: username.trim(),
|
||||
password: password.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const res = await fetch("/api/settings/proxy/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -550,7 +579,7 @@ export default function ProxyConfigModal({
|
||||
icon="speed"
|
||||
onClick={handleTest}
|
||||
loading={testing}
|
||||
disabled={mode !== "custom" || !host.trim()}
|
||||
disabled={mode === "saved" ? !selectedProxyId : !host.trim()}
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user