diff --git a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx index 36a99ddebd..c84f8515b1 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx @@ -33,116 +33,6 @@ type TestResult = { error?: string; }; -type ParsedProxyEntry = { - name: string; - host: string; - port: number; - username: string; - password: string; - type: string; - region: string; - status: string; - notes: string; -}; - -type ParseError = { - line: number; - reason: string; -}; - -const EMPTY_FORM = { - id: "", - name: "", - type: "http", - host: "", - port: "8080", - username: "", - password: "", - region: "", - notes: "", - status: "active", - family: "auto", -}; - -const BULK_IMPORT_TEMPLATE = `# Proxy Bulk Import -# Format: NAME|HOST|PORT|USERNAME|PASSWORD|TYPE|REGION|STATUS|NOTES -# Required: NAME, HOST, PORT -# Optional: USERNAME, PASSWORD, TYPE (http|https|socks5, default: socks5), REGION, STATUS (active|inactive, default: active), NOTES -# Lines starting with # are ignored. Existing proxies (same host+port) will be updated. -# -# SOCKS5 examples: -# proxy-us|138.99.147.218|50101|myuser|mypass|socks5|US-East|active|US production proxy -# proxy-eu|200.234.177.62|50101|myuser|mypass|socks5|EU-West -# -# HTTP/HTTPS examples: -# http-proxy|10.0.0.50|8080|||http||active|Internal HTTP proxy -# https-proxy|proxy.example.com|443|admin|secret123|https|US|active -`; - -const VALID_TYPES = new Set(["http", "https", "socks5"]); -const VALID_STATUSES = new Set(["active", "inactive"]); - -function parseBulkImportText(text: string): { - entries: ParsedProxyEntry[]; - errors: ParseError[]; - skipped: number; -} { - const lines = text.split("\n"); - const entries: ParsedProxyEntry[] = []; - const errors: ParseError[] = []; - let skipped = 0; - - for (let i = 0; i < lines.length; i++) { - const raw = lines[i].trim(); - if (!raw || raw.startsWith("#")) { - skipped++; - continue; - } - - const parts = raw.split("|").map((p) => p.trim()); - const [name, host, portStr, username, password, type, region, status, notes] = parts; - const lineNum = i + 1; - - if (!name) { - errors.push({ line: lineNum, reason: "bulkImportErrorMissingName" }); - continue; - } - if (!host) { - errors.push({ line: lineNum, reason: "bulkImportErrorMissingHost" }); - continue; - } - const port = Number(portStr); - if (!portStr || isNaN(port) || port < 1 || port > 65535) { - errors.push({ line: lineNum, reason: "bulkImportErrorInvalidPort" }); - continue; - } - const normalizedType = (type || "socks5").toLowerCase(); - if (!VALID_TYPES.has(normalizedType)) { - errors.push({ line: lineNum, reason: "bulkImportErrorInvalidType" }); - continue; - } - const normalizedStatus = (status || "active").toLowerCase(); - if (!VALID_STATUSES.has(normalizedStatus)) { - errors.push({ line: lineNum, reason: "bulkImportErrorInvalidStatus" }); - continue; - } - - entries.push({ - name, - host, - port, - username: username || "", - password: password || "", - type: normalizedType, - region: region || "", - status: normalizedStatus, - notes: notes || "", - }); - } - - return { entries, errors, skipped }; -} - export default function ProxyRegistryManager({ onRedeployRelay, }: { @@ -181,22 +71,6 @@ export default function ProxyRegistryManager({ const [poolMembers, setPoolMembers] = useState([]); const [poolAddProxyId, setPoolAddProxyId] = useState(""); const [poolLoading, setPoolLoading] = useState(false); - const [poolSaving, setPoolSaving] = useState(false); - const [poolLoaded, setPoolLoaded] = useState(false); - - // Bulk Import state - const [bulkImportOpen, setBulkImportOpen] = useState(false); - const [bulkImportText, setBulkImportText] = useState(BULK_IMPORT_TEMPLATE); - const [bulkImportParsed, setBulkImportParsed] = useState([]); - const [bulkImportErrors, setBulkImportErrors] = useState([]); - const [bulkImportSkipped, setBulkImportSkipped] = useState(0); - const [bulkImportParsedOnce, setBulkImportParsedOnce] = useState(false); - const [bulkImporting, setBulkImporting] = useState(false); - const [bulkImportResult, setBulkImportResult] = useState<{ - created: number; - updated: number; - failed: number; - } | null>(null); const editingId = useMemo(() => form.id || "", [form.id]); diff --git a/src/app/(dashboard)/dashboard/settings/components/useProxyBulkImport.ts b/src/app/(dashboard)/dashboard/settings/components/useProxyBulkImport.ts new file mode 100644 index 0000000000..a152b8d7dc --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/useProxyBulkImport.ts @@ -0,0 +1,176 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { useTranslations } from "next-intl"; + +type ParsedProxyEntry = { + name: string; + host: string; + port: number; + username: string; + password: string; + type: string; + region: string; + status: string; + notes: string; +}; + +type ParseError = { + line: number; + reason: string; +}; + +type BulkImportResult = { + created: number; + updated: number; + failed: number; +}; + +const BULK_IMPORT_TEMPLATE = `# Proxy Bulk Import +# Format: NAME|HOST|PORT|USERNAME|PASSWORD|TYPE|REGION|STATUS|NOTES +# Required: NAME, HOST, PORT +# Optional: USERNAME, PASSWORD, TYPE (http|https|socks5, default: socks5), REGION, STATUS (active|inactive, default: active), NOTES +# Lines starting with # are ignored. Existing proxies (same host+port) will be updated. +# +# SOCKS5 examples: +# proxy-us|138.99.147.218|50101|myuser|mypass|socks5|US-East|active|US production proxy +# proxy-eu|200.234.177.62|50101|myuser|mypass|socks5|EU-West +# +# HTTP/HTTPS examples: +# http-proxy|10.0.0.50|8080|||http||active|Internal HTTP proxy +# https-proxy|proxy.example.com|443|admin|secret123|https|US|active +`; + +const VALID_TYPES = new Set(["http", "https", "socks5"]); +const VALID_STATUSES = new Set(["active", "inactive"]); + +export function parseBulkImportText(text: string): { + entries: ParsedProxyEntry[]; + errors: ParseError[]; + skipped: number; +} { + const lines = text.split("\n"); + const entries: ParsedProxyEntry[] = []; + const errors: ParseError[] = []; + let skipped = 0; + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i].trim(); + if (!raw || raw.startsWith("#")) { + skipped++; + continue; + } + + const parts = raw.split("|").map((p) => p.trim()); + const [name, host, portStr, username, password, type, region, status, notes] = parts; + const lineNum = i + 1; + + if (!name) { + errors.push({ line: lineNum, reason: "bulkImportErrorMissingName" }); + continue; + } + if (!host) { + errors.push({ line: lineNum, reason: "bulkImportErrorMissingHost" }); + continue; + } + const port = Number(portStr); + if (!portStr || isNaN(port) || port < 1 || port > 65535) { + errors.push({ line: lineNum, reason: "bulkImportErrorInvalidPort" }); + continue; + } + const normalizedType = (type || "socks5").toLowerCase(); + if (!VALID_TYPES.has(normalizedType)) { + errors.push({ line: lineNum, reason: "bulkImportErrorInvalidType" }); + continue; + } + const normalizedStatus = (status || "active").toLowerCase(); + if (!VALID_STATUSES.has(normalizedStatus)) { + errors.push({ line: lineNum, reason: "bulkImportErrorInvalidStatus" }); + continue; + } + + entries.push({ + name, + host, + port, + username: username || "", + password: password || "", + type: normalizedType, + region: region || "", + status: normalizedStatus, + notes: notes || "", + }); + } + + return { entries, errors, skipped }; +} + +interface UseProxyBulkImportOptions { + onImport: (entries: ParsedProxyEntry[]) => Promise; +} + +export function useProxyBulkImport({ onImport }: UseProxyBulkImportOptions) { + const t = useTranslations("proxyRegistry"); + const [open, setOpen] = useState(false); + const [text, setText] = useState(BULK_IMPORT_TEMPLATE); + const [parsed, setParsed] = useState([]); + const [errors, setErrors] = useState([]); + const [skipped, setSkipped] = useState(0); + const [parsedOnce, setParsedOnce] = useState(false); + const [importing, setImporting] = useState(false); + const [result, setResult] = useState(null); + + const handleParse = useCallback(() => { + const { entries, errors: parseErrors, skipped: skippedLines } = parseBulkImportText(text); + setParsed(entries); + setErrors(parseErrors); + setSkipped(skippedLines); + setParsedOnce(true); + setResult(null); + }, [text]); + + const handleImport = useCallback(async () => { + if (parsed.length === 0) return; + setImporting(true); + try { + const res = await onImport(parsed); + setResult(res); + if (res.failed === 0) { + setText(BULK_IMPORT_TEMPLATE); + setParsed([]); + setErrors([]); + setSkipped(0); + setParsedOnce(false); + } + } finally { + setImporting(false); + } + }, [parsed, onImport]); + + const handleClose = useCallback(() => { + setOpen(false); + setText(BULK_IMPORT_TEMPLATE); + setParsed([]); + setErrors([]); + setSkipped(0); + setParsedOnce(false); + setResult(null); + }, []); + + return { + open, + setOpen, + text, + setText, + parsed, + errors, + skipped, + parsedOnce, + importing, + result, + t, + handleParse, + handleImport, + handleClose, + }; +} diff --git a/src/app/(dashboard)/dashboard/settings/components/useProxyPoolModal.ts b/src/app/(dashboard)/dashboard/settings/components/useProxyPoolModal.ts new file mode 100644 index 0000000000..b23e48d6cb --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/useProxyPoolModal.ts @@ -0,0 +1,96 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { useTranslations } from "next-intl"; + +interface UseProxyPoolModalOptions { + items: { id: string; name: string }[]; + onSave: ( + scope: string, + scopeIds: string, + proxyId: string, + strategy: "round-robin" | "random" | "sticky" + ) => Promise; + onLoad: ( + scope: string, + scopeIds: string, + proxyId: string + ) => Promise<{ members: string[]; strategy: string } | null>; +} + +export function useProxyPoolModal({ items, onSave, onLoad }: UseProxyPoolModalOptions) { + const t = useTranslations("proxyRegistry"); + const [open, setOpen] = useState(false); + const [scope, setScope] = useState<"global" | "provider">("provider"); + const [scopeIds, setScopeIds] = useState(""); + const [proxyId, setProxyId] = useState(""); + const [strategy, setStrategy] = useState<"round-robin" | "random" | "sticky">("round-robin"); + const [members, setMembers] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [loaded, setLoaded] = useState(false); + + const handleLoad = useCallback(async () => { + if (!proxyId) return; + setLoading(true); + setError(null); + try { + const data = await onLoad(scope, scopeIds, proxyId); + if (data) { + setMembers(data.members); + setStrategy(data.strategy as "round-robin" | "random" | "sticky"); + setLoaded(true); + } + } catch (e: any) { + setError(e?.message || t("poolLoadFailed")); + } finally { + setLoading(false); + } + }, [scope, scopeIds, proxyId, onLoad, t]); + + const handleSave = useCallback(async () => { + if (!proxyId) return; + setLoading(true); + setError(null); + try { + await onSave(scope, scopeIds, proxyId, strategy); + setOpen(false); + } catch (e: any) { + setError(e?.message || t("poolSaveFailed")); + } finally { + setLoading(false); + } + }, [scope, scopeIds, proxyId, strategy, onSave, t]); + + const handleClose = useCallback(() => { + setOpen(false); + setScope("provider"); + setScopeIds(""); + setProxyId(""); + setStrategy("round-robin"); + setMembers([]); + setLoaded(false); + setError(null); + }, []); + + return { + open, + setOpen, + scope, + setScope, + scopeIds, + setScopeIds, + proxyId, + setProxyId, + strategy, + setStrategy, + members, + loading, + error, + loaded, + handleLoad, + handleSave, + handleClose, + t, + }; +} diff --git a/src/lib/db/migrations/119_free_proxy_sync_errors.sql b/src/lib/db/migrations/121_free_proxy_sync_errors.sql similarity index 91% rename from src/lib/db/migrations/119_free_proxy_sync_errors.sql rename to src/lib/db/migrations/121_free_proxy_sync_errors.sql index 3c365a869a..e6803d4459 100644 --- a/src/lib/db/migrations/119_free_proxy_sync_errors.sql +++ b/src/lib/db/migrations/121_free_proxy_sync_errors.sql @@ -1,4 +1,4 @@ --- 119_free_proxy_sync_errors.sql +-- 121_free_proxy_sync_errors.sql -- Per-source sync errors for the free-proxy pool. Each failed source writes its -- last error(s) here, keyed by source id, so a "Total: 0" result is honest -- instead of silent. Cleared on a successful sync for that source.