Spaces:
Paused
Paused
icebear0828 Claude Opus 4.6 commited on
Commit ·
af3e19d
1
Parent(s): ffcac87
feat: proxy pool add form with separate protocol/host/port/auth fields
Browse filesReplace single URL input with structured fields (protocol dropdown,
host, port, username, password) for easier configuration of
authenticated proxies. Backend still accepts raw url for API compat.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- shared/hooks/use-proxies.ts +19 -3
- shared/i18n/translations.ts +12 -0
- src/routes/proxies.ts +38 -4
- web/src/components/ProxyPool.tsx +85 -23
shared/hooks/use-proxies.ts
CHANGED
|
@@ -1,13 +1,22 @@
|
|
| 1 |
import { useState, useEffect, useCallback } from "preact/hooks";
|
| 2 |
import type { ProxyEntry, ProxyAssignment } from "../types";
|
| 3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
export interface ProxiesState {
|
| 5 |
proxies: ProxyEntry[];
|
| 6 |
assignments: ProxyAssignment[];
|
| 7 |
healthCheckIntervalMinutes: number;
|
| 8 |
loading: boolean;
|
| 9 |
refresh: () => Promise<void>;
|
| 10 |
-
addProxy: (
|
| 11 |
removeProxy: (id: string) => Promise<string | null>;
|
| 12 |
checkProxy: (id: string) => Promise<void>;
|
| 13 |
checkAll: () => Promise<void>;
|
|
@@ -43,12 +52,19 @@ export function useProxies(): ProxiesState {
|
|
| 43 |
}, [refresh]);
|
| 44 |
|
| 45 |
const addProxy = useCallback(
|
| 46 |
-
async (
|
| 47 |
try {
|
| 48 |
const resp = await fetch("/api/proxies", {
|
| 49 |
method: "POST",
|
| 50 |
headers: { "Content-Type": "application/json" },
|
| 51 |
-
body: JSON.stringify({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
});
|
| 53 |
const data = await resp.json();
|
| 54 |
if (!resp.ok) return data.error || "Failed to add proxy";
|
|
|
|
| 1 |
import { useState, useEffect, useCallback } from "preact/hooks";
|
| 2 |
import type { ProxyEntry, ProxyAssignment } from "../types";
|
| 3 |
|
| 4 |
+
export interface AddProxyFields {
|
| 5 |
+
name: string;
|
| 6 |
+
protocol: string;
|
| 7 |
+
host: string;
|
| 8 |
+
port: string;
|
| 9 |
+
username: string;
|
| 10 |
+
password: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
export interface ProxiesState {
|
| 14 |
proxies: ProxyEntry[];
|
| 15 |
assignments: ProxyAssignment[];
|
| 16 |
healthCheckIntervalMinutes: number;
|
| 17 |
loading: boolean;
|
| 18 |
refresh: () => Promise<void>;
|
| 19 |
+
addProxy: (fields: AddProxyFields) => Promise<string | null>;
|
| 20 |
removeProxy: (id: string) => Promise<string | null>;
|
| 21 |
checkProxy: (id: string) => Promise<void>;
|
| 22 |
checkAll: () => Promise<void>;
|
|
|
|
| 52 |
}, [refresh]);
|
| 53 |
|
| 54 |
const addProxy = useCallback(
|
| 55 |
+
async (fields: AddProxyFields): Promise<string | null> => {
|
| 56 |
try {
|
| 57 |
const resp = await fetch("/api/proxies", {
|
| 58 |
method: "POST",
|
| 59 |
headers: { "Content-Type": "application/json" },
|
| 60 |
+
body: JSON.stringify({
|
| 61 |
+
name: fields.name,
|
| 62 |
+
protocol: fields.protocol,
|
| 63 |
+
host: fields.host,
|
| 64 |
+
port: fields.port,
|
| 65 |
+
username: fields.username,
|
| 66 |
+
password: fields.password,
|
| 67 |
+
}),
|
| 68 |
});
|
| 69 |
const data = await resp.json();
|
| 70 |
if (!resp.ok) return data.error || "Failed to add proxy";
|
shared/i18n/translations.ts
CHANGED
|
@@ -77,6 +77,12 @@ export const translations = {
|
|
| 77 |
addProxy: "Add Proxy",
|
| 78 |
proxyName: "Name",
|
| 79 |
proxyUrl: "Proxy URL",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
noProxies: "No proxies configured. Add one to route accounts through different IPs.",
|
| 81 |
proxyActive: "Active",
|
| 82 |
proxyUnreachable: "Unreachable",
|
|
@@ -177,6 +183,12 @@ export const translations = {
|
|
| 177 |
addProxy: "\u6dfb\u52a0\u4ee3\u7406",
|
| 178 |
proxyName: "\u540d\u79f0",
|
| 179 |
proxyUrl: "\u4ee3\u7406 URL",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
noProxies: "\u672a\u914d\u7f6e\u4ee3\u7406\u3002\u6dfb\u52a0\u4e00\u4e2a\u4ee5\u901a\u8fc7\u4e0d\u540c IP \u8def\u7531\u8d26\u53f7\u3002",
|
| 181 |
proxyActive: "\u6d3b\u8dc3",
|
| 182 |
proxyUnreachable: "\u4e0d\u53ef\u8fbe",
|
|
|
|
| 77 |
addProxy: "Add Proxy",
|
| 78 |
proxyName: "Name",
|
| 79 |
proxyUrl: "Proxy URL",
|
| 80 |
+
proxyProtocol: "Protocol",
|
| 81 |
+
proxyHost: "Host",
|
| 82 |
+
proxyPort: "Port",
|
| 83 |
+
proxyUsername: "Username",
|
| 84 |
+
proxyPassword: "Password",
|
| 85 |
+
proxyOptional: "optional",
|
| 86 |
noProxies: "No proxies configured. Add one to route accounts through different IPs.",
|
| 87 |
proxyActive: "Active",
|
| 88 |
proxyUnreachable: "Unreachable",
|
|
|
|
| 183 |
addProxy: "\u6dfb\u52a0\u4ee3\u7406",
|
| 184 |
proxyName: "\u540d\u79f0",
|
| 185 |
proxyUrl: "\u4ee3\u7406 URL",
|
| 186 |
+
proxyProtocol: "\u534f\u8bae",
|
| 187 |
+
proxyHost: "\u4e3b\u673a",
|
| 188 |
+
proxyPort: "\u7aef\u53e3",
|
| 189 |
+
proxyUsername: "\u7528\u6237\u540d",
|
| 190 |
+
proxyPassword: "\u5bc6\u7801",
|
| 191 |
+
proxyOptional: "\u53ef\u9009",
|
| 192 |
noProxies: "\u672a\u914d\u7f6e\u4ee3\u7406\u3002\u6dfb\u52a0\u4e00\u4e2a\u4ee5\u901a\u8fc7\u4e0d\u540c IP \u8def\u7531\u8d26\u53f7\u3002",
|
| 193 |
proxyActive: "\u6d3b\u8dc3",
|
| 194 |
proxyUnreachable: "\u4e0d\u53ef\u8fbe",
|
src/routes/proxies.ts
CHANGED
|
@@ -29,14 +29,28 @@ export function createProxyRoutes(proxyPool: ProxyPool): Hono {
|
|
| 29 |
});
|
| 30 |
});
|
| 31 |
|
| 32 |
-
// Add proxy
|
| 33 |
app.post("/api/proxies", async (c) => {
|
| 34 |
-
const body = await c.req.json<{
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
if (!url) {
|
| 38 |
c.status(400);
|
| 39 |
-
return c.json({ error: "url is required" });
|
| 40 |
}
|
| 41 |
|
| 42 |
// URL validation + scheme check
|
|
@@ -174,3 +188,23 @@ export function createProxyRoutes(proxyPool: ProxyPool): Hono {
|
|
| 174 |
|
| 175 |
return app;
|
| 176 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
});
|
| 30 |
});
|
| 31 |
|
| 32 |
+
// Add proxy — accepts { name, url } OR { name, protocol, host, port, username?, password? }
|
| 33 |
app.post("/api/proxies", async (c) => {
|
| 34 |
+
const body = await c.req.json<{
|
| 35 |
+
name?: string;
|
| 36 |
+
url?: string;
|
| 37 |
+
protocol?: string;
|
| 38 |
+
host?: string;
|
| 39 |
+
port?: string | number;
|
| 40 |
+
username?: string;
|
| 41 |
+
password?: string;
|
| 42 |
+
}>();
|
| 43 |
+
|
| 44 |
+
let url = body.url?.trim();
|
| 45 |
+
|
| 46 |
+
// Compose URL from separate fields if raw url not provided
|
| 47 |
+
if (!url && body.host) {
|
| 48 |
+
url = composeProxyUrl(body.protocol, body.host, body.port, body.username, body.password);
|
| 49 |
+
}
|
| 50 |
|
| 51 |
if (!url) {
|
| 52 |
c.status(400);
|
| 53 |
+
return c.json({ error: "url or host is required" });
|
| 54 |
}
|
| 55 |
|
| 56 |
// URL validation + scheme check
|
|
|
|
| 188 |
|
| 189 |
return app;
|
| 190 |
}
|
| 191 |
+
|
| 192 |
+
/** Compose a proxy URL from separate fields. */
|
| 193 |
+
function composeProxyUrl(
|
| 194 |
+
protocol: string | undefined,
|
| 195 |
+
host: string,
|
| 196 |
+
port: string | number | undefined,
|
| 197 |
+
username: string | undefined,
|
| 198 |
+
password: string | undefined,
|
| 199 |
+
): string {
|
| 200 |
+
const scheme = protocol || "http";
|
| 201 |
+
const trimmedHost = host.trim();
|
| 202 |
+
let auth = "";
|
| 203 |
+
if (username) {
|
| 204 |
+
auth = password
|
| 205 |
+
? `${encodeURIComponent(username)}:${encodeURIComponent(password)}@`
|
| 206 |
+
: `${encodeURIComponent(username)}@`;
|
| 207 |
+
}
|
| 208 |
+
const portSuffix = port ? `:${port}` : "";
|
| 209 |
+
return `${scheme}://${auth}${trimmedHost}${portSuffix}`;
|
| 210 |
+
}
|
web/src/components/ProxyPool.tsx
CHANGED
|
@@ -3,6 +3,8 @@ import { useT } from "../../../shared/i18n/context";
|
|
| 3 |
import type { TranslationKey } from "../../../shared/i18n/translations";
|
| 4 |
import type { ProxiesState } from "../../../shared/hooks/use-proxies";
|
| 5 |
|
|
|
|
|
|
|
| 6 |
const statusStyles: Record<string, [string, TranslationKey]> = {
|
| 7 |
active: [
|
| 8 |
"bg-green-100 text-green-700 border-green-200 dark:bg-[#11281d] dark:text-primary dark:border-[#1a442e]",
|
|
@@ -18,6 +20,8 @@ const statusStyles: Record<string, [string, TranslationKey]> = {
|
|
| 18 |
],
|
| 19 |
};
|
| 20 |
|
|
|
|
|
|
|
| 21 |
interface ProxyPoolProps {
|
| 22 |
proxies: ProxiesState;
|
| 23 |
}
|
|
@@ -26,26 +30,46 @@ export function ProxyPool({ proxies }: ProxyPoolProps) {
|
|
| 26 |
const t = useT();
|
| 27 |
const [showAdd, setShowAdd] = useState(false);
|
| 28 |
const [newName, setNewName] = useState("");
|
| 29 |
-
const [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
const [addError, setAddError] = useState("");
|
| 31 |
const [checking, setChecking] = useState<string | null>(null);
|
| 32 |
const [checkingAll, setCheckingAll] = useState(false);
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
const handleAdd = useCallback(async () => {
|
| 35 |
setAddError("");
|
| 36 |
-
if (!
|
| 37 |
-
setAddError("
|
| 38 |
return;
|
| 39 |
}
|
| 40 |
-
const err = await proxies.addProxy(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
if (err) {
|
| 42 |
setAddError(err);
|
| 43 |
} else {
|
| 44 |
-
|
| 45 |
-
setNewUrl("");
|
| 46 |
setShowAdd(false);
|
| 47 |
}
|
| 48 |
-
}, [newName,
|
| 49 |
|
| 50 |
const handleCheck = useCallback(
|
| 51 |
async (id: string) => {
|
|
@@ -102,31 +126,69 @@ export function ProxyPool({ proxies }: ProxyPoolProps) {
|
|
| 102 |
{/* Add form */}
|
| 103 |
{showAdd && (
|
| 104 |
<div class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-4 mb-4">
|
| 105 |
-
<div class="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
<input
|
| 107 |
type="text"
|
| 108 |
-
placeholder={t("proxyName")}
|
| 109 |
value={newName}
|
| 110 |
onInput={(e) => setNewName((e.target as HTMLInputElement).value)}
|
| 111 |
-
class=
|
| 112 |
/>
|
| 113 |
<input
|
| 114 |
type="text"
|
| 115 |
-
placeholder="
|
| 116 |
-
value={
|
| 117 |
-
onInput={(e) =>
|
| 118 |
-
class=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
/>
|
| 120 |
-
<button
|
| 121 |
-
onClick={handleAdd}
|
| 122 |
-
class="px-4 py-2 text-sm font-medium rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors whitespace-nowrap"
|
| 123 |
-
>
|
| 124 |
-
{t("addProxy")}
|
| 125 |
-
</button>
|
| 126 |
</div>
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
</div>
|
| 131 |
)}
|
| 132 |
|
|
|
|
| 3 |
import type { TranslationKey } from "../../../shared/i18n/translations";
|
| 4 |
import type { ProxiesState } from "../../../shared/hooks/use-proxies";
|
| 5 |
|
| 6 |
+
const PROTOCOLS = ["http", "https", "socks5", "socks5h"] as const;
|
| 7 |
+
|
| 8 |
const statusStyles: Record<string, [string, TranslationKey]> = {
|
| 9 |
active: [
|
| 10 |
"bg-green-100 text-green-700 border-green-200 dark:bg-[#11281d] dark:text-primary dark:border-[#1a442e]",
|
|
|
|
| 20 |
],
|
| 21 |
};
|
| 22 |
|
| 23 |
+
const inputCls = "px-3 py-2 text-sm border border-gray-200 dark:border-border-dark rounded-lg bg-transparent focus:outline-none focus:ring-1 focus:ring-primary";
|
| 24 |
+
|
| 25 |
interface ProxyPoolProps {
|
| 26 |
proxies: ProxiesState;
|
| 27 |
}
|
|
|
|
| 30 |
const t = useT();
|
| 31 |
const [showAdd, setShowAdd] = useState(false);
|
| 32 |
const [newName, setNewName] = useState("");
|
| 33 |
+
const [newProtocol, setNewProtocol] = useState("http");
|
| 34 |
+
const [newHost, setNewHost] = useState("");
|
| 35 |
+
const [newPort, setNewPort] = useState("");
|
| 36 |
+
const [newUsername, setNewUsername] = useState("");
|
| 37 |
+
const [newPassword, setNewPassword] = useState("");
|
| 38 |
const [addError, setAddError] = useState("");
|
| 39 |
const [checking, setChecking] = useState<string | null>(null);
|
| 40 |
const [checkingAll, setCheckingAll] = useState(false);
|
| 41 |
|
| 42 |
+
const resetForm = useCallback(() => {
|
| 43 |
+
setNewName("");
|
| 44 |
+
setNewProtocol("http");
|
| 45 |
+
setNewHost("");
|
| 46 |
+
setNewPort("");
|
| 47 |
+
setNewUsername("");
|
| 48 |
+
setNewPassword("");
|
| 49 |
+
setAddError("");
|
| 50 |
+
}, []);
|
| 51 |
+
|
| 52 |
const handleAdd = useCallback(async () => {
|
| 53 |
setAddError("");
|
| 54 |
+
if (!newHost.trim()) {
|
| 55 |
+
setAddError(t("proxyHost") + " is required");
|
| 56 |
return;
|
| 57 |
}
|
| 58 |
+
const err = await proxies.addProxy({
|
| 59 |
+
name: newName,
|
| 60 |
+
protocol: newProtocol,
|
| 61 |
+
host: newHost,
|
| 62 |
+
port: newPort,
|
| 63 |
+
username: newUsername,
|
| 64 |
+
password: newPassword,
|
| 65 |
+
});
|
| 66 |
if (err) {
|
| 67 |
setAddError(err);
|
| 68 |
} else {
|
| 69 |
+
resetForm();
|
|
|
|
| 70 |
setShowAdd(false);
|
| 71 |
}
|
| 72 |
+
}, [newName, newProtocol, newHost, newPort, newUsername, newPassword, proxies, resetForm, t]);
|
| 73 |
|
| 74 |
const handleCheck = useCallback(
|
| 75 |
async (id: string) => {
|
|
|
|
| 126 |
{/* Add form */}
|
| 127 |
{showAdd && (
|
| 128 |
<div class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-4 mb-4">
|
| 129 |
+
<div class="grid grid-cols-[auto_1fr_auto] sm:grid-cols-[auto_1fr_80px] gap-2 items-center">
|
| 130 |
+
{/* Row 1: Protocol + Host + Port */}
|
| 131 |
+
<select
|
| 132 |
+
value={newProtocol}
|
| 133 |
+
onChange={(e) => setNewProtocol((e.target as HTMLSelectElement).value)}
|
| 134 |
+
class={`${inputCls} w-[110px]`}
|
| 135 |
+
>
|
| 136 |
+
{PROTOCOLS.map((p) => (
|
| 137 |
+
<option key={p} value={p}>{p}://</option>
|
| 138 |
+
))}
|
| 139 |
+
</select>
|
| 140 |
+
<input
|
| 141 |
+
type="text"
|
| 142 |
+
placeholder={t("proxyHost")}
|
| 143 |
+
value={newHost}
|
| 144 |
+
onInput={(e) => setNewHost((e.target as HTMLInputElement).value)}
|
| 145 |
+
class={`${inputCls} font-mono`}
|
| 146 |
+
/>
|
| 147 |
+
<input
|
| 148 |
+
type="text"
|
| 149 |
+
placeholder={t("proxyPort")}
|
| 150 |
+
value={newPort}
|
| 151 |
+
onInput={(e) => setNewPort((e.target as HTMLInputElement).value)}
|
| 152 |
+
class={`${inputCls} font-mono`}
|
| 153 |
+
/>
|
| 154 |
+
</div>
|
| 155 |
+
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2">
|
| 156 |
+
{/* Row 2: Name + Username + Password */}
|
| 157 |
<input
|
| 158 |
type="text"
|
| 159 |
+
placeholder={`${t("proxyName")} (${t("proxyOptional")})`}
|
| 160 |
value={newName}
|
| 161 |
onInput={(e) => setNewName((e.target as HTMLInputElement).value)}
|
| 162 |
+
class={inputCls}
|
| 163 |
/>
|
| 164 |
<input
|
| 165 |
type="text"
|
| 166 |
+
placeholder={`${t("proxyUsername")} (${t("proxyOptional")})`}
|
| 167 |
+
value={newUsername}
|
| 168 |
+
onInput={(e) => setNewUsername((e.target as HTMLInputElement).value)}
|
| 169 |
+
class={inputCls}
|
| 170 |
+
/>
|
| 171 |
+
<input
|
| 172 |
+
type="password"
|
| 173 |
+
placeholder={`${t("proxyPassword")} (${t("proxyOptional")})`}
|
| 174 |
+
value={newPassword}
|
| 175 |
+
onInput={(e) => setNewPassword((e.target as HTMLInputElement).value)}
|
| 176 |
+
class={`${inputCls} col-span-2 sm:col-span-1`}
|
| 177 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
</div>
|
| 179 |
+
<div class="flex items-center justify-between mt-3">
|
| 180 |
+
{addError && (
|
| 181 |
+
<p class="text-xs text-red-500">{addError}</p>
|
| 182 |
+
)}
|
| 183 |
+
<div class="ml-auto">
|
| 184 |
+
<button
|
| 185 |
+
onClick={handleAdd}
|
| 186 |
+
class="px-4 py-2 text-sm font-medium rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors whitespace-nowrap"
|
| 187 |
+
>
|
| 188 |
+
{t("addProxy")}
|
| 189 |
+
</button>
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
</div>
|
| 193 |
)}
|
| 194 |
|