feat(proxy): add proxy pool and per-connection binding + strictProxy support
- Centralize proxy management with reusable proxy pools - Per-connection proxy binding with legacy fallback - Add strictProxy option: fail hard instead of silently falling back to direct - Resolve alicode-intl conflict: keep alicode-intl support + proxy support Made-with: Cursor
This commit is contained in:
1 parent
4c469291a1
commit
880f4eca91
22 files changed
+1811
-146
No files matched your search
@@ -17,11 +17,13 @@ export default function ProviderDetailPage() {
|
||||
const [connections, setConnections] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [providerNode, setProviderNode] = useState(null);
|
||||
const [proxyPools, setProxyPools] = useState([]);
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false);
|
||||
const [showIFlowCookieModal, setShowIFlowCookieModal] = useState(false);
|
||||
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showEditNodeModal, setShowEditNodeModal] = useState(false);
|
||||
const [showBulkProxyModal, setShowBulkProxyModal] = useState(false);
|
||||
const [selectedConnection, setSelectedConnection] = useState(null);
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
const [headerImgError, setHeaderImgError] = useState(false);
|
||||
@@ -29,6 +31,9 @@ export default function ProviderDetailPage() {
|
||||
const [modelsTestError, setModelsTestError] = useState("");
|
||||
const [testingModelId, setTestingModelId] = useState(null);
|
||||
const [showAddCustomModel, setShowAddCustomModel] = useState(false);
|
||||
const [selectedConnectionIds, setSelectedConnectionIds] = useState([]);
|
||||
const [bulkProxyPoolId, setBulkProxyPoolId] = useState("__none__");
|
||||
const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false);
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
const providerInfo = providerNode
|
||||
@@ -70,16 +75,21 @@ export default function ProviderDetailPage() {
|
||||
|
||||
const fetchConnections = useCallback(async () => {
|
||||
try {
|
||||
const [connectionsRes, nodesRes] = await Promise.all([
|
||||
const [connectionsRes, nodesRes, proxyPoolsRes] = await Promise.all([
|
||||
fetch("/api/providers", { cache: "no-store" }),
|
||||
fetch("/api/provider-nodes", { cache: "no-store" }),
|
||||
fetch("/api/proxy-pools?isActive=true", { cache: "no-store" }),
|
||||
]);
|
||||
const connectionsData = await connectionsRes.json();
|
||||
const nodesData = await nodesRes.json();
|
||||
const proxyPoolsData = await proxyPoolsRes.json();
|
||||
if (connectionsRes.ok) {
|
||||
const filtered = (connectionsData.connections || []).filter(c => c.provider === providerId);
|
||||
setConnections(filtered);
|
||||
}
|
||||
if (proxyPoolsRes.ok) {
|
||||
setProxyPools(proxyPoolsData.proxyPools || []);
|
||||
}
|
||||
if (nodesRes.ok) {
|
||||
let node = (nodesData.nodes || []).find((entry) => entry.id === providerId) || null;
|
||||
|
||||
@@ -266,6 +276,201 @@ export default function ProviderDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const selectedConnections = connections.filter((conn) => selectedConnectionIds.includes(conn.id));
|
||||
const allSelected = connections.length > 0 && selectedConnectionIds.length === connections.length;
|
||||
|
||||
const toggleSelectConnection = (connectionId) => {
|
||||
setSelectedConnectionIds((prev) => (
|
||||
prev.includes(connectionId)
|
||||
? prev.filter((id) => id !== connectionId)
|
||||
: [...prev, connectionId]
|
||||
));
|
||||
};
|
||||
|
||||
const toggleSelectAllConnections = () => {
|
||||
if (allSelected) {
|
||||
setSelectedConnectionIds([]);
|
||||
return;
|
||||
}
|
||||
setSelectedConnectionIds(connections.map((conn) => conn.id));
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedConnectionIds([]);
|
||||
setBulkProxyPoolId("__none__");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedConnectionIds((prev) => prev.filter((id) => connections.some((conn) => conn.id === id)));
|
||||
}, [connections]);
|
||||
|
||||
const selectedProxySummary = (() => {
|
||||
if (selectedConnections.length === 0) return "";
|
||||
const poolIds = new Set(selectedConnections.map((conn) => conn.providerSpecificData?.proxyPoolId || "__none__"));
|
||||
if (poolIds.size === 1) {
|
||||
const onlyId = [...poolIds][0];
|
||||
if (onlyId === "__none__") return "All selected currently unbound";
|
||||
const pool = proxyPools.find((p) => p.id === onlyId);
|
||||
return `All selected currently bound to ${pool?.name || onlyId}`;
|
||||
}
|
||||
return "Selected connections have mixed proxy bindings";
|
||||
})();
|
||||
|
||||
const openBulkProxyModal = () => {
|
||||
if (selectedConnections.length === 0) return;
|
||||
const uniquePoolIds = [...new Set(selectedConnections.map((conn) => conn.providerSpecificData?.proxyPoolId || "__none__"))];
|
||||
setBulkProxyPoolId(uniquePoolIds.length === 1 ? uniquePoolIds[0] : "__none__");
|
||||
setShowBulkProxyModal(true);
|
||||
};
|
||||
|
||||
const closeBulkProxyModal = () => {
|
||||
if (bulkUpdatingProxy) return;
|
||||
setShowBulkProxyModal(false);
|
||||
};
|
||||
|
||||
const handleBulkApplyProxyPool = async () => {
|
||||
if (selectedConnectionIds.length === 0) return;
|
||||
|
||||
const proxyPoolId = bulkProxyPoolId === "__none__" ? null : bulkProxyPoolId;
|
||||
setBulkUpdatingProxy(true);
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
selectedConnectionIds.map(async (connectionId) => {
|
||||
const res = await fetch(`/api/providers/${connectionId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ proxyPoolId }),
|
||||
});
|
||||
return res.ok;
|
||||
})
|
||||
);
|
||||
|
||||
const failedCount = results.filter((ok) => !ok).length;
|
||||
if (failedCount > 0) {
|
||||
alert(`Updated with ${failedCount} failed request(s).`);
|
||||
}
|
||||
|
||||
await fetchConnections();
|
||||
clearSelection();
|
||||
setShowBulkProxyModal(false);
|
||||
} catch (error) {
|
||||
console.log("Error applying bulk proxy pool:", error);
|
||||
} finally {
|
||||
setBulkUpdatingProxy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectionToolbar = connections.length > 0 ? (
|
||||
<div className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-3 mb-3 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSelectAllConnections}
|
||||
className="text-xs px-2 py-1 rounded bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 text-text-muted"
|
||||
>
|
||||
{allSelected ? "Unselect all" : "Select all"}
|
||||
</button>
|
||||
{selectedConnectionIds.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSelection}
|
||||
className="text-xs px-2 py-1 rounded bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 text-text-muted"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted">{selectedConnectionIds.length} selected</span>
|
||||
<Button size="sm" variant="secondary" onClick={openBulkProxyModal} disabled={selectedConnectionIds.length === 0}>
|
||||
Proxy Action
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const isSelected = (connectionId) => selectedConnectionIds.includes(connectionId);
|
||||
|
||||
const connectionsList = (
|
||||
<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) => (
|
||||
<div key={conn.id} className="flex items-stretch">
|
||||
<div className="pr-2 flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected(conn.id)}
|
||||
onChange={() => toggleSelectConnection(conn.id)}
|
||||
className="size-4 rounded border-border bg-transparent"
|
||||
title="Select connection"
|
||||
aria-label={`Select ${conn.name || conn.email || conn.id}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<ConnectionRow
|
||||
connection={conn}
|
||||
proxyPools={proxyPools}
|
||||
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)}
|
||||
onEdit={() => {
|
||||
setSelectedConnection(conn);
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
onDelete={() => handleDelete(conn.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const bulkProxyOptions = [
|
||||
{ value: "__none__", label: "None" },
|
||||
...proxyPools.map((pool) => ({ value: pool.id, label: pool.name })),
|
||||
];
|
||||
|
||||
const bulkHint = selectedConnectionIds.length === 0
|
||||
? "Select one or more connections, then click Proxy Action."
|
||||
: selectedProxySummary;
|
||||
|
||||
const canApplyBulkProxy = selectedConnectionIds.length > 0 && !bulkUpdatingProxy;
|
||||
|
||||
const bulkActionModal = (
|
||||
<Modal
|
||||
isOpen={showBulkProxyModal}
|
||||
onClose={closeBulkProxyModal}
|
||||
title={`Proxy Action (${selectedConnectionIds.length} selected)`}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Select
|
||||
label="Proxy Pool"
|
||||
value={bulkProxyPoolId}
|
||||
onChange={(e) => setBulkProxyPoolId(e.target.value)}
|
||||
options={bulkProxyOptions}
|
||||
placeholder="None"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-text-muted">{bulkHint}</p>
|
||||
<p className="text-xs text-text-muted">Selecting None will unbind selected connections from proxy pool.</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleBulkApplyProxyPool} fullWidth disabled={!canApplyBulkProxy}>
|
||||
{bulkUpdatingProxy ? "Applying..." : "Apply"}
|
||||
</Button>
|
||||
<Button onClick={closeBulkProxyModal} variant="ghost" fullWidth disabled={bulkUpdatingProxy}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const handleTestModel = async (modelId) => {
|
||||
if (testingModelId) return;
|
||||
setTestingModelId(modelId);
|
||||
@@ -560,27 +765,10 @@ 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)}
|
||||
onEdit={() => {
|
||||
setSelectedConnection(conn);
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
onDelete={() => handleDelete(conn.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
{selectionToolbar}
|
||||
{connectionsList}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -597,6 +785,8 @@ export default function ProviderDetailPage() {
|
||||
{renderModelsSection()}
|
||||
</Card>
|
||||
|
||||
{bulkActionModal}
|
||||
|
||||
{/* Modals */}
|
||||
{providerId === "kiro" ? (
|
||||
<KiroOAuthWrapper
|
||||
@@ -633,12 +823,14 @@ export default function ProviderDetailPage() {
|
||||
providerName={providerInfo.name}
|
||||
isCompatible={isCompatible}
|
||||
isAnthropic={isAnthropicCompatible}
|
||||
proxyPools={proxyPools}
|
||||
onSave={handleSaveApiKey}
|
||||
onClose={() => setShowAddApiKeyModal(false)}
|
||||
/>
|
||||
<EditConnectionModal
|
||||
isOpen={showEditModal}
|
||||
connection={selectedConnection}
|
||||
proxyPools={proxyPools}
|
||||
onSave={handleUpdateConnection}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
/>
|
||||
@@ -1126,7 +1318,40 @@ CooldownTimer.propTypes = {
|
||||
until: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
function ConnectionRow({ connection, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onEdit, onDelete }) {
|
||||
function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onEdit, onDelete }) {
|
||||
const proxyPoolMap = new Map((proxyPools || []).map((pool) => [pool.id, pool]));
|
||||
const boundProxyPoolId = connection.providerSpecificData?.proxyPoolId || null;
|
||||
const boundProxyPool = boundProxyPoolId ? proxyPoolMap.get(boundProxyPoolId) : null;
|
||||
const hasLegacyProxy = connection.providerSpecificData?.connectionProxyEnabled === true && !!connection.providerSpecificData?.connectionProxyUrl;
|
||||
const hasAnyProxy = !!boundProxyPoolId || hasLegacyProxy;
|
||||
const proxyDisplayText = boundProxyPool
|
||||
? `Pool: ${boundProxyPool.name}`
|
||||
: boundProxyPoolId
|
||||
? `Pool: ${boundProxyPoolId} (inactive/missing)`
|
||||
: hasLegacyProxy
|
||||
? `Legacy: ${connection.providerSpecificData?.connectionProxyUrl}`
|
||||
: "";
|
||||
|
||||
let proxyBadgeVariant = "default";
|
||||
if (boundProxyPool?.isActive === true) {
|
||||
proxyBadgeVariant = "success";
|
||||
} else if (boundProxyPoolId || hasLegacyProxy) {
|
||||
proxyBadgeVariant = "error";
|
||||
}
|
||||
|
||||
let maskedProxyUrl = "";
|
||||
if (boundProxyPool?.proxyUrl || connection.providerSpecificData?.connectionProxyUrl) {
|
||||
const rawProxyUrl = boundProxyPool?.proxyUrl || connection.providerSpecificData?.connectionProxyUrl;
|
||||
try {
|
||||
const parsed = new URL(rawProxyUrl);
|
||||
maskedProxyUrl = `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}`;
|
||||
} catch {
|
||||
maskedProxyUrl = rawProxyUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const noProxyText = boundProxyPool?.noProxy || connection.providerSpecificData?.connectionNoProxy || "";
|
||||
|
||||
const displayName = isOAuth
|
||||
? connection.name || connection.email || connection.displayName || "OAuth Account"
|
||||
: connection.name;
|
||||
@@ -1199,6 +1424,11 @@ function ConnectionRow({ connection, isOAuth, isFirst, isLast, onMoveUp, onMoveD
|
||||
<Badge variant={getStatusVariant()} size="sm" dot>
|
||||
{connection.isActive === false ? "disabled" : (effectiveStatus || "Unknown")}
|
||||
</Badge>
|
||||
{hasAnyProxy && (
|
||||
<Badge variant={proxyBadgeVariant} size="sm">
|
||||
Proxy
|
||||
</Badge>
|
||||
)}
|
||||
{isCooldown && connection.isActive !== false && <CooldownTimer until={modelLockUntil} />}
|
||||
{connection.lastError && connection.isActive !== false && (
|
||||
<span className="text-xs text-red-500 truncate max-w-[300px]" title={connection.lastError}>
|
||||
@@ -1210,6 +1440,23 @@ function ConnectionRow({ connection, isOAuth, isFirst, isLast, onMoveUp, onMoveD
|
||||
<span className="text-xs text-text-muted">Auto: {connection.globalPriority}</span>
|
||||
)}
|
||||
</div>
|
||||
{hasAnyProxy && (
|
||||
<div className="mt-1 flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[11px] text-text-muted truncate max-w-[420px]" title={proxyDisplayText}>
|
||||
{proxyDisplayText}
|
||||
</span>
|
||||
{maskedProxyUrl && (
|
||||
<code className="text-[10px] font-mono bg-black/5 dark:bg-white/5 px-1 py-0.5 rounded text-text-muted">
|
||||
{maskedProxyUrl}
|
||||
</code>
|
||||
)}
|
||||
{noProxyText && (
|
||||
<span className="text-[11px] text-text-muted truncate max-w-[320px]" title={noProxyText}>
|
||||
no_proxy: {noProxyText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1245,6 +1492,13 @@ ConnectionRow.propTypes = {
|
||||
priority: PropTypes.number,
|
||||
globalPriority: PropTypes.number,
|
||||
}).isRequired,
|
||||
proxyPools: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
proxyUrl: PropTypes.string,
|
||||
noProxy: PropTypes.string,
|
||||
isActive: PropTypes.bool,
|
||||
})),
|
||||
isOAuth: PropTypes.bool.isRequired,
|
||||
isFirst: PropTypes.bool.isRequired,
|
||||
isLast: PropTypes.bool.isRequired,
|
||||
@@ -1255,11 +1509,14 @@ ConnectionRow.propTypes = {
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, onSave, onClose }) {
|
||||
function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, proxyPools, onSave, onClose }) {
|
||||
const NONE_PROXY_POOL_VALUE = "__none__";
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
apiKey: "",
|
||||
priority: 1,
|
||||
proxyPoolId: NONE_PROXY_POOL_VALUE,
|
||||
});
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState(null);
|
||||
@@ -1309,6 +1566,7 @@ function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthro
|
||||
name: formData.name,
|
||||
apiKey: formData.apiKey,
|
||||
priority: formData.priority,
|
||||
proxyPoolId: formData.proxyPoolId === NONE_PROXY_POOL_VALUE ? null : formData.proxyPoolId,
|
||||
testStatus: isValid ? "active" : "unknown",
|
||||
});
|
||||
} finally {
|
||||
@@ -1360,6 +1618,28 @@ function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthro
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Proxy Pool"
|
||||
value={formData.proxyPoolId}
|
||||
onChange={(e) => setFormData({ ...formData, proxyPoolId: e.target.value })}
|
||||
options={[
|
||||
{ value: NONE_PROXY_POOL_VALUE, label: "None" },
|
||||
...(proxyPools || []).map((pool) => ({ value: pool.id, label: pool.name })),
|
||||
]}
|
||||
placeholder="None"
|
||||
/>
|
||||
|
||||
{(proxyPools || []).length === 0 && (
|
||||
<p className="text-xs text-text-muted">
|
||||
No active proxy pools available. Create one in Proxy Pools page first.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-text-muted">
|
||||
Legacy manual proxy fields are still accepted by API for backward compatibility.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSubmit} fullWidth disabled={!formData.name || !formData.apiKey || saving}>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
@@ -1379,15 +1659,22 @@ AddApiKeyModal.propTypes = {
|
||||
providerName: PropTypes.string,
|
||||
isCompatible: PropTypes.bool,
|
||||
isAnthropic: PropTypes.bool,
|
||||
proxyPools: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
})),
|
||||
onSave: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
function EditConnectionModal({ isOpen, connection, proxyPools, onSave, onClose }) {
|
||||
const NONE_PROXY_POOL_VALUE = "__none__";
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
priority: 1,
|
||||
apiKey: "",
|
||||
proxyPoolId: NONE_PROXY_POOL_VALUE,
|
||||
});
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState(null);
|
||||
@@ -1401,6 +1688,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
name: connection.name || "",
|
||||
priority: connection.priority || 1,
|
||||
apiKey: "",
|
||||
proxyPoolId: connection.providerSpecificData?.proxyPoolId || NONE_PROXY_POOL_VALUE,
|
||||
});
|
||||
setTestResult(null);
|
||||
setValidationResult(null);
|
||||
@@ -1444,7 +1732,11 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
const handleSubmit = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const updates = { name: formData.name, priority: formData.priority };
|
||||
const updates = {
|
||||
name: formData.name,
|
||||
priority: formData.priority,
|
||||
proxyPoolId: formData.proxyPoolId === NONE_PROXY_POOL_VALUE ? null : formData.proxyPoolId,
|
||||
};
|
||||
if (!isOAuth && formData.apiKey) {
|
||||
updates.apiKey = formData.apiKey;
|
||||
let isValid = validationResult === "success";
|
||||
@@ -1504,6 +1796,59 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Proxy Pool"
|
||||
value={formData.proxyPoolId}
|
||||
onChange={(e) => setFormData({ ...formData, proxyPoolId: e.target.value })}
|
||||
options={[
|
||||
{ value: NONE_PROXY_POOL_VALUE, label: "None" },
|
||||
...(proxyPools || []).map((pool) => ({ value: pool.id, label: pool.name })),
|
||||
]}
|
||||
placeholder="None"
|
||||
/>
|
||||
|
||||
{(proxyPools || []).length === 0 && (
|
||||
<p className="text-xs text-text-muted">
|
||||
No active proxy pools available. Create one in Proxy Pools page first.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-text-muted">
|
||||
Runtime prefers proxy pool settings. Legacy proxy fields are still supported as fallback.
|
||||
</p>
|
||||
|
||||
{connection.providerSpecificData?.connectionProxyEnabled === true && !connection.providerSpecificData?.proxyPoolId && (
|
||||
<p className="text-xs text-amber-500">
|
||||
This connection is still using legacy manual proxy settings until you bind a proxy pool.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{connection.providerSpecificData?.proxyPoolId && formData.proxyPoolId === NONE_PROXY_POOL_VALUE && (
|
||||
<p className="text-xs text-amber-500">
|
||||
Saving with None will unbind this connection from proxy pool and fallback to legacy/global proxy behavior.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{connection.providerSpecificData?.proxyPoolId && formData.proxyPoolId !== NONE_PROXY_POOL_VALUE && connection.providerSpecificData?.proxyPoolId !== formData.proxyPoolId && (
|
||||
<p className="text-xs text-amber-500">
|
||||
You changed proxy pool binding. Use Test Connection to verify connectivity.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{connection.providerSpecificData?.proxyPoolId && !(proxyPools || []).some((pool) => pool.id === connection.providerSpecificData.proxyPoolId) && (
|
||||
<p className="text-xs text-red-500">
|
||||
Current bound proxy pool is inactive or missing. Runtime will fallback to legacy proxy if available.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!connection.providerSpecificData?.proxyPoolId && (connection.providerSpecificData?.connectionProxyUrl || connection.providerSpecificData?.connectionNoProxy) && (
|
||||
<p className="text-xs text-text-muted">
|
||||
Legacy proxy: {connection.providerSpecificData?.connectionProxyUrl || "(empty)"}
|
||||
{connection.providerSpecificData?.connectionNoProxy ? ` · no_proxy: ${connection.providerSpecificData.connectionNoProxy}` : ""}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isOAuth && (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
@@ -1562,7 +1907,12 @@ EditConnectionModal.propTypes = {
|
||||
priority: PropTypes.number,
|
||||
authType: PropTypes.string,
|
||||
provider: PropTypes.string,
|
||||
providerSpecificData: PropTypes.object,
|
||||
}),
|
||||
proxyPools: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
})),
|
||||
onSave: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Badge, Button, Card, CardSkeleton, Input, Modal, Toggle } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
|
||||
function getStatusVariant(status) {
|
||||
if (status === "active") return "success";
|
||||
if (status === "error") return "error";
|
||||
return "default";
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return "Never";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "Never";
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function normalizeFormData(data = {}) {
|
||||
return {
|
||||
name: data.name || "",
|
||||
proxyUrl: data.proxyUrl || "",
|
||||
noProxy: data.noProxy || "",
|
||||
isActive: data.isActive !== false,
|
||||
strictProxy: data.strictProxy === true,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ProxyPoolsPage() {
|
||||
const [proxyPools, setProxyPools] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showFormModal, setShowFormModal] = useState(false);
|
||||
const [showBatchImportModal, setShowBatchImportModal] = useState(false);
|
||||
const [editingProxyPool, setEditingProxyPool] = useState(null);
|
||||
const [formData, setFormData] = useState(normalizeFormData());
|
||||
const [batchImportText, setBatchImportText] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [testingId, setTestingId] = useState(null);
|
||||
const notify = useNotificationStore();
|
||||
|
||||
const fetchProxyPools = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/proxy-pools?includeUsage=true", { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setProxyPools(data.proxyPools || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching proxy pools:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProxyPools();
|
||||
}, [fetchProxyPools]);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingProxyPool(null);
|
||||
setFormData(normalizeFormData());
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
resetForm();
|
||||
setShowFormModal(true);
|
||||
};
|
||||
|
||||
const openEditModal = (proxyPool) => {
|
||||
setEditingProxyPool(proxyPool);
|
||||
setFormData(normalizeFormData(proxyPool));
|
||||
setShowFormModal(true);
|
||||
};
|
||||
|
||||
const closeFormModal = () => {
|
||||
setShowFormModal(false);
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const payload = {
|
||||
name: formData.name.trim(),
|
||||
proxyUrl: formData.proxyUrl.trim(),
|
||||
noProxy: formData.noProxy.trim(),
|
||||
isActive: formData.isActive === true,
|
||||
strictProxy: formData.strictProxy === true,
|
||||
};
|
||||
|
||||
if (!payload.name || !payload.proxyUrl) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const isEdit = !!editingProxyPool;
|
||||
const res = await fetch(isEdit ? `/api/proxy-pools/${editingProxyPool.id}` : "/api/proxy-pools", {
|
||||
method: isEdit ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
await fetchProxyPools();
|
||||
closeFormModal();
|
||||
notify.success(editingProxyPool ? "Proxy pool updated" : "Proxy pool created");
|
||||
} else {
|
||||
const data = await res.json();
|
||||
notify.error(data.error || "Failed to save proxy pool");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error saving proxy pool:", error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (proxyPool) => {
|
||||
const deleting = confirm(`Delete proxy pool \"${proxyPool.name}\"?`);
|
||||
if (!deleting) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/proxy-pools/${proxyPool.id}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
setProxyPools((prev) => prev.filter((item) => item.id !== proxyPool.id));
|
||||
notify.success("Proxy pool deleted");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (res.status === 409) {
|
||||
notify.warning(`Cannot delete: ${data.boundConnectionCount || 0} connection(s) are still using this pool.`);
|
||||
} else {
|
||||
notify.error(data.error || "Failed to delete proxy pool");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error deleting proxy pool:", error);
|
||||
notify.error("Failed to delete proxy pool");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (proxyPoolId) => {
|
||||
setTestingId(proxyPoolId);
|
||||
try {
|
||||
const res = await fetch(`/api/proxy-pools/${proxyPoolId}/test`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
notify.error(data.error || "Failed to test proxy");
|
||||
return;
|
||||
}
|
||||
|
||||
await fetchProxyPools();
|
||||
notify.success(data.ok ? "Proxy test passed" : "Proxy test failed");
|
||||
} catch (error) {
|
||||
console.log("Error testing proxy pool:", error);
|
||||
notify.error("Failed to test proxy");
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openBatchImportModal = () => {
|
||||
setBatchImportText("");
|
||||
setShowBatchImportModal(true);
|
||||
};
|
||||
|
||||
const closeBatchImportModal = () => {
|
||||
if (importing) return;
|
||||
setShowBatchImportModal(false);
|
||||
};
|
||||
|
||||
const parseProxyLine = (line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (trimmed.includes("://")) {
|
||||
const parsed = new URL(trimmed);
|
||||
const hostLabel = parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname;
|
||||
return {
|
||||
proxyUrl: parsed.toString(),
|
||||
name: `Imported ${hostLabel}`,
|
||||
};
|
||||
}
|
||||
|
||||
const parts = trimmed.split(":");
|
||||
if (parts.length === 4) {
|
||||
const [host, port, username, password] = parts;
|
||||
if (!host || !port || !username || !password) {
|
||||
throw new Error("Invalid host:port:user:pass format");
|
||||
}
|
||||
|
||||
const proxyUrl = `http://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}`;
|
||||
const parsed = new URL(proxyUrl);
|
||||
return {
|
||||
proxyUrl: parsed.toString(),
|
||||
name: `Imported ${host}:${port}`,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("Unsupported format");
|
||||
};
|
||||
|
||||
const handleBatchImport = async () => {
|
||||
const lines = batchImportText
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (lines.length === 0) {
|
||||
notify.warning("Please paste at least one proxy line.");
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedEntries = [];
|
||||
const invalidLines = [];
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
try {
|
||||
const parsed = parseProxyLine(line);
|
||||
if (parsed) {
|
||||
parsedEntries.push({
|
||||
...parsed,
|
||||
lineNumber: index + 1,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
invalidLines.push(`Line ${index + 1}: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (invalidLines.length > 0) {
|
||||
notify.error(`Invalid proxy format:\n${invalidLines.join("\n")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const existingKeys = new Set(
|
||||
proxyPools.map((pool) => `${(pool.proxyUrl || "").trim()}|||${(pool.noProxy || "").trim()}`)
|
||||
);
|
||||
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const entry of parsedEntries) {
|
||||
const dedupeKey = `${entry.proxyUrl}|||`;
|
||||
if (existingKeys.has(dedupeKey)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/proxy-pools", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: entry.name,
|
||||
proxyUrl: entry.proxyUrl,
|
||||
noProxy: "",
|
||||
isActive: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
created += 1;
|
||||
existingKeys.add(dedupeKey);
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
await fetchProxyPools();
|
||||
setShowBatchImportModal(false);
|
||||
notify.success(`Batch import completed: Created ${created}, Skipped ${skipped}, Failed ${failed}`);
|
||||
} catch (error) {
|
||||
console.log("Error batch importing proxies:", error);
|
||||
notify.error("Batch import failed");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const activeCount = useMemo(
|
||||
() => proxyPools.filter((pool) => pool.isActive === true).length,
|
||||
[proxyPools]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Proxy Pools</h1>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Manage reusable per-connection proxies and bind them to provider connections.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" icon="upload" onClick={openBatchImportModal}>
|
||||
Batch Import Proxies
|
||||
</Button>
|
||||
<Button icon="add" onClick={openCreateModal}>Add Proxy Pool</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="default">Total: {proxyPools.length}</Badge>
|
||||
<Badge variant="success">Active: {activeCount}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{proxyPools.length === 0 ? (
|
||||
<div className="text-center py-10">
|
||||
<p className="text-text-main font-medium mb-1">No proxy pool entries yet</p>
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
Create a proxy pool entry, then assign it to connections.
|
||||
</p>
|
||||
<Button icon="add" onClick={openCreateModal}>Add Proxy Pool</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-black/[0.04] dark:divide-white/[0.05]">
|
||||
{proxyPools.map((pool) => (
|
||||
<div key={pool.id} className="py-3 flex items-center justify-between gap-3 group">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-sm font-medium truncate">{pool.name}</p>
|
||||
<Badge variant={getStatusVariant(pool.testStatus)} size="sm" dot>
|
||||
{pool.testStatus || "unknown"}
|
||||
</Badge>
|
||||
<Badge variant={pool.isActive ? "success" : "default"} size="sm">
|
||||
{pool.isActive ? "active" : "inactive"}
|
||||
</Badge>
|
||||
<Badge variant="default" size="sm">
|
||||
{pool.boundConnectionCount || 0} bound
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate mt-1">{pool.proxyUrl}</p>
|
||||
{pool.noProxy ? (
|
||||
<p className="text-xs text-text-muted truncate">No proxy: {pool.noProxy}</p>
|
||||
) : null}
|
||||
<p className="text-[11px] text-text-muted mt-1">
|
||||
Last tested: {formatDateTime(pool.lastTestedAt)}
|
||||
{pool.lastError ? ` · ${pool.lastError}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleTest(pool.id)}
|
||||
className="p-2 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-primary"
|
||||
title="Test proxy"
|
||||
disabled={testingId === pool.id}
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-[18px]"
|
||||
style={testingId === pool.id ? { animation: "spin 1s linear infinite" } : undefined}
|
||||
>
|
||||
{testingId === pool.id ? "progress_activity" : "science"}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditModal(pool)}
|
||||
className="p-2 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-primary"
|
||||
title="Edit"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">edit</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(pool)}
|
||||
className="p-2 rounded hover:bg-red-500/10 text-red-500"
|
||||
title="Delete"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
isOpen={showBatchImportModal}
|
||||
title="Batch Import Proxies"
|
||||
onClose={closeBatchImportModal}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-text-main mb-1 block">Paste Proxy List (One per line)</label>
|
||||
<textarea
|
||||
value={batchImportText}
|
||||
onChange={(e) => setBatchImportText(e.target.value)}
|
||||
placeholder={"http://user:pass@127.0.0.1:7897\n127.0.0.1:7897:user:pass"}
|
||||
className="w-full min-h-[180px] py-2 px-3 text-sm text-text-main bg-white dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-md focus:ring-1 focus:ring-primary/30 focus:border-primary/50 focus:outline-none transition-all"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Supported formats: protocol://user:pass@host:port, host:port:user:pass
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button fullWidth onClick={handleBatchImport} disabled={!batchImportText.trim() || importing}>
|
||||
{importing ? "Importing..." : "Import"}
|
||||
</Button>
|
||||
<Button fullWidth variant="ghost" onClick={closeBatchImportModal} disabled={importing}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={showFormModal}
|
||||
title={editingProxyPool ? "Edit Proxy Pool" : "Add Proxy Pool"}
|
||||
onClose={closeFormModal}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="Office Proxy"
|
||||
/>
|
||||
<Input
|
||||
label="Proxy URL"
|
||||
value={formData.proxyUrl}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
||||
placeholder="http://127.0.0.1:7897"
|
||||
/>
|
||||
<Input
|
||||
label="No Proxy"
|
||||
value={formData.noProxy}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, noProxy: e.target.value }))}
|
||||
placeholder="localhost,127.0.0.1,.internal"
|
||||
hint="Comma-separated hosts/domains to bypass proxy"
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-border/50 p-3 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-sm">Active</p>
|
||||
<p className="text-xs text-text-muted">Inactive pools are ignored by runtime resolution.</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={formData.isActive === true}
|
||||
onChange={() => setFormData((prev) => ({ ...prev, isActive: !prev.isActive }))}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/50 p-3 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-sm">Strict Proxy</p>
|
||||
<p className="text-xs text-text-muted">Fail request if proxy is unreachable instead of falling back to direct.</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={formData.strictProxy === true}
|
||||
onChange={() => setFormData((prev) => ({ ...prev, strictProxy: !prev.strictProxy }))}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={handleSave}
|
||||
disabled={!formData.name.trim() || !formData.proxyUrl.trim() || saving}
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button fullWidth variant="ghost" onClick={closeFormModal} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user