mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
1063 lines
41 KiB
JavaScript
1063 lines
41 KiB
JavaScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
|
import { Badge, Button, Card, CardSkeleton, Input, Modal, Toggle, ConfirmModal } from "@/shared/components";
|
|
import { useNotificationStore } from "@/store/notificationStore";
|
|
import { formatVietnamDateTime } from "@/shared/utils/dateTime";
|
|
|
|
function getStatusVariant(status) {
|
|
if (status === "active") return "success";
|
|
if (status === "error") return "error";
|
|
return "default";
|
|
}
|
|
|
|
function formatDateTime(value) {
|
|
if (!value) return "Never";
|
|
return formatVietnamDateTime(value, { dateStyle: "medium", timeStyle: "medium" }) || "Never";
|
|
}
|
|
|
|
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 [showVercelModal, setShowVercelModal] = useState(false);
|
|
const [showCloudflareModal, setShowCloudflareModal] = useState(false);
|
|
const [showDenoModal, setShowDenoModal] = useState(false);
|
|
const [showRelayMenu, setShowRelayMenu] = useState(false);
|
|
const [editingProxyPool, setEditingProxyPool] = useState(null);
|
|
const [formData, setFormData] = useState(normalizeFormData());
|
|
const [batchImportText, setBatchImportText] = useState("");
|
|
const [vercelForm, setVercelForm] = useState({ vercelToken: "", projectName: "vercel-relay" });
|
|
const [cloudflareForm, setCloudflareForm] = useState({ accountId: "", apiToken: "", projectName: "cloudflare-relay" });
|
|
const [denoForm, setDenoForm] = useState({ denoToken: "", orgDomain: "", projectName: "" });
|
|
const [saving, setSaving] = useState(false);
|
|
const [importing, setImporting] = useState(false);
|
|
const [deploying, setDeploying] = useState(false);
|
|
const [testingId, setTestingId] = useState(null);
|
|
const [selectedIds, setSelectedIds] = useState([]);
|
|
const [healthChecking, setHealthChecking] = useState(false);
|
|
const [healthProgress, setHealthProgress] = useState({ current: 0, total: 0 });
|
|
const [bulkBusy, setBulkBusy] = useState(false);
|
|
const [confirmState, setConfirmState] = useState(null);
|
|
const relayMenuRef = useRef(null);
|
|
const notify = useNotificationStore();
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (e) => {
|
|
if (relayMenuRef.current && !relayMenuRef.current.contains(e.target)) {
|
|
setShowRelayMenu(false);
|
|
}
|
|
};
|
|
if (showRelayMenu) {
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
}
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, [showRelayMenu]);
|
|
|
|
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) => {
|
|
setConfirmState({
|
|
title: "Delete Proxy Pool",
|
|
message: `Delete proxy pool "${proxyPool.name}"?`,
|
|
onConfirm: async () => {
|
|
setConfirmState(null);
|
|
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 handleToggleActive = async (pool) => {
|
|
const next = !pool.isActive;
|
|
setProxyPools((prev) => prev.map((p) => p.id === pool.id ? { ...p, isActive: next } : p));
|
|
try {
|
|
const res = await fetch(`/api/proxy-pools/${pool.id}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ isActive: next }),
|
|
});
|
|
if (!res.ok) {
|
|
setProxyPools((prev) => prev.map((p) => p.id === pool.id ? { ...p, isActive: pool.isActive } : p));
|
|
notify.error("Failed to update active state");
|
|
}
|
|
} catch (error) {
|
|
console.log("Error toggling active:", error);
|
|
setProxyPools((prev) => prev.map((p) => p.id === pool.id ? { ...p, isActive: pool.isActive } : p));
|
|
}
|
|
};
|
|
|
|
const allSelected = proxyPools.length > 0 && selectedIds.length === proxyPools.length;
|
|
const toggleSelect = (id) => setSelectedIds((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]);
|
|
const toggleSelectAll = () => setSelectedIds(allSelected ? [] : proxyPools.map((p) => p.id));
|
|
const clearSelection = () => setSelectedIds([]);
|
|
|
|
const bulkSetActive = async (isActive) => {
|
|
const targets = selectedIds.length > 0 ? selectedIds : proxyPools.map((p) => p.id);
|
|
if (targets.length === 0) return;
|
|
setBulkBusy(true);
|
|
try {
|
|
let ok = 0; let failed = 0;
|
|
for (const id of targets) {
|
|
try {
|
|
const res = await fetch(`/api/proxy-pools/${id}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ isActive }),
|
|
});
|
|
if (res.ok) ok += 1; else failed += 1;
|
|
} catch { failed += 1; }
|
|
}
|
|
await fetchProxyPools();
|
|
notify.success(`${isActive ? "Activated" : "Deactivated"} ${ok}${failed ? `, failed ${failed}` : ""}`);
|
|
} finally {
|
|
setBulkBusy(false);
|
|
}
|
|
};
|
|
|
|
const bulkDelete = async () => {
|
|
if (selectedIds.length === 0) return;
|
|
setConfirmState({
|
|
title: "Delete Proxy Pools",
|
|
message: `Delete ${selectedIds.length} proxy pool(s)?`,
|
|
onConfirm: async () => {
|
|
setConfirmState(null);
|
|
setBulkBusy(true);
|
|
try {
|
|
let ok = 0; let blocked = 0; let failed = 0;
|
|
for (const id of selectedIds) {
|
|
try {
|
|
const res = await fetch(`/api/proxy-pools/${id}`, { method: "DELETE" });
|
|
if (res.ok) ok += 1;
|
|
else if (res.status === 409) blocked += 1;
|
|
else failed += 1;
|
|
} catch { failed += 1; }
|
|
}
|
|
await fetchProxyPools();
|
|
clearSelection();
|
|
notify.success(`Deleted ${ok}${blocked ? `, ${blocked} bound` : ""}${failed ? `, ${failed} failed` : ""}`);
|
|
} finally {
|
|
setBulkBusy(false);
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleHealthCheck = async () => {
|
|
const targets = selectedIds.length > 0
|
|
? proxyPools.filter((p) => selectedIds.includes(p.id))
|
|
: proxyPools;
|
|
if (targets.length === 0) return;
|
|
setHealthChecking(true);
|
|
setHealthProgress({ current: 0, total: targets.length });
|
|
let alive = 0; const deadIds = [];
|
|
let done = 0;
|
|
const CONCURRENCY = 10;
|
|
const queue = [...targets];
|
|
|
|
const worker = async () => {
|
|
while (queue.length > 0) {
|
|
const pool = queue.shift();
|
|
if (!pool) break;
|
|
try {
|
|
const res = await fetch(`/api/proxy-pools/${pool.id}/test`, { method: "POST" });
|
|
const data = await res.json();
|
|
if (res.ok && data.ok) alive += 1; else deadIds.push(pool.id);
|
|
} catch {
|
|
deadIds.push(pool.id);
|
|
} finally {
|
|
done += 1;
|
|
setHealthProgress({ current: done, total: targets.length });
|
|
}
|
|
}
|
|
};
|
|
|
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, targets.length) }, worker));
|
|
await fetchProxyPools();
|
|
setHealthChecking(false);
|
|
setHealthProgress({ current: 0, total: 0 });
|
|
|
|
if (deadIds.length > 0) {
|
|
setConfirmState({
|
|
title: "Disable Dead Proxies",
|
|
message: `Alive: ${alive}, Dead: ${deadIds.length}.\n\nDisable ${deadIds.length} dead proxies?`,
|
|
onConfirm: async () => {
|
|
setConfirmState(null);
|
|
setBulkBusy(true);
|
|
try {
|
|
for (const id of deadIds) {
|
|
try {
|
|
await fetch(`/api/proxy-pools/${id}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ isActive: false }),
|
|
});
|
|
} catch {}
|
|
}
|
|
await fetchProxyPools();
|
|
notify.success(`Disabled ${deadIds.length} dead proxies`);
|
|
} finally {
|
|
setBulkBusy(false);
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
notify.success(`Health check done. Alive: ${alive}, Dead: ${deadIds.length}`);
|
|
}
|
|
};
|
|
|
|
// Cleanup selectedIds when pools change
|
|
useEffect(() => {
|
|
setSelectedIds((prev) => prev.filter((id) => proxyPools.some((p) => p.id === id)));
|
|
}, [proxyPools]);
|
|
|
|
const openBatchImportModal = () => {
|
|
setBatchImportText("");
|
|
setShowBatchImportModal(true);
|
|
};
|
|
|
|
const closeBatchImportModal = () => {
|
|
if (importing) return;
|
|
setShowBatchImportModal(false);
|
|
};
|
|
|
|
const openVercelModal = () => {
|
|
setVercelForm({ vercelToken: "", projectName: "vercel-relay" });
|
|
setShowVercelModal(true);
|
|
};
|
|
|
|
const closeVercelModal = () => {
|
|
if (deploying) return;
|
|
setShowVercelModal(false);
|
|
};
|
|
|
|
const openCloudflareModal = () => {
|
|
setCloudflareForm({ accountId: "", apiToken: "", projectName: "cloudflare-relay" });
|
|
setShowCloudflareModal(true);
|
|
};
|
|
|
|
const closeCloudflareModal = () => {
|
|
if (deploying) return;
|
|
setShowCloudflareModal(false);
|
|
};
|
|
|
|
const openDenoModal = () => {
|
|
setDenoForm({ denoToken: "", orgDomain: "", projectName: "" });
|
|
setShowDenoModal(true);
|
|
};
|
|
|
|
const closeDenoModal = () => {
|
|
if (deploying) return;
|
|
setShowDenoModal(false);
|
|
};
|
|
|
|
const handleVercelDeploy = async () => {
|
|
if (!vercelForm.vercelToken.trim()) return;
|
|
setDeploying(true);
|
|
try {
|
|
const res = await fetch("/api/proxy-pools/vercel-deploy", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(vercelForm),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
await fetchProxyPools();
|
|
closeVercelModal();
|
|
notify.success(`Deployed: ${data.deployUrl}`);
|
|
} else {
|
|
notify.error(data.error || "Deploy failed");
|
|
}
|
|
} catch (error) {
|
|
console.log("Error deploying Vercel relay:", error);
|
|
notify.error("Deploy failed");
|
|
} finally {
|
|
setDeploying(false);
|
|
}
|
|
};
|
|
|
|
const handleCloudflareDeploy = async () => {
|
|
if (!cloudflareForm.accountId.trim() || !cloudflareForm.apiToken.trim()) return;
|
|
setDeploying(true);
|
|
try {
|
|
const res = await fetch("/api/proxy-pools/cloudflare-deploy", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(cloudflareForm),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
await fetchProxyPools();
|
|
closeCloudflareModal();
|
|
notify.success(`Deployed: ${data.deployUrl}`);
|
|
} else {
|
|
notify.error(data.error || "Deploy failed");
|
|
}
|
|
} catch (error) {
|
|
console.log("Error deploying Cloudflare relay:", error);
|
|
notify.error("Deploy failed");
|
|
} finally {
|
|
setDeploying(false);
|
|
}
|
|
};
|
|
|
|
const handleDenoDeploy = async () => {
|
|
if (!denoForm.denoToken.trim()) return;
|
|
setDeploying(true);
|
|
try {
|
|
const res = await fetch("/api/proxy-pools/deno-deploy", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(denoForm),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
await fetchProxyPools();
|
|
closeDenoModal();
|
|
notify.success(`Deployed: ${data.deployUrl}`);
|
|
} else {
|
|
notify.error(data.error || "Deploy failed");
|
|
}
|
|
} catch (error) {
|
|
console.log("Error deploying Deno relay:", error);
|
|
notify.error("Deploy failed");
|
|
} finally {
|
|
setDeploying(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="mx-auto flex w-full max-w-5xl flex-col gap-4 px-1 sm:gap-6 sm:px-0">
|
|
<CardSkeleton />
|
|
<CardSkeleton />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4 px-1 sm:gap-6 sm:px-0">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
<div className="min-w-0">
|
|
<h1 className="text-xl font-semibold sm:text-2xl">Proxy Pools</h1>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
|
|
<div className="relative" ref={relayMenuRef}>
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
icon="rocket_launch"
|
|
onClick={() => setShowRelayMenu(!showRelayMenu)}
|
|
>
|
|
Deploy Relay
|
|
<span className="material-symbols-outlined ml-1 text-[18px]">
|
|
{showRelayMenu ? "expand_less" : "expand_more"}
|
|
</span>
|
|
</Button>
|
|
|
|
{showRelayMenu && (
|
|
<div className="absolute left-0 top-full z-50 mt-1 w-48 rounded-xl border border-black/10 bg-white p-1 shadow-xl dark:border-white/10 dark:bg-zinc-900 sm:left-auto sm:right-0">
|
|
<button
|
|
onClick={() => {
|
|
openCloudflareModal();
|
|
setShowRelayMenu(false);
|
|
}}
|
|
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-text-main transition-colors hover:bg-black/5 dark:hover:bg-white/5"
|
|
>
|
|
<span className="material-symbols-outlined text-[20px] text-orange-500">cloud</span>
|
|
Cloudflare Relay
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
openVercelModal();
|
|
setShowRelayMenu(false);
|
|
}}
|
|
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-text-main transition-colors hover:bg-black/5 dark:hover:bg-white/5"
|
|
>
|
|
<span className="material-symbols-outlined text-[20px] text-blue-500">cloud_upload</span>
|
|
Vercel Relay
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
openDenoModal();
|
|
setShowRelayMenu(false);
|
|
}}
|
|
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-text-main transition-colors hover:bg-black/5 dark:hover:bg-white/5"
|
|
>
|
|
<span className="material-symbols-outlined text-[20px] text-green-500">terminal</span>
|
|
Deno Relay
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Button size="sm" variant="secondary" icon="upload" onClick={openBatchImportModal}>
|
|
Batch Import
|
|
</Button>
|
|
<Button size="sm" icon="add" onClick={openCreateModal}>Add Proxy Pool</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<Card>
|
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
|
{proxyPools.length > 0 && (
|
|
<label className="flex items-center gap-1.5 text-xs text-text-muted cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={allSelected}
|
|
onChange={toggleSelectAll}
|
|
className="size-4 rounded border-black/20 dark:border-white/20"
|
|
/>
|
|
{allSelected ? "Unselect all" : "Select all"}
|
|
</label>
|
|
)}
|
|
<Badge variant="default">Total: {proxyPools.length}</Badge>
|
|
<Badge variant="success">Active: {activeCount}</Badge>
|
|
</div>
|
|
|
|
{(selectedIds.length > 0 || healthChecking) && (
|
|
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2">
|
|
<span className="material-symbols-outlined text-[18px] text-primary">checklist</span>
|
|
<span className="text-xs font-medium text-primary">
|
|
{selectedIds.length > 0 ? `${selectedIds.length} selected` : "All pools"}
|
|
</span>
|
|
<div className="ml-auto flex flex-wrap items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
icon={healthChecking ? "progress_activity" : "health_and_safety"}
|
|
onClick={handleHealthCheck}
|
|
disabled={healthChecking || bulkBusy || proxyPools.length === 0}
|
|
>
|
|
{healthChecking ? `Checking ${healthProgress.current}/${healthProgress.total}` : "Health Check"}
|
|
</Button>
|
|
{selectedIds.length > 0 && (
|
|
<>
|
|
<Button size="sm" variant="secondary" icon="toggle_on" onClick={() => bulkSetActive(true)} disabled={bulkBusy || healthChecking}>
|
|
Activate
|
|
</Button>
|
|
<Button size="sm" variant="secondary" icon="toggle_off" onClick={() => bulkSetActive(false)} disabled={bulkBusy || healthChecking}>
|
|
Deactivate
|
|
</Button>
|
|
<Button size="sm" variant="secondary" icon="delete" onClick={bulkDelete} disabled={bulkBusy || healthChecking}>
|
|
Delete
|
|
</Button>
|
|
<Button size="sm" variant="ghost" onClick={clearSelection} disabled={bulkBusy || healthChecking}>
|
|
Clear
|
|
</Button>
|
|
</>
|
|
)}
|
|
</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="flex flex-col gap-3 py-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex items-start gap-3 min-w-0 flex-1">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedIds.includes(pool.id)}
|
|
onChange={() => toggleSelect(pool.id)}
|
|
className="mt-1 size-4 shrink-0 rounded border-black/20 dark:border-white/20"
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<p className="min-w-0 max-w-full truncate text-sm font-medium sm:max-w-[18rem]">{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>
|
|
{pool.type === "vercel" && (
|
|
<Badge variant="default" size="sm">vercel relay</Badge>
|
|
)}
|
|
{pool.type === "cloudflare" && (
|
|
<Badge variant="default" size="sm">cloudflare relay</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>
|
|
|
|
<div className="flex items-center justify-end gap-1">
|
|
<Toggle
|
|
size="sm"
|
|
checked={pool.isActive === true}
|
|
onChange={() => handleToggleActive(pool)}
|
|
title={pool.isActive ? "Disable" : "Enable"}
|
|
/>
|
|
<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="grid grid-cols-1 gap-2 sm:grid-cols-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={showVercelModal}
|
|
title="Deploy Vercel Relay"
|
|
onClose={closeVercelModal}
|
|
>
|
|
<div className="flex flex-col gap-4">
|
|
<div className="rounded-lg bg-blue-500/5 border border-blue-500/10 p-3 flex flex-col gap-1.5">
|
|
<p className="text-sm text-text-main font-medium">What is Vercel Relay?</p>
|
|
<p className="text-xs text-text-muted">
|
|
Deploys an edge relay function to Vercel. All AI provider requests will be forwarded through Vercel's edge network, masking your real IP from providers.
|
|
</p>
|
|
<ul className="text-xs text-text-muted list-disc pl-4 space-y-0.5">
|
|
<li>Your IP is replaced by Vercel's dynamic edge IPs (hundreds of IPs across 20+ global regions)</li>
|
|
<li>Vercel serves millions of apps — providers can't block Vercel IPs without affecting legitimate traffic</li>
|
|
<li>Free tier: 100GB bandwidth/month, 500K edge invocations</li>
|
|
<li>Deploy multiple relays on different accounts for more IP diversity</li>
|
|
</ul>
|
|
</div>
|
|
<Input
|
|
label="Vercel API Token"
|
|
value={vercelForm.vercelToken}
|
|
onChange={(e) => setVercelForm((prev) => ({ ...prev, vercelToken: e.target.value }))}
|
|
placeholder="your-vercel-api-token"
|
|
hint={<>Token is used once for deployment and not stored. <a href="https://vercel.com/account/tokens" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">Get token →</a></>}
|
|
type="password"
|
|
/>
|
|
<Input
|
|
label="Project Name"
|
|
value={vercelForm.projectName}
|
|
onChange={(e) => setVercelForm((prev) => ({ ...prev, projectName: e.target.value }))}
|
|
placeholder="my-relay"
|
|
hint="Unique name for your Vercel project. Leave empty for auto-generated name."
|
|
/>
|
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
<Button
|
|
fullWidth
|
|
onClick={handleVercelDeploy}
|
|
disabled={!vercelForm.vercelToken.trim() || deploying}
|
|
>
|
|
{deploying ? "Deploying... (may take ~1 min)" : "Deploy"}
|
|
</Button>
|
|
<Button fullWidth variant="ghost" onClick={closeVercelModal} disabled={deploying}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
isOpen={showCloudflareModal}
|
|
title="Deploy Cloudflare Relay"
|
|
onClose={closeCloudflareModal}
|
|
>
|
|
<div className="flex flex-col gap-4">
|
|
<div className="rounded-lg bg-orange-500/5 border border-orange-500/10 p-3 flex flex-col gap-1.5">
|
|
<p className="text-sm text-text-main font-medium">What is Cloudflare Relay?</p>
|
|
<p className="text-xs text-text-muted">
|
|
Deploys a Cloudflare Worker as a proxy relay. All AI provider requests will be forwarded through Cloudflare's global edge network.
|
|
</p>
|
|
<ul className="text-xs text-text-muted list-disc pl-4 space-y-0.5">
|
|
<li>High performance global routing and IP masking via Cloudflare Workers</li>
|
|
<li>Free tier: 100,000 requests per day</li>
|
|
<li>Requires Cloudflare Account ID and a Workers API Token (Edit Workers permission)</li>
|
|
</ul>
|
|
<div className="mt-2 pt-2 border-t border-orange-500/10 text-xs text-text-muted">
|
|
<p className="font-medium text-text-main mb-1">How to generate your API Token:</p>
|
|
<ol className="list-decimal pl-4 space-y-0.5">
|
|
<li>Go to <b>My Profile</b> → <b>API Tokens</b> → <b>Create Token</b></li>
|
|
<li>Scroll down to <b>Custom Token</b> and click <b>Get started</b></li>
|
|
<li>Under <b>Permissions</b>: Account | Workers Scripts | Edit</li>
|
|
<li>Under <b>Account Resources</b>: Include | Account | <i>Your Account Name</i></li>
|
|
<li>Click <b>Continue to summary</b> → <b>Create Token</b></li>
|
|
</ol>
|
|
</div>
|
|
</div>
|
|
<Input
|
|
label="Account ID"
|
|
value={cloudflareForm.accountId}
|
|
onChange={(e) => setCloudflareForm((prev) => ({ ...prev, accountId: e.target.value }))}
|
|
placeholder="your-cloudflare-account-id"
|
|
hint={<>Found on the right side of the Cloudflare dashboard overview page.</>}
|
|
/>
|
|
<Input
|
|
label="API Token"
|
|
value={cloudflareForm.apiToken}
|
|
onChange={(e) => setCloudflareForm((prev) => ({ ...prev, apiToken: e.target.value }))}
|
|
placeholder="your-cloudflare-api-token"
|
|
hint={<>Requires "Workers Scripts: Edit" permission. <a href="https://dash.cloudflare.com/profile/api-tokens" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">Get token →</a></>}
|
|
type="password"
|
|
/>
|
|
<Input
|
|
label="Worker Name"
|
|
value={cloudflareForm.projectName}
|
|
onChange={(e) => setCloudflareForm((prev) => ({ ...prev, projectName: e.target.value }))}
|
|
placeholder="my-relay"
|
|
hint="Unique name for your Cloudflare Worker. Leave empty for auto-generated name."
|
|
/>
|
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
<Button
|
|
fullWidth
|
|
onClick={handleCloudflareDeploy}
|
|
disabled={!cloudflareForm.accountId.trim() || !cloudflareForm.apiToken.trim() || deploying}
|
|
>
|
|
{deploying ? "Deploying..." : "Deploy Worker"}
|
|
</Button>
|
|
<Button fullWidth variant="ghost" onClick={closeCloudflareModal} disabled={deploying}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
isOpen={showDenoModal}
|
|
title="Deploy Deno Relay"
|
|
onClose={closeDenoModal}
|
|
>
|
|
<div className="flex flex-col gap-4">
|
|
<div className="rounded-lg bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 p-3 flex flex-col gap-1.5">
|
|
<p className="text-sm text-text-main font-medium">What is Deno Relay?</p>
|
|
<p className="text-xs text-text-muted">
|
|
Deploys a relay worker to Deno Deploy's global edge network. All AI provider requests are forwarded through Deno's edge, masking your real IP.
|
|
</p>
|
|
<ul className="text-xs text-text-muted list-disc pl-4 space-y-0.5">
|
|
<li>Deno Deploy v2 runs on a high-performance global edge network</li>
|
|
<li>Free tier: 1M requests & 100GiB outbound traffic per month</li>
|
|
<li>No per-request CPU time limits (unlike Vercel/Cloudflare)</li>
|
|
<li>Support up to 20 active apps & 50 custom domains</li>
|
|
<li>Deploy multiple relays for maximum IP diversity</li>
|
|
</ul>
|
|
<div className="mt-2 pt-2 border-t border-black/10 dark:border-white/10 text-xs text-text-muted">
|
|
<p className="font-medium text-text-main mb-1">How to generate API token:</p>
|
|
<ol className="list-decimal pl-4 space-y-0.5">
|
|
<li>Go to <b>console.deno.com</b></li>
|
|
<li>Select your <b>Organization</b> → <b>Settings</b> → <b>Organization Tokens</b></li>
|
|
<li>Create a <b>Organization Token</b> (prefix <b>ddo_</b>)</li>
|
|
</ol>
|
|
</div>
|
|
</div>
|
|
<Input
|
|
label="Deno Deploy API Token"
|
|
value={denoForm.denoToken}
|
|
onChange={(e) => setDenoForm((prev) => ({ ...prev, denoToken: e.target.value }))}
|
|
placeholder="ddo_xxxxxxxxxxxxxxxx"
|
|
hint={<>Token is used once for deployment, not stored. Found in Organization Settings.</>}
|
|
type="password"
|
|
/>
|
|
<Input
|
|
label="Organization Domain"
|
|
value={denoForm.orgDomain}
|
|
onChange={(e) => setDenoForm((prev) => ({ ...prev, orgDomain: e.target.value }))}
|
|
placeholder="your-org.deno.net"
|
|
hint="Organization's default domain. Your relay URL will be in the format: https://my-relay.your-org.deno.net"
|
|
/>
|
|
<Input
|
|
label="App Name"
|
|
value={denoForm.projectName}
|
|
onChange={(e) => setDenoForm((prev) => ({ ...prev, projectName: e.target.value }))}
|
|
placeholder="deno-relay"
|
|
hint="Unique app name. Leave empty for auto-generated name."
|
|
/>
|
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
<Button
|
|
fullWidth
|
|
onClick={handleDenoDeploy}
|
|
disabled={!denoForm.denoToken.trim() || !denoForm.orgDomain.trim() || deploying}
|
|
>
|
|
{deploying ? "Deploying..." : "Deploy Relay"}
|
|
</Button>
|
|
<Button fullWidth variant="ghost" onClick={closeDenoModal} disabled={deploying}>
|
|
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="flex flex-col gap-3 rounded-lg border border-border/50 p-3 sm:flex-row sm:items-center sm: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="flex flex-col gap-3 rounded-lg border border-border/50 p-3 sm:flex-row sm:items-center sm: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="grid grid-cols-1 gap-2 sm:grid-cols-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>
|
|
|
|
{/* Confirm Modal */}
|
|
<ConfirmModal
|
|
isOpen={!!confirmState}
|
|
onClose={() => setConfirmState(null)}
|
|
onConfirm={confirmState?.onConfirm}
|
|
title={confirmState?.title || "Confirm"}
|
|
message={confirmState?.message}
|
|
variant="danger"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|