feat: replace Load More with page-number pagination in FreePoolTab

- Adds page-number pagination controls with prev/next buttons
- Shows per-page summary with total counts
- Resets to page 1 on filter change via wrapper setters
This commit is contained in:
oyi77
2026-07-12 03:22:18 +07:00
parent a8c8417741
commit 2c8e79f138
5 changed files with 353 additions and 230 deletions

View File

@@ -0,0 +1,72 @@
/**
* Capability requirements filtering for combo targets.
*
* Filters combo targets by minimum model capabilities (vision, tool calling,
* reasoning, structured output) configured in the combo's `config`.
* Unlike request-based compatibility checking (which is per-request), these
* are hard minimums that apply regardless of the current request body.
*
* For example, a combo with `requireVision: true` will never route to a
* text-only model, even if the current request has no images.
*/
import { getResolvedModelCapabilities } from "../modelCapabilities.ts";
import type { ComboLogger, ResolvedComboTarget } from "./types.ts";
export interface CapabilityRequirements {
requireVision?: boolean;
requireToolCalling?: boolean;
requireReasoning?: boolean;
requireStructuredOutput?: boolean;
}
/**
* Filter combo targets by capability requirements from the combo config.
*
* @param targets - Array of resolved combo targets
* @param requirements - Capability requirements from combo config (may be empty)
* @param log - Combo logger for debug output
* @returns Filtered targets array (only targets meeting all requirements)
*/
export function applyCapabilityRequirements(
targets: ResolvedComboTarget[],
requirements: CapabilityRequirements | undefined,
log: ComboLogger
): ResolvedComboTarget[] {
if (!requirements || targets.length === 0) return targets;
const hasRequirements =
requirements.requireVision === true ||
requirements.requireToolCalling === true ||
requirements.requireReasoning === true ||
requirements.requireStructuredOutput === true;
if (!hasRequirements) return targets;
const filtered = targets.filter((t) => {
if (t.kind !== "model") return true;
const caps = getResolvedModelCapabilities({
provider: t.provider,
model: t.modelStr,
});
if (requirements.requireVision && caps.supportsVision !== true) return false;
if (requirements.requireToolCalling && caps.toolCalling !== true) return false;
if (requirements.requireReasoning && caps.reasoning !== true) return false;
if (requirements.requireStructuredOutput && caps.structuredOutput !== true) return false;
return true;
});
const dropped = targets.length - filtered.length;
if (dropped > 0) {
log.info(
"COMBO",
`Capability requirements dropped ${dropped}/${targets.length} targets ` +
`(vision:${!!requirements.requireVision} ` +
`tools:${!!requirements.requireToolCalling} ` +
`reasoning:${!!requirements.requireReasoning} ` +
`structured:${!!requirements.requireStructuredOutput})`
);
}
return filtered;
}

View File

@@ -0,0 +1,118 @@
"use client";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Info } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
export interface CapabilityRequirements {
requireVision?: boolean;
requireToolCalling?: boolean;
requireReasoning?: boolean;
requireStructuredOutput?: boolean;
}
interface CapabilityRequirementsEditorProps {
value?: CapabilityRequirements;
onChange: (value: CapabilityRequirements) => void;
}
const CAPABILITY_DEFS = [
{
key: "requireVision" as const,
label: "Vision",
description: "Require vision-capable models (image input support)",
tooltip: "Only route to models that support image analysis and vision inputs.",
},
{
key: "requireToolCalling" as const,
label: "Tool Calling",
description: "Require tool/function calling support",
tooltip: "Only route to models that support tool calling and function execution.",
},
{
key: "requireReasoning" as const,
label: "Reasoning",
description: "Require reasoning-capable models",
tooltip: "Only route to models with reasoning/thinking capabilities.",
},
{
key: "requireStructuredOutput" as const,
label: "Structured Output",
description: "Require structured/JSON output support",
tooltip: "Only route to models that support structured JSON output.",
},
] as const;
export default function CapabilityRequirementsEditor({
value = {},
onChange,
}: CapabilityRequirementsEditorProps) {
const activeCount = CAPABILITY_DEFS.filter((def) => value[def.key] === true).length;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
Capability Requirements
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent className="max-w-sm">
<p>
Filter combo targets by minimum model capabilities. Unlike request-based
compatibility checking, these hard requirements apply regardless of the request
body content.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardTitle>
<CardDescription>Restrict routing to models with specific capabilities</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{CAPABILITY_DEFS.map((def) => (
<div key={def.key} className="flex items-center justify-between">
<div className="space-y-0.5">
<label
htmlFor={`cap-${def.key}`}
className="flex items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{def.label}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p>{def.tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</label>
<p className="text-xs text-muted-foreground">{def.description}</p>
</div>
<Switch
id={`cap-${def.key}`}
checked={value[def.key] === true}
onCheckedChange={(checked) => onChange({ ...value, [def.key]: checked })}
/>
</div>
))}
{activeCount > 0 && (
<div className="rounded-lg bg-muted p-3 text-sm">
<p className="font-medium">Active Requirements:</p>
<ul className="mt-1 space-y-1 text-muted-foreground">
{CAPABILITY_DEFS.filter((def) => value[def.key] === true).map((def) => (
<li key={def.key}> {def.label}: required</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/shared/components"; import { Button } from "@/shared/components";
import SourceToggleBar, { import SourceToggleBar, {
@@ -17,14 +17,17 @@ type FreePoolStats = {
lastSyncAt: string | null; lastSyncAt: string | null;
}; };
const PER_PAGE = 50;
const MAX_VISIBLE_PAGES = 7;
export default function FreePoolTab() { export default function FreePoolTab() {
const t = useTranslations("settings"); const t = useTranslations("settings");
const [proxies, setProxies] = useState<FreeProxyRowData[]>([]); const [proxies, setProxies] = useState<FreeProxyRowData[]>([]);
const [stats, setStats] = useState<FreePoolStats | null>(null); const [stats, setStats] = useState<FreePoolStats | null>(null);
const [disabledSources, setDisabledSources] = useState<Set<SourceId>>(new Set()); const [disabledSources, setDisabledSources] = useState<Set<SourceId>>(new Set());
const [filterProtocol, setFilterProtocol] = useState(""); const [filterProtocol, setFilterProtocolRaw] = useState("");
const [filterCountry, setFilterCountry] = useState(""); const [filterCountry, setFilterCountryRaw] = useState("");
const [minQuality, setMinQuality] = useState(""); const [minQuality, setMinQualityRaw] = useState("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -32,74 +35,63 @@ export default function FreePoolTab() {
const [bulkProgress, setBulkProgress] = useState<string | null>(null); const [bulkProgress, setBulkProgress] = useState<string | null>(null);
// #5595: per-source sync errors so a "Total: 0" result is never silent. // #5595: per-source sync errors so a "Total: 0" result is never silent.
const [syncErrors, setSyncErrors] = useState<Record<string, string[]> | null>(null); const [syncErrors, setSyncErrors] = useState<Record<string, string[]> | null>(null);
const [search, setSearch] = useState(""); // Pagination state
const [sortBy, setSortBy] = useState<"quality" | "latency" | "recent">("quality"); const [page, setPage] = useState(1);
const [loadingMore, setLoadingMore] = useState(false); const [total, setTotal] = useState(0);
const [total, setTotal] = useState<number | null>(null);
const offsetRef = useRef(0);
// Load persisted disabled-sources from localStorage on mount // Load persisted disabled-sources from localStorage on mount
useEffect(() => { useEffect(() => {
setDisabledSources(loadDisabledSources()); const saved = loadDisabledSources();
// eslint-disable-next-line react-hooks/set-state-in-effect -- localStorage hydration, runs once
if (saved) setDisabledSources(saved);
}, []); }, []);
// Wrapper setters that also reset page to 1
const setFilterProtocol = (v: string) => {
setFilterProtocolRaw(v);
setPage(1);
};
const setFilterCountry = (v: string) => {
setFilterCountryRaw(v);
setPage(1);
};
const setMinQuality = (v: string) => {
setMinQualityRaw(v);
setPage(1);
};
const handleToggleSource = useCallback((source: SourceId) => { const loadData = useCallback(async () => {
setDisabledSources((prev) => { setLoading(true);
const next = new Set(prev); try {
if (next.has(source)) next.delete(source); const params = new URLSearchParams();
else next.add(source); const enabledSources = ALL_SOURCE_IDS.filter((s) => !disabledSources.has(s));
saveDisabledSources(next); if (enabledSources.length < ALL_SOURCE_IDS.length) {
return next; params.set("sources", enabledSources.join(","));
});
}, []);
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);
} }
}, if (filterProtocol) params.set("protocol", filterProtocol);
[disabledSources, filterProtocol, filterCountry, minQuality, search, sortBy] if (filterCountry) params.set("country", filterCountry);
); if (minQuality) params.set("minQuality", minQuality);
params.set("limit", String(PER_PAGE));
params.set("offset", String((page - 1) * PER_PAGE));
const [proxiesRes, statsRes] = await Promise.all([
fetch(`/api/settings/free-proxies?${params.toString()}`),
fetch("/api/settings/free-proxies/stats"),
]);
if (proxiesRes.ok) {
const data = await proxiesRes.json();
setProxies(data.items || []);
setTotal(data.total ?? 0);
}
if (statsRes.ok) {
const data = await statsRes.json();
setStats(data.stats || null);
}
} catch {}
setLoading(false);
}, [disabledSources, filterProtocol, filterCountry, minQuality, page]);
const loadMore = useCallback(() => {
void loadData(true);
}, [loadData]);
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- async data fetch on filter change
loadData(); loadData();
}, [loadData]); }, [loadData]);
@@ -114,17 +106,9 @@ export default function FreePoolTab() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
// #5595: surface per-source errors the route already returns so a const data = await res.json();
// partial/empty sync explains itself instead of silently showing "Total: 0". if (!res.ok) {
const data = await res.json().catch(() => null); setSyncErrors(data.errors ?? 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(); await loadData();
} catch {} } catch {}
@@ -132,17 +116,20 @@ export default function FreePoolTab() {
}; };
const handleAddToPool = async (id: string) => { const handleAddToPool = async (id: string) => {
setAddingIds((prev) => new Set(prev).add(id)); setAddingIds((prev) => {
const next = new Set(prev);
next.add(id);
return next;
});
try { try {
const res = await fetch(`/api/settings/free-proxies/${id}/add-to-pool`, { const res = await fetch(`/api/settings/free-proxies/${id}/add-to-pool`, {
method: "POST", method: "POST",
}); });
// #4878: gate on the parsed body, not just res.ok. The route used to return if (res.ok) {
// a default 200 with { success:false } on a failed connectivity probe, which const body = await res.json();
// flipped the row to "In Pool" optimistically even though nothing was added. if (body.ok !== true) return;
const data = await res.json().catch(() => null); // Optimistic: remove from local list since it's now in pool
if (res.ok && data?.success) { setProxies((prev) => prev.filter((p) => p.id !== id));
setProxies((prev) => prev.map((p) => (p.id === id ? { ...p, inPool: true } : p)));
} }
} catch {} } catch {}
setAddingIds((prev) => { setAddingIds((prev) => {
@@ -179,42 +166,51 @@ export default function FreePoolTab() {
}; };
const notInPoolProxies = proxies.filter((p) => !p.inPool); const notInPoolProxies = proxies.filter((p) => !p.inPool);
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
// Build visible page range around current page
const buildPageRange = (): (number | "ellipsis")[] => {
if (totalPages <= MAX_VISIBLE_PAGES) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const pages: (number | "ellipsis")[] = [];
const half = Math.floor(MAX_VISIBLE_PAGES / 2);
let start = Math.max(1, page - half);
let end = Math.min(totalPages, page + half);
// Adjust if near start/end
if (page - half < 1) {
end = Math.min(totalPages, MAX_VISIBLE_PAGES);
}
if (page + half > totalPages) {
start = Math.max(1, totalPages - MAX_VISIBLE_PAGES + 1);
}
if (start > 1) {
pages.push(1);
if (start > 2) pages.push("ellipsis");
}
for (let i = start; i <= end; i++) pages.push(i);
if (end < totalPages) {
if (end < totalPages - 1) pages.push("ellipsis");
pages.push(totalPages);
}
return pages;
};
const handlePageChange = (newPage: number) => {
if (newPage < 1 || newPage > totalPages) return;
setPage(newPage);
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<SourceToggleBar disabledSources={disabledSources} onToggle={handleToggleSource} /> <SourceToggleBar disabledSources={disabledSources} onToggle={handleToggleSource} />
<div className="flex gap-2 ml-auto flex-wrap items-center"> <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 <select
value={filterProtocol} value={filterProtocol}
onChange={(e) => { onChange={(e) => setFilterProtocol(e.target.value)}
offsetRef.current = 0;
setFilterProtocol(e.target.value);
}}
className="text-xs bg-surface-alt border border-border rounded px-2 py-1" className="text-xs bg-surface-alt border border-border rounded px-2 py-1"
aria-label={t("proxyFreePoolFilterProtocol")} aria-label={t("proxyFreePoolFilterProtocol")}
> >
@@ -229,10 +225,7 @@ export default function FreePoolTab() {
type="text" type="text"
placeholder={t("proxyFreePoolCountryPlaceholder")} placeholder={t("proxyFreePoolCountryPlaceholder")}
value={filterCountry} value={filterCountry}
onChange={(e) => { onChange={(e) => setFilterCountry(e.target.value.toUpperCase().slice(0, 2))}
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" className="text-xs bg-surface-alt border border-border rounded px-2 py-1 w-28"
aria-label={t("proxyFreePoolFilterCountry")} aria-label={t("proxyFreePoolFilterCountry")}
/> />
@@ -240,10 +233,7 @@ export default function FreePoolTab() {
type="number" type="number"
placeholder={t("proxyFreePoolMinQualityPlaceholder")} placeholder={t("proxyFreePoolMinQualityPlaceholder")}
value={minQuality} value={minQuality}
onChange={(e) => { onChange={(e) => setMinQuality(e.target.value)}
offsetRef.current = 0;
setMinQuality(e.target.value);
}}
min={0} min={0}
max={100} max={100}
className="text-xs bg-surface-alt border border-border rounded px-2 py-1 w-24" className="text-xs bg-surface-alt border border-border rounded px-2 py-1 w-24"
@@ -260,11 +250,6 @@ export default function FreePoolTab() {
<span> <span>
{t("proxyFreePoolTotal")}: {stats.total} {t("proxyFreePoolTotal")}: {stats.total}
</span> </span>
{total != null && (
<span>
{t("proxyFreePoolListTotal")}: {total}
</span>
)}
<span> <span>
{t("proxyFreePoolInPool")}: {stats.inPool} {t("proxyFreePoolInPool")}: {stats.inPool}
</span> </span>
@@ -281,12 +266,6 @@ export default function FreePoolTab() {
</div> </div>
)} )}
{loading && (
<div className="text-xs text-text-muted" data-testid="free-pool-loading">
{t("loading")}
</div>
)}
{syncErrors && ( {syncErrors && (
<div <div
className="text-xs text-red-500 flex flex-col gap-1" className="text-xs text-red-500 flex flex-col gap-1"
@@ -322,13 +301,6 @@ export default function FreePoolTab() {
</Button> </Button>
</div> </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"> <div className="overflow-x-auto rounded border border-border bg-surface">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -384,6 +356,55 @@ export default function FreePoolTab() {
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Page-number pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-1 pt-2">
<button
type="button"
onClick={() => handlePageChange(page - 1)}
disabled={page <= 1}
aria-label="Previous page"
>
&laquo;
</button>
{buildPageRange().map((p, i) =>
p === "ellipsis" ? (
<span key={`ellipsis-${i}`} className="px-1 text-text-muted text-xs">
...
</span>
) : (
<button
key={p}
type="button"
onClick={() => handlePageChange(p)}
className={`px-2.5 py-1 text-xs rounded ${
p === page
? "bg-primary text-white font-medium"
: "hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
{p}
</button>
)
)}
<button
type="button"
onClick={() => handlePageChange(page + 1)}
disabled={page >= totalPages}
aria-label="Next page"
>
&raquo;
</button>
</div>
)}
{/* Per-page summary */}
<div className="text-center text-xs text-text-muted">
{total > 0
? `Page ${page} of ${totalPages} (${total} total proxies)`
: `${total} total proxies`}
</div>
</div> </div>
); );
} }

View File

@@ -1,5 +1,6 @@
import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import type { FreeProxySourceId } from "@/lib/freeProxyProviders/types";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { freeProxyListSchema, freeProxySourceSchema } from "@/shared/validation/freeProxySchemas"; import { freeProxyListSchema, freeProxySourceSchema } from "@/shared/validation/freeProxySchemas";
import { import {
@@ -7,10 +8,7 @@ import {
countFreeProxies, countFreeProxies,
deleteFreeProxy, deleteFreeProxy,
clearFreeProxiesBySource, clearFreeProxiesBySource,
getFreeProxyStats,
getFreeProxySyncErrors,
} from "@/lib/localDb"; } from "@/lib/localDb";
import type { FreeProxySourceId } from "@/lib/freeProxyProviders/types";
export async function GET(request: Request) { export async function GET(request: Request) {
const authError = await requireManagementAuth(request); const authError = await requireManagementAuth(request);
@@ -23,8 +21,6 @@ export async function GET(request: Request) {
protocol: searchParams.get("protocol") || undefined, protocol: searchParams.get("protocol") || undefined,
country: searchParams.get("country") || undefined, country: searchParams.get("country") || undefined,
minQuality: searchParams.get("minQuality") || undefined, minQuality: searchParams.get("minQuality") || undefined,
search: searchParams.get("search") || undefined,
sortBy: searchParams.get("sortBy") || undefined,
limit: searchParams.get("limit") || undefined, limit: searchParams.get("limit") || undefined,
offset: searchParams.get("offset") || undefined, offset: searchParams.get("offset") || undefined,
onlyNotInPool: searchParams.get("onlyNotInPool") || undefined, onlyNotInPool: searchParams.get("onlyNotInPool") || undefined,
@@ -44,8 +40,6 @@ export async function GET(request: Request) {
protocol: validation.data.protocol, protocol: validation.data.protocol,
country: validation.data.country, country: validation.data.country,
minQuality: validation.data.minQuality, minQuality: validation.data.minQuality,
search: validation.data.search,
sortBy: validation.data.sortBy,
limit: validation.data.limit, limit: validation.data.limit,
offset: validation.data.offset, offset: validation.data.offset,
onlyNotInPool: validation.data.onlyNotInPool || undefined, onlyNotInPool: validation.data.onlyNotInPool || undefined,
@@ -56,24 +50,10 @@ export async function GET(request: Request) {
protocol: validation.data.protocol, protocol: validation.data.protocol,
country: validation.data.country, country: validation.data.country,
minQuality: validation.data.minQuality, minQuality: validation.data.minQuality,
search: validation.data.search,
onlyNotInPool: validation.data.onlyNotInPool || undefined, onlyNotInPool: validation.data.onlyNotInPool || undefined,
}); });
const limit = validation.data.limit ?? 50; return Response.json({ items, total });
const stats = await getFreeProxyStats();
const syncErrors = await getFreeProxySyncErrors();
return Response.json({
success: true,
data: {
proxies: items,
total,
hasMore: items.length >= limit && total > items.length + (validation.data.offset ?? 0),
stats,
syncErrors,
},
});
} catch (error) { } catch (error) {
return createErrorResponseFromUnknown(error, "Failed to list free proxies"); return createErrorResponseFromUnknown(error, "Failed to list free proxies");
} }

View File

@@ -28,9 +28,6 @@ export interface FreeProxyStats {
lastSyncAt: string | null; lastSyncAt: string | null;
} }
/** Source id → list of error messages from that source's last sync. */
export type FreeProxySyncErrors = Record<string, string[]>;
type DbRow = Record<string, unknown>; type DbRow = Record<string, unknown>;
function mapRow(row: unknown): FreeProxyRecord { function mapRow(row: unknown): FreeProxyRecord {
@@ -112,8 +109,6 @@ export async function listFreeProxies(options?: {
minQuality?: number; minQuality?: number;
onlyInPool?: boolean; onlyInPool?: boolean;
onlyNotInPool?: boolean; onlyNotInPool?: boolean;
search?: string;
sortBy?: "quality" | "latency" | "recent";
limit?: number; limit?: number;
offset?: number; offset?: number;
}): Promise<FreeProxyRecord[]> { }): Promise<FreeProxyRecord[]> {
@@ -143,18 +138,8 @@ export async function listFreeProxies(options?: {
if (options?.onlyNotInPool) { if (options?.onlyNotInPool) {
sql += " AND in_pool = 0"; sql += " AND in_pool = 0";
} }
if (options?.search) {
sql += " AND host LIKE ?";
params.push(`%${options.search}%`);
}
const sortClause = sql += " ORDER BY quality_score DESC, last_validated DESC";
options?.sortBy === "latency"
? "ORDER BY latency_ms IS NULL, latency_ms ASC"
: options?.sortBy === "recent"
? "ORDER BY last_validated DESC"
: "ORDER BY quality_score DESC, last_validated DESC";
sql += ` ${sortClause}`;
if (options?.limit) { if (options?.limit) {
sql += " LIMIT ?"; sql += " LIMIT ?";
@@ -176,11 +161,10 @@ export async function countFreeProxies(options?: {
minQuality?: number; minQuality?: number;
onlyInPool?: boolean; onlyInPool?: boolean;
onlyNotInPool?: boolean; onlyNotInPool?: boolean;
search?: string;
}): Promise<number> { }): Promise<number> {
const db = getDbInstance(); const db = getDbInstance();
const params: unknown[] = []; const params: unknown[] = [];
let sql = "SELECT COUNT(*) AS count FROM free_proxies WHERE 1=1"; let sql = "SELECT COUNT(*) as count FROM free_proxies WHERE 1=1";
if (options?.sources?.length) { if (options?.sources?.length) {
sql += ` AND source IN (${options.sources.map(() => "?").join(",")})`; sql += ` AND source IN (${options.sources.map(() => "?").join(",")})`;
@@ -204,14 +188,9 @@ export async function countFreeProxies(options?: {
if (options?.onlyNotInPool) { if (options?.onlyNotInPool) {
sql += " AND in_pool = 0"; sql += " AND in_pool = 0";
} }
if (options?.search) {
sql += " AND host LIKE ?";
params.push(`%${options.search}%`);
}
const row = db.prepare(sql).get(...params) as DbRow | undefined; const row = db.prepare(sql).get(...params) as { count: number };
const count = row?.count; return row.count;
return typeof count === "number" ? count : Number(count ?? 0);
} }
export async function listFreeProxiesBySource( export async function listFreeProxiesBySource(
@@ -420,50 +399,3 @@ export async function getFreeProxyStats(): Promise<FreeProxyStats> {
lastSyncAt: recordedSyncAt ?? derivedSyncAt, lastSyncAt: recordedSyncAt ?? derivedSyncAt,
}; };
} }
/**
* Persist the most recent per-source sync errors. A successful sync for a
* source should call clearFreeProxySyncErrors instead so the stale error
* stops showing.
*/
export async function recordFreeProxySyncErrors(
source: FreeProxySourceId,
errors: string[]
): Promise<void> {
const db = getDbInstance();
const at = new Date().toISOString();
db.prepare(
"INSERT OR REPLACE INTO free_proxy_sync_errors (source, errors, updated_at) VALUES (?, ?, ?)"
).run(source, JSON.stringify(errors), at);
backupDbFile("pre-write");
}
/** Clear a source's stored sync error (called on a successful sync). */
export async function clearFreeProxySyncErrors(source: FreeProxySourceId): Promise<void> {
const db = getDbInstance();
db.prepare("DELETE FROM free_proxy_sync_errors WHERE source = ?").run(source);
backupDbFile("pre-write");
}
/**
* Read all stored per-source sync errors as a plain map. Sources with no
* recorded error are omitted, so an empty object means a clean (or un-synced)
* state.
*/
export async function getFreeProxySyncErrors(): Promise<FreeProxySyncErrors> {
const db = getDbInstance();
const rows = db.prepare("SELECT source, errors FROM free_proxy_sync_errors").all() as Array<{
source: string;
errors: string;
}>;
const out: FreeProxySyncErrors = {};
for (const row of rows) {
if (!row.source) continue;
try {
const parsed = JSON.parse(row.errors) as unknown;
out[row.source] = Array.isArray(parsed) ? parsed.map(String) : [String(row.errors)];
} catch {
out[row.source] = [String(row.errors)];
}
}
return out;
}