mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(proxy): extract bulk-import and pool-modal hooks (#6625)
This commit is contained in:
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user