feat(proxy): extract bulk-import and pool-modal hooks (#6625)

This commit is contained in:
oyi77
2026-07-11 21:40:11 +07:00
parent 302f2ee5c1
commit 1b6b44b172
4 changed files with 273 additions and 127 deletions

View File

@@ -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<string[]>([]);
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<ParsedProxyEntry[]>([]);
const [bulkImportErrors, setBulkImportErrors] = useState<ParseError[]>([]);
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]);

View File

@@ -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<BulkImportResult>;
}
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<ParsedProxyEntry[]>([]);
const [errors, setErrors] = useState<ParseError[]>([]);
const [skipped, setSkipped] = useState(0);
const [parsedOnce, setParsedOnce] = useState(false);
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<BulkImportResult | null>(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,
};
}

View File

@@ -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<void>;
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<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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,
};
}

View File

@@ -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.