feat(proxy-pools): add support for deno deploy relays and fix layout overflow issues in proxy pools dashboard (#1437)
Co-authored-by: TD <tho.din@inno.ai.vn>
This commit is contained in:
1 parent
13a28452fb
commit
57b3e94e40
5 files changed
+343
-10
No files matched your search
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
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";
|
||||
|
||||
@@ -34,11 +34,14 @@ export default function ProxyPoolsPage() {
|
||||
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);
|
||||
@@ -48,8 +51,21 @@ export default function ProxyPoolsPage() {
|
||||
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" });
|
||||
@@ -346,6 +362,16 @@ export default function ProxyPoolsPage() {
|
||||
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);
|
||||
@@ -396,6 +422,31 @@ export default function ProxyPoolsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -532,12 +583,55 @@ export default function ProxyPoolsPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
|
||||
<Button size="sm" variant="secondary" icon="cloud" onClick={openCloudflareModal}>
|
||||
Cloudflare Relay
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" icon="cloud_upload" onClick={openVercelModal}>
|
||||
Vercel Relay
|
||||
</Button>
|
||||
<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>
|
||||
@@ -829,6 +923,70 @@ export default function ProxyPoolsPage() {
|
||||
</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"}
|
||||
|
||||
Reference in new issue
Block a user