mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
72
open-sse/services/combo/capabilityRequirements.ts
Normal file
72
open-sse/services/combo/capabilityRequirements.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/shared/components";
|
||||
import SourceToggleBar, {
|
||||
@@ -17,14 +17,17 @@ type FreePoolStats = {
|
||||
lastSyncAt: string | null;
|
||||
};
|
||||
|
||||
const PER_PAGE = 50;
|
||||
const MAX_VISIBLE_PAGES = 7;
|
||||
|
||||
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 [filterProtocol, setFilterProtocolRaw] = useState("");
|
||||
const [filterCountry, setFilterCountryRaw] = useState("");
|
||||
const [minQuality, setMinQualityRaw] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
@@ -32,31 +35,32 @@ export default function FreePoolTab() {
|
||||
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);
|
||||
// Pagination state
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
// Load persisted disabled-sources from localStorage on mount
|
||||
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) => {
|
||||
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);
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
const enabledSources = ALL_SOURCE_IDS.filter((s) => !disabledSources.has(s));
|
||||
@@ -66,40 +70,28 @@ export default function FreePoolTab() {
|
||||
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");
|
||||
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()}`),
|
||||
append ? null : fetch("/api/settings/free-proxies/stats"),
|
||||
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;
|
||||
setProxies(data.items || []);
|
||||
setTotal(data.total ?? 0);
|
||||
}
|
||||
if (!append && statsRes?.ok) {
|
||||
if (statsRes.ok) {
|
||||
const data = await statsRes.json();
|
||||
setStats(data.stats || null);
|
||||
}
|
||||
} catch {
|
||||
// surface nothing on transient failure; table keeps last good data
|
||||
} finally {
|
||||
} catch {}
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
},
|
||||
[disabledSources, filterProtocol, filterCountry, minQuality, search, sortBy]
|
||||
);
|
||||
}, [disabledSources, filterProtocol, filterCountry, minQuality, page]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
void loadData(true);
|
||||
}, [loadData]);
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- async data fetch on filter change
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
@@ -114,17 +106,9 @@ export default function FreePoolTab() {
|
||||
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);
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setSyncErrors(data.errors ?? null);
|
||||
}
|
||||
await loadData();
|
||||
} catch {}
|
||||
@@ -132,17 +116,20 @@ export default function FreePoolTab() {
|
||||
};
|
||||
|
||||
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 {
|
||||
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)));
|
||||
if (res.ok) {
|
||||
const body = await res.json();
|
||||
if (body.ok !== true) return;
|
||||
// Optimistic: remove from local list since it's now in pool
|
||||
setProxies((prev) => prev.filter((p) => p.id !== id));
|
||||
}
|
||||
} catch {}
|
||||
setAddingIds((prev) => {
|
||||
@@ -179,42 +166,51 @@ export default function FreePoolTab() {
|
||||
};
|
||||
|
||||
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 (
|
||||
<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);
|
||||
}}
|
||||
onChange={(e) => setFilterProtocol(e.target.value)}
|
||||
className="text-xs bg-surface-alt border border-border rounded px-2 py-1"
|
||||
aria-label={t("proxyFreePoolFilterProtocol")}
|
||||
>
|
||||
@@ -229,10 +225,7 @@ export default function FreePoolTab() {
|
||||
type="text"
|
||||
placeholder={t("proxyFreePoolCountryPlaceholder")}
|
||||
value={filterCountry}
|
||||
onChange={(e) => {
|
||||
offsetRef.current = 0;
|
||||
setFilterCountry(e.target.value.toUpperCase().slice(0, 2));
|
||||
}}
|
||||
onChange={(e) => 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")}
|
||||
/>
|
||||
@@ -240,10 +233,7 @@ export default function FreePoolTab() {
|
||||
type="number"
|
||||
placeholder={t("proxyFreePoolMinQualityPlaceholder")}
|
||||
value={minQuality}
|
||||
onChange={(e) => {
|
||||
offsetRef.current = 0;
|
||||
setMinQuality(e.target.value);
|
||||
}}
|
||||
onChange={(e) => setMinQuality(e.target.value)}
|
||||
min={0}
|
||||
max={100}
|
||||
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>
|
||||
{t("proxyFreePoolTotal")}: {stats.total}
|
||||
</span>
|
||||
{total != null && (
|
||||
<span>
|
||||
{t("proxyFreePoolListTotal")}: {total}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{t("proxyFreePoolInPool")}: {stats.inPool}
|
||||
</span>
|
||||
@@ -281,12 +266,6 @@ export default function FreePoolTab() {
|
||||
</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"
|
||||
@@ -322,13 +301,6 @@ export default function FreePoolTab() {
|
||||
</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">
|
||||
@@ -384,6 +356,55 @@ export default function FreePoolTab() {
|
||||
</tbody>
|
||||
</table>
|
||||
</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"
|
||||
>
|
||||
«
|
||||
</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"
|
||||
>
|
||||
»
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
|
||||
import type { FreeProxySourceId } from "@/lib/freeProxyProviders/types";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { freeProxyListSchema, freeProxySourceSchema } from "@/shared/validation/freeProxySchemas";
|
||||
import {
|
||||
@@ -7,10 +8,7 @@ import {
|
||||
countFreeProxies,
|
||||
deleteFreeProxy,
|
||||
clearFreeProxiesBySource,
|
||||
getFreeProxyStats,
|
||||
getFreeProxySyncErrors,
|
||||
} from "@/lib/localDb";
|
||||
import type { FreeProxySourceId } from "@/lib/freeProxyProviders/types";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
@@ -23,8 +21,6 @@ export async function GET(request: Request) {
|
||||
protocol: searchParams.get("protocol") || undefined,
|
||||
country: searchParams.get("country") || undefined,
|
||||
minQuality: searchParams.get("minQuality") || undefined,
|
||||
search: searchParams.get("search") || undefined,
|
||||
sortBy: searchParams.get("sortBy") || undefined,
|
||||
limit: searchParams.get("limit") || undefined,
|
||||
offset: searchParams.get("offset") || undefined,
|
||||
onlyNotInPool: searchParams.get("onlyNotInPool") || undefined,
|
||||
@@ -44,8 +40,6 @@ export async function GET(request: Request) {
|
||||
protocol: validation.data.protocol,
|
||||
country: validation.data.country,
|
||||
minQuality: validation.data.minQuality,
|
||||
search: validation.data.search,
|
||||
sortBy: validation.data.sortBy,
|
||||
limit: validation.data.limit,
|
||||
offset: validation.data.offset,
|
||||
onlyNotInPool: validation.data.onlyNotInPool || undefined,
|
||||
@@ -56,24 +50,10 @@ export async function GET(request: Request) {
|
||||
protocol: validation.data.protocol,
|
||||
country: validation.data.country,
|
||||
minQuality: validation.data.minQuality,
|
||||
search: validation.data.search,
|
||||
onlyNotInPool: validation.data.onlyNotInPool || undefined,
|
||||
});
|
||||
|
||||
const limit = validation.data.limit ?? 50;
|
||||
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,
|
||||
},
|
||||
});
|
||||
return Response.json({ items, total });
|
||||
} catch (error) {
|
||||
return createErrorResponseFromUnknown(error, "Failed to list free proxies");
|
||||
}
|
||||
|
||||
@@ -28,9 +28,6 @@ export interface FreeProxyStats {
|
||||
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>;
|
||||
|
||||
function mapRow(row: unknown): FreeProxyRecord {
|
||||
@@ -112,8 +109,6 @@ export async function listFreeProxies(options?: {
|
||||
minQuality?: number;
|
||||
onlyInPool?: boolean;
|
||||
onlyNotInPool?: boolean;
|
||||
search?: string;
|
||||
sortBy?: "quality" | "latency" | "recent";
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<FreeProxyRecord[]> {
|
||||
@@ -143,18 +138,8 @@ export async function listFreeProxies(options?: {
|
||||
if (options?.onlyNotInPool) {
|
||||
sql += " AND in_pool = 0";
|
||||
}
|
||||
if (options?.search) {
|
||||
sql += " AND host LIKE ?";
|
||||
params.push(`%${options.search}%`);
|
||||
}
|
||||
|
||||
const sortClause =
|
||||
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}`;
|
||||
sql += " ORDER BY quality_score DESC, last_validated DESC";
|
||||
|
||||
if (options?.limit) {
|
||||
sql += " LIMIT ?";
|
||||
@@ -176,11 +161,10 @@ export async function countFreeProxies(options?: {
|
||||
minQuality?: number;
|
||||
onlyInPool?: boolean;
|
||||
onlyNotInPool?: boolean;
|
||||
search?: string;
|
||||
}): Promise<number> {
|
||||
const db = getDbInstance();
|
||||
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) {
|
||||
sql += ` AND source IN (${options.sources.map(() => "?").join(",")})`;
|
||||
@@ -204,14 +188,9 @@ export async function countFreeProxies(options?: {
|
||||
if (options?.onlyNotInPool) {
|
||||
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 count = row?.count;
|
||||
return typeof count === "number" ? count : Number(count ?? 0);
|
||||
const row = db.prepare(sql).get(...params) as { count: number };
|
||||
return row.count;
|
||||
}
|
||||
|
||||
export async function listFreeProxiesBySource(
|
||||
@@ -420,50 +399,3 @@ export async function getFreeProxyStats(): Promise<FreeProxyStats> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user