Files
OmniRoute/src/app/(dashboard)/dashboard/settings/components/proxy/FreePoolTab.tsx

390 lines
14 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components";
import SourceToggleBar, {
type SourceId,
ALL_SOURCE_IDS,
loadDisabledSources,
saveDisabledSources,
} from "./SourceToggleBar";
import FreeProxyRow, { type FreeProxyRowData } from "./FreeProxyRow";
type FreePoolStats = {
total: number;
inPool: number;
avgQuality: number | null;
lastSyncAt: string | null;
};
export default function FreePoolTab() {
const t = useTranslations("settings");
const [proxies, setProxies] = useState<FreeProxyRowData[]>([]);
const [stats, setStats] = useState<FreePoolStats | null>(null);
const [disabledSources, setDisabledSources] = useState<Set<SourceId>>(new Set());
const [filterProtocol, setFilterProtocol] = useState("");
const [filterCountry, setFilterCountry] = useState("");
const [minQuality, setMinQuality] = useState("");
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [addingIds, setAddingIds] = useState<Set<string>>(new Set());
const [bulkProgress, setBulkProgress] = useState<string | null>(null);
// #5595: per-source sync errors so a "Total: 0" result is never silent.
const [syncErrors, setSyncErrors] = useState<Record<string, string[]> | null>(null);
const [search, setSearch] = useState("");
const [sortBy, setSortBy] = useState<"quality" | "latency" | "recent">("quality");
const [loadingMore, setLoadingMore] = useState(false);
const [total, setTotal] = useState<number | null>(null);
const offsetRef = useRef(0);
// Load persisted disabled-sources from localStorage on mount
useEffect(() => {
setDisabledSources(loadDisabledSources());
}, []);
const handleToggleSource = useCallback((source: SourceId) => {
setDisabledSources((prev) => {
const next = new Set(prev);
if (next.has(source)) next.delete(source);
else next.add(source);
saveDisabledSources(next);
return next;
});
}, []);
const loadData = useCallback(
async (append = false) => {
if (append) setLoadingMore(true);
else setLoading(true);
try {
const params = new URLSearchParams();
const enabledSources = ALL_SOURCE_IDS.filter((s) => !disabledSources.has(s));
if (enabledSources.length < ALL_SOURCE_IDS.length) {
params.set("sources", enabledSources.join(","));
}
if (filterProtocol) params.set("protocol", filterProtocol);
if (filterCountry) params.set("country", filterCountry);
if (minQuality) params.set("minQuality", minQuality);
if (search.trim()) params.set("search", search.trim());
if (sortBy) params.set("sortBy", sortBy);
params.set("limit", "50");
params.set("offset", append ? String(offsetRef.current) : "0");
const [proxiesRes, statsRes] = await Promise.all([
fetch(`/api/settings/free-proxies?${params.toString()}`),
append ? null : fetch("/api/settings/free-proxies/stats"),
]);
if (proxiesRes.ok) {
const data = await proxiesRes.json();
const fetched: FreeProxyRowData[] = data.items || [];
setProxies((prev) => (append ? [...prev, ...fetched] : fetched));
setTotal(typeof data.total === "number" ? data.total : fetched.length);
if (append) offsetRef.current += fetched.length;
}
if (!append && statsRes?.ok) {
const data = await statsRes.json();
setStats(data.stats || null);
}
} catch {
// surface nothing on transient failure; table keeps last good data
} finally {
setLoading(false);
setLoadingMore(false);
}
},
[disabledSources, filterProtocol, filterCountry, minQuality, search, sortBy]
);
const loadMore = useCallback(() => {
void loadData(true);
}, [loadData]);
useEffect(() => {
loadData();
}, [loadData]);
const handleSync = async () => {
setSyncing(true);
setSyncErrors(null);
try {
const enabledSources = ALL_SOURCE_IDS.filter((s) => !disabledSources.has(s));
const body = enabledSources.length < ALL_SOURCE_IDS.length ? { sources: enabledSources } : {};
const res = await fetch("/api/settings/free-proxies/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
// #5595: surface per-source errors the route already returns so a
// partial/empty sync explains itself instead of silently showing "Total: 0".
const data = await res.json().catch(() => null);
if (data?.results) {
const errs: Record<string, string[]> = {};
for (const [src, r] of Object.entries(
data.results as Record<string, { errors?: string[] }>
)) {
if (Array.isArray(r?.errors) && r.errors.length > 0) errs[src] = r.errors;
}
if (Object.keys(errs).length > 0) setSyncErrors(errs);
}
await loadData();
} catch {}
setSyncing(false);
};
const handleAddToPool = async (id: string) => {
setAddingIds((prev) => new Set(prev).add(id));
try {
const res = await fetch(`/api/settings/free-proxies/${id}/add-to-pool`, {
method: "POST",
});
// #4878: gate on the parsed body, not just res.ok. The route used to return
// a default 200 with { success:false } on a failed connectivity probe, which
// flipped the row to "In Pool" optimistically even though nothing was added.
const data = await res.json().catch(() => null);
if (res.ok && data?.success) {
setProxies((prev) => prev.map((p) => (p.id === id ? { ...p, inPool: true } : p)));
}
} catch {}
setAddingIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
};
const handleToggleSelect = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleBulkAdd = async (ids: string[]) => {
if (!ids.length) return;
setBulkProgress("Testing proxies...");
try {
const res = await fetch("/api/settings/free-proxies/bulk-add-to-pool", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
});
const data = await res.json();
setBulkProgress(`${data.succeeded ?? 0} added, ${data.failed ?? 0} failed`);
await loadData();
setSelected(new Set());
} catch {}
setTimeout(() => setBulkProgress(null), 4000);
};
const notInPoolProxies = proxies.filter((p) => !p.inPool);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<SourceToggleBar disabledSources={disabledSources} onToggle={handleToggleSource} />
<div className="flex gap-2 ml-auto flex-wrap items-center">
<input
type="text"
placeholder={t("proxyFreePoolSearchPlaceholder")}
value={search}
onChange={(e) => {
offsetRef.current = 0;
setSearch(e.target.value);
}}
className="text-xs bg-surface-alt border border-border rounded px-2 py-1 w-40"
aria-label={t("proxyFreePoolSearchLabel")}
/>
<select
value={sortBy}
onChange={(e) => {
offsetRef.current = 0;
setSortBy(e.target.value as "quality" | "latency" | "recent");
}}
className="text-xs bg-surface-alt border border-border rounded px-2 py-1"
aria-label={t("proxyFreePoolSortLabel")}
>
<option value="quality">{t("proxyFreePoolSortQuality")}</option>
<option value="latency">{t("proxyFreePoolSortLatency")}</option>
<option value="recent">{t("proxyFreePoolSortRecent")}</option>
</select>
<select
value={filterProtocol}
onChange={(e) => {
offsetRef.current = 0;
setFilterProtocol(e.target.value);
}}
className="text-xs bg-surface-alt border border-border rounded px-2 py-1"
aria-label={t("proxyFreePoolFilterProtocol")}
>
<option value="">{t("proxyFreePoolProtocol")}</option>
{["http", "https", "socks4", "socks5"].map((p) => (
<option key={p} value={p}>
{p.toUpperCase()}
</option>
))}
</select>
<input
type="text"
placeholder={t("proxyFreePoolCountryPlaceholder")}
value={filterCountry}
onChange={(e) => {
offsetRef.current = 0;
setFilterCountry(e.target.value.toUpperCase().slice(0, 2));
}}
className="text-xs bg-surface-alt border border-border rounded px-2 py-1 w-28"
aria-label={t("proxyFreePoolFilterCountry")}
/>
<input
type="number"
placeholder={t("proxyFreePoolMinQualityPlaceholder")}
value={minQuality}
onChange={(e) => {
offsetRef.current = 0;
setMinQuality(e.target.value);
}}
min={0}
max={100}
className="text-xs bg-surface-alt border border-border rounded px-2 py-1 w-24"
aria-label={t("proxyFreePoolMinQualityLabel")}
/>
<Button size="sm" variant="secondary" icon="sync" onClick={handleSync} disabled={syncing}>
{syncing ? t("syncing") : t("proxyFreePoolSyncAll")}
</Button>
</div>
</div>
{stats && (
<div className="text-xs text-text-muted flex gap-4 flex-wrap">
<span>
{t("proxyFreePoolTotal")}: {stats.total}
</span>
{total != null && (
<span>
{t("proxyFreePoolListTotal")}: {total}
</span>
)}
<span>
{t("proxyFreePoolInPool")}: {stats.inPool}
</span>
{stats.avgQuality != null && (
<span>
{t("proxyFreePoolAvgQuality")}: {stats.avgQuality}
</span>
)}
{stats.lastSyncAt && (
<span>
{t("lastSync")}: {new Date(stats.lastSyncAt).toLocaleTimeString()}
</span>
)}
</div>
)}
{loading && (
<div className="text-xs text-text-muted" data-testid="free-pool-loading">
{t("loading")}
</div>
)}
{syncErrors && (
<div
className="text-xs text-red-500 flex flex-col gap-1"
role="alert"
data-testid="free-pool-sync-errors"
>
{Object.entries(syncErrors).map(([src, errs]) => (
<span key={src}>
{src}: {errs.join("; ")}
</span>
))}
</div>
)}
{selected.size > 0 && (
<div className="flex items-center gap-2 p-2 bg-primary/10 rounded border border-primary/20">
<span className="text-xs">{t("proxyFreePoolSelected", { count: selected.size })}</span>
<Button size="sm" variant="primary" onClick={() => handleBulkAdd(Array.from(selected))}>
{t("proxyFreePoolAddSelected")}
</Button>
{bulkProgress && <span className="text-xs text-text-muted">{bulkProgress}</span>}
</div>
)}
{notInPoolProxies.length > 0 && selected.size === 0 && (
<div className="flex justify-end">
<Button
size="sm"
variant="secondary"
onClick={() => handleBulkAdd(notInPoolProxies.slice(0, 100).map((p) => p.id))}
>
{t("proxyFreePoolAddVisible")}
</Button>
</div>
)}
{!loading && total != null && proxies.length < total && (
<div className="flex justify-center pt-1">
<Button size="sm" variant="secondary" onClick={loadMore} disabled={loadingMore}>
{loadingMore ? t("loading") : t("proxyFreePoolLoadMore")}
</Button>
</div>
)}
<div className="overflow-x-auto rounded border border-border bg-surface">
<table className="w-full text-sm">
<thead className="bg-surface-alt text-text-muted text-xs">
<tr>
<th className="px-3 py-2 text-left w-8" scope="col"></th>
<th className="px-3 py-2 text-left" scope="col">
{t("proxyFreePoolSource")}
</th>
<th className="px-3 py-2 text-left" scope="col">
{t("proxyFreePoolHostPort")}
</th>
<th className="px-3 py-2 text-left" scope="col">
{t("proxyFreePoolType")}
</th>
<th className="px-3 py-2 text-left" scope="col">
{t("proxyFreePoolCountry")}
</th>
<th className="px-3 py-2 text-left" scope="col">
{t("proxyFreePoolQuality")}
</th>
<th className="px-3 py-2 text-left" scope="col">
{t("proxyFreePoolLatency")}
</th>
<th className="px-3 py-2 text-left" scope="col"></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={8} className="px-3 py-8 text-center text-text-muted">
{t("loading")}
</td>
</tr>
) : proxies.length === 0 ? (
<tr>
<td colSpan={8} className="px-3 py-8 text-center text-text-muted">
{t("proxyFreePoolEmpty")}
</td>
</tr>
) : (
proxies.map((p) => (
<FreeProxyRow
key={p.id}
proxy={p}
selected={selected.has(p.id)}
onToggleSelect={handleToggleSelect}
onAddToPool={handleAddToPool}
adding={addingIds.has(p.id)}
/>
))
)}
</tbody>
</table>
</div>
</div>
);
}