Add sessions page, live websocket, subscription URL search; fix Settings toggles
CI / validate (push) Failing after 7s
CI / images (deploy/Dockerfile.api, api) (push) Has been skipped
CI / images (deploy/Dockerfile.checker, checker) (push) Has been skipped
CI / images (deploy/Dockerfile.web, web) (push) Has been skipped

1. Settings toggles were wrapped in <label>, which forwarded a second click to
   the switch button and cancelled the toggle — unwrapped so they respond.
2. Sessions: new GET /sessions, /sessions/{id} (per-protocol/source/country
   stats), /sessions/{id}/proxies. New Sessions tab: history list + per-session
   breakdown + the proxies that were valid in each session.
3. Subscriptions: url_search text[] — match canonical_url against ANY of several
   substrings (OR ILIKE). Multi-value tag input in the form.
4. Live: /api/v1/ws/status websocket pushes checker status ~1s; a client hook
   keeps the UI live and refreshes data queries on session completion. nginx
   upgrades the connection; statusWriter now implements http.Hijacker.
5. Dashboard + Sessions source breakdown: added valid-% (passed/unique) column.

Verified via Docker + Playwright: WS 101 upgrade and 1s stream, toggle flips,
sessions detail with real data, url_search=[vless://] yields only vless configs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtR4PP2JM9KadaBkhPGAE
This commit is contained in:
omar
2026-07-02 14:40:10 +03:00
co-authored by Claude Opus 4.8
parent 5126b0f6a6
commit aee04924da
18 changed files with 456 additions and 28 deletions
+11 -1
View File
@@ -1,3 +1,9 @@
# Maps the Upgrade header to the Connection value for WebSocket proxying.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name _;
@@ -12,11 +18,15 @@ server {
# API + public subscription endpoints proxy to the Go api service.
location /api/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
# WebSocket upgrade (used by /api/v1/ws/status).
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
}
location /sub/ {
proxy_pass http://api:8080;
+2
View File
@@ -7,6 +7,7 @@ import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
import Proxies from "./pages/Proxies";
import Subscriptions from "./pages/Subscriptions";
import Sessions from "./pages/Sessions";
import Settings from "./pages/Settings";
function RequireAuth({ children }: { children: React.ReactNode }) {
@@ -45,6 +46,7 @@ export default function App() {
<Route path="/" element={<Dashboard />} />
<Route path="/proxies" element={<Proxies />} />
<Route path="/subscriptions" element={<Subscriptions />} />
<Route path="/sessions" element={<Sessions />} />
<Route path="/settings" element={<Settings />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
+8 -2
View File
@@ -1,8 +1,9 @@
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { LayoutDashboard, Radio, Rss, Settings2, Play, Square, LogOut } from "lucide-react";
import { LayoutDashboard, Radio, Rss, Settings2, History, Play, Square, LogOut } from "lucide-react";
import { api } from "@/lib/api";
import { cn } from "@/lib/format";
import { useLiveStatus } from "@/lib/useLiveStatus";
import { Button } from "./ui";
import { StatusPill } from "./telemetry";
import { useToast } from "./Toast";
@@ -11,6 +12,7 @@ const nav = [
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
{ to: "/proxies", label: "Proxies", icon: Radio, end: false },
{ to: "/subscriptions", label: "Subscriptions", icon: Rss, end: false },
{ to: "/sessions", label: "Sessions", icon: History, end: false },
{ to: "/settings", label: "Settings", icon: Settings2, end: false },
];
@@ -19,10 +21,14 @@ export function Layout() {
const toast = useToast();
const navigate = useNavigate();
// Live status over websocket (keeps ["checker-status"] fresh ~1s and refreshes
// data queries when a check finishes). Polling below is the fallback.
useLiveStatus();
const status = useQuery({
queryKey: ["checker-status"],
queryFn: api.checkerStatus,
refetchInterval: 3000,
refetchInterval: 15000,
});
const run = useMutation({
+7
View File
@@ -4,6 +4,7 @@ import type {
Facets,
Proxy,
Session,
SessionDetail,
Settings,
Source,
Subscription,
@@ -87,4 +88,10 @@ export const api = {
checkerStatus: () => request<CheckerStatus>("GET", "/api/v1/checker/status"),
checkerRun: () => request<{ status: string }>("POST", "/api/v1/checker/run"),
checkerStop: () => request<{ status: string }>("POST", "/api/v1/checker/stop"),
// sessions
sessions: () => request<{ items: Session[] }>("GET", "/api/v1/sessions"),
session: (id: number) => request<SessionDetail>("GET", `/api/v1/sessions/${id}`),
sessionProxies: (id: number, query: string) =>
request<{ items: Proxy[]; total: number }>("GET", `/api/v1/sessions/${id}/proxies?${query}`),
};
+12
View File
@@ -57,6 +57,7 @@ export interface Subscription {
filter_protocols: string[];
filter_countries: string[];
filter_sources: string[];
url_search: string[];
max_latency_ms: number | null;
min_speed_mbps: number | null;
limit_n: number | null;
@@ -133,6 +134,17 @@ export interface CheckerStatus {
current: Session | null;
}
export interface SessionDetail {
session: Session;
protocols?: ProtocolStat[];
sources?: SourceStat[];
countries?: LabelCount[];
latency_avg?: number;
latency_median?: number;
speed_avg?: number;
speed_median?: number;
}
export type Settings = Record<string, string>;
export interface Facets {
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { CheckerStatus } from "./types";
// useLiveStatus opens a websocket to the API and pushes checker status into the
// react-query cache (~1s cadence), so the whole UI reflects live progress
// without aggressive polling. When a check finishes (the current session id
// changes, or a run ends) it invalidates the data queries so the panel refreshes
// immediately. Falls back silently to the existing polling if the socket drops.
export function useLiveStatus() {
const qc = useQueryClient();
const prevCurrentId = useRef<number | null>(null);
const prevBusy = useRef<boolean>(false);
const closedByUs = useRef(false);
useEffect(() => {
closedByUs.current = false;
let ws: WebSocket | null = null;
let reconnectTimer: number | undefined;
const connect = () => {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(`${proto}//${window.location.host}/api/v1/ws/status`);
ws.onmessage = (ev) => {
let data: CheckerStatus;
try {
data = JSON.parse(ev.data);
} catch {
return;
}
qc.setQueryData(["checker-status"], data);
const curId = data.current?.id ?? null;
const finished = prevBusy.current && !data.busy;
const newSession = prevCurrentId.current !== null && curId !== prevCurrentId.current;
if (finished || newSession) {
qc.invalidateQueries({ queryKey: ["dashboard"] });
qc.invalidateQueries({ queryKey: ["proxies"] });
qc.invalidateQueries({ queryKey: ["sessions"] });
qc.invalidateQueries({ queryKey: ["facets"] });
}
prevCurrentId.current = curId;
prevBusy.current = data.busy;
};
ws.onclose = () => {
if (closedByUs.current) return;
reconnectTimer = window.setTimeout(connect, 3000);
};
ws.onerror = () => ws?.close();
};
connect();
return () => {
closedByUs.current = true;
if (reconnectTimer) window.clearTimeout(reconnectTimer);
ws?.close();
};
}, [qc]);
}
+16 -11
View File
@@ -213,26 +213,31 @@ export default function Dashboard() {
<th className="pb-2 text-right font-normal">Raw</th>
<th className="pb-2 text-right font-normal">Unique</th>
<th className="pb-2 text-right font-normal">Valid</th>
<th className="pb-2 text-right font-normal">Valid %</th>
</tr>
</thead>
<tbody className="font-mono text-xs">
{(d.sources ?? []).length === 0 && (
<tr>
<td colSpan={4} className="py-6 text-center text-fog-faint">
<td colSpan={5} className="py-6 text-center text-fog-faint">
—
</td>
</tr>
)}
{(d.sources ?? []).map((sc) => (
<tr key={sc.source} className="border-b border-ink-800/70">
<td className="max-w-[180px] truncate py-2 pr-2 text-fog" title={sc.source}>
{sc.source}
</td>
<td className="py-2 text-right text-fog-muted">{fmtInt(sc.raw_lines)}</td>
<td className="py-2 text-right text-fog-muted">{fmtInt(sc.unique_candidates)}</td>
<td className="py-2 text-right text-signal">{fmtInt(sc.passed)}</td>
</tr>
))}
{(d.sources ?? []).map((sc) => {
const pct = sc.unique_candidates > 0 ? (sc.passed / sc.unique_candidates) * 100 : 0;
return (
<tr key={sc.source} className="border-b border-ink-800/70">
<td className="max-w-[180px] truncate py-2 pr-2 text-fog" title={sc.source}>
{sc.source}
</td>
<td className="py-2 text-right text-fog-muted">{fmtInt(sc.raw_lines)}</td>
<td className="py-2 text-right text-fog-muted">{fmtInt(sc.unique_candidates)}</td>
<td className="py-2 text-right text-signal">{fmtInt(sc.passed)}</td>
<td className="py-2 text-right text-peri">{fmtPct(pct, 1)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
+234
View File
@@ -0,0 +1,234 @@
import * as React from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { Badge, Card, CardTitle, EmptyState, Spinner } from "@/components/ui";
import { PROTO_COLORS, cn, countryFlag, fmtAgo, fmtDuration, fmtInt, fmtMs, fmtPct, fmtSpeed } from "@/lib/format";
import type { Session } from "@/lib/types";
const statusColor: Record<string, "signal" | "rose" | "amber" | "muted"> = {
completed: "signal",
running: "amber",
cancelled: "muted",
failed: "rose",
};
export default function Sessions() {
const list = useQuery({ queryKey: ["sessions"], queryFn: api.sessions, refetchInterval: 10000 });
const [selected, setSelected] = React.useState<number | null>(null);
const items = list.data?.items ?? [];
React.useEffect(() => {
if (selected === null && items.length > 0) setSelected(items[0].id);
}, [items, selected]);
return (
<div className="space-y-5">
<div>
<div className="eyebrow mb-1">Check history</div>
<h1 className="font-display text-2xl font-bold tracking-tight">Sessions</h1>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[380px_1fr]">
{/* Session list */}
<Card className="p-0">
{list.isLoading ? (
<Spinner />
) : items.length === 0 ? (
<EmptyState title="No sessions yet" hint="Run a check to create one." />
) : (
<div className="max-h-[70vh] divide-y divide-ink-800/70 overflow-y-auto">
{items.map((s) => (
<SessionRow key={s.id} s={s} active={s.id === selected} onClick={() => setSelected(s.id)} />
))}
</div>
)}
</Card>
{/* Detail */}
{selected !== null ? <SessionDetailView id={selected} /> : <div />}
</div>
</div>
);
}
function SessionRow({ s, active, onClick }: { s: Session; active: boolean; onClick: () => void }) {
const total = s.valid + s.invalid;
const pct = total > 0 ? (s.valid / total) * 100 : 0;
return (
<button
onClick={onClick}
className={cn(
"flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors hover:bg-ink-800/50",
active && "bg-signal/5",
)}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-fog">#{s.id}</span>
<Badge color={statusColor[s.status] ?? "muted"}>{s.status}</Badge>
{s.is_current && <Badge color="signal">current</Badge>}
</div>
<div className="mt-0.5 font-mono text-[11px] text-fog-faint">
{s.trigger_kind} · {fmtAgo(s.started_at)}
</div>
</div>
<div className="shrink-0 text-right">
<div className="num text-sm">
<span className="text-signal">{fmtInt(s.valid)}</span>
<span className="text-fog-faint"> / {fmtInt(s.valid + s.invalid)}</span>
</div>
<div className="num text-[11px] text-fog-faint">{fmtPct(pct, 0)} valid</div>
</div>
</button>
);
}
function SessionDetailView({ id }: { id: number }) {
const q = useQuery({ queryKey: ["session", id], queryFn: () => api.session(id) });
const px = useQuery({
queryKey: ["session-proxies", id],
queryFn: () => api.sessionProxies(id, "limit=100"),
});
if (q.isLoading) return <Card><Spinner /></Card>;
const d = q.data;
if (!d) return <Card><EmptyState title="Not found" /></Card>;
const s = d.session;
const total = s.valid + s.invalid;
return (
<div className="space-y-6">
<Card>
<CardTitle
eyebrow={`${s.trigger_kind} · ${s.status}`}
title={`Session #${s.id}`}
right={s.is_current ? <Badge color="signal">current</Badge> : undefined}
/>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Metric label="Valid" value={fmtInt(s.valid)} accent="text-signal" />
<Metric label="Invalid" value={fmtInt(s.invalid)} accent="text-rose" />
<Metric label="Checked" value={fmtInt(total)} />
<Metric label="Duration" value={fmtDuration(s.duration_ms)} />
<Metric label="Raw lines" value={fmtInt(s.total_raw_lines)} />
<Metric label="Unique" value={fmtInt(s.unique_candidates)} />
<Metric label="Deduped" value={fmtInt(s.collapsed)} />
<Metric label="Avg latency" value={fmtMs(Math.round(d.latency_avg ?? 0))} />
</div>
{s.error && <p className="mt-3 font-mono text-xs text-rose">error: {s.error}</p>}
</Card>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<Card>
<CardTitle eyebrow="By protocol" title="Valid share" />
<div className="space-y-3">
{(d.protocols ?? []).length === 0 && <EmptyState title="—" />}
{(d.protocols ?? []).map((p) => {
const pct = p.checked > 0 ? (p.passed / p.checked) * 100 : 0;
return (
<div key={p.protocol}>
<div className="mb-1 flex items-center justify-between font-mono text-xs">
<span className="uppercase" style={{ color: PROTO_COLORS[p.protocol] ?? "#8A93A6" }}>
{p.protocol}
</span>
<span className="text-fog-muted">
{fmtInt(p.passed)}/{fmtInt(p.checked)} · {fmtPct(pct, 0)}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-ink-700">
<div
className="h-full rounded-full"
style={{ width: `${pct}%`, background: PROTO_COLORS[p.protocol] ?? "#8A93A6" }}
/>
</div>
</div>
);
})}
</div>
</Card>
<Card>
<CardTitle eyebrow="By source" title="Yield" />
<table className="w-full text-left text-sm">
<thead>
<tr className="eyebrow border-b border-ink-700 text-fog-faint">
<th className="pb-2 font-normal">Source</th>
<th className="pb-2 text-right font-normal">Unique</th>
<th className="pb-2 text-right font-normal">Valid</th>
<th className="pb-2 text-right font-normal">Valid %</th>
</tr>
</thead>
<tbody className="font-mono text-xs">
{(d.sources ?? []).length === 0 && (
<tr>
<td colSpan={4} className="py-6 text-center text-fog-faint">—</td>
</tr>
)}
{(d.sources ?? []).map((sc) => {
const pct = sc.unique_candidates > 0 ? (sc.passed / sc.unique_candidates) * 100 : 0;
return (
<tr key={sc.source} className="border-b border-ink-800/70">
<td className="max-w-[160px] truncate py-2 pr-2 text-fog" title={sc.source}>{sc.source}</td>
<td className="py-2 text-right text-fog-muted">{fmtInt(sc.unique_candidates)}</td>
<td className="py-2 text-right text-signal">{fmtInt(sc.passed)}</td>
<td className="py-2 text-right text-peri">{fmtPct(pct, 1)}</td>
</tr>
);
})}
</tbody>
</table>
</Card>
</div>
<Card>
<CardTitle
eyebrow="What was valid"
title="Proxies in this session"
right={<span className="num text-xs text-fog-faint">{fmtInt(px.data?.total ?? 0)} total</span>}
/>
{px.isLoading ? (
<Spinner />
) : (px.data?.items ?? []).length === 0 ? (
<EmptyState title="No proxies recorded" />
) : (
<div className="max-h-[50vh] overflow-auto">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 bg-ink-850">
<tr className="eyebrow border-b border-ink-700 text-fog-faint">
<th className="py-2 font-normal">Proxy</th>
<th className="py-2 font-normal">Proto</th>
<th className="py-2 font-normal">Geo</th>
<th className="py-2 text-right font-normal">Latency</th>
<th className="py-2 text-right font-normal">Speed</th>
</tr>
</thead>
<tbody className="font-mono text-xs">
{(px.data?.items ?? []).map((p) => (
<tr key={p.proxy_id} className="border-b border-ink-800/60">
<td className="max-w-[260px] truncate py-2 pr-2 text-fog" title={p.url}>
{p.host}:{p.port}
</td>
<td className="py-2 uppercase" style={{ color: PROTO_COLORS[p.protocol] ?? "#8A93A6" }}>
{p.protocol}
</td>
<td className="py-2 text-fog-muted">{countryFlag(p.country)} {p.country || "—"}</td>
<td className="py-2 text-right text-fog-muted">{p.latency_ms}ms</td>
<td className="py-2 text-right text-signal">{fmtSpeed(p.speed_mbps)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
</div>
);
}
function Metric({ label, value, accent = "text-fog" }: { label: string; value: React.ReactNode; accent?: string }) {
return (
<div className="rounded-lg border border-ink-700 bg-ink-900/60 p-3">
<div className="eyebrow mb-1">{label}</div>
<div className={cn("num text-lg font-semibold", accent)}>{value}</div>
</div>
);
}
+6 -6
View File
@@ -265,14 +265,14 @@ function CheckParamsPanel() {
<Input value={form[KEYS.tgChat] ?? ""} onChange={(e) => set(KEYS.tgChat, e.target.value)} placeholder="-1001234567890" />
</Field>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm text-fog-muted">
<Toggle checked={bool(KEYS.tgStart)} onChange={(v) => set(KEYS.tgStart, String(v))} label="Notify start" />
<div className="flex items-center gap-2 text-sm text-fog-muted">
<Toggle checked={bool(KEYS.tgStart)} onChange={(v) => set(KEYS.tgStart, String(v))} label="Notify on check start" />
on check start
</label>
<label className="flex items-center gap-2 text-sm text-fog-muted">
<Toggle checked={bool(KEYS.tgFinish)} onChange={(v) => set(KEYS.tgFinish, String(v))} label="Notify finish" />
</div>
<div className="flex items-center gap-2 text-sm text-fog-muted">
<Toggle checked={bool(KEYS.tgFinish)} onChange={(v) => set(KEYS.tgFinish, String(v))} label="Notify on check finish" />
on check finish
</label>
</div>
</div>
<p className="font-mono text-[11px] leading-relaxed text-fog-faint">
Exit-IP echo endpoints are hardcoded with fallbacks. The finish message includes the session summary.
+48
View File
@@ -96,6 +96,7 @@ function SubCard({
if (sub.filter_protocols.length) filters.push(sub.filter_protocols.join("/"));
if (sub.filter_countries.length) filters.push(sub.filter_countries.join(","));
if (sub.filter_sources.length) filters.push(`${sub.filter_sources.length} src`);
if (sub.url_search?.length) filters.push(`url~${sub.url_search.join("|")}`);
if (sub.max_latency_ms) filters.push(`≤${sub.max_latency_ms}ms`);
if (sub.min_speed_mbps) filters.push(`≥${sub.min_speed_mbps}Mbps`);
if (sub.limit_n) filters.push(`top ${sub.limit_n}`);
@@ -173,6 +174,8 @@ function SubscriptionForm({
const [protocols, setProtocols] = React.useState<string[]>([]);
const [countries, setCountries] = React.useState("");
const [sources, setSources] = React.useState<string[]>([]);
const [urlSearch, setUrlSearch] = React.useState<string[]>([]);
const [urlInput, setUrlInput] = React.useState("");
const [maxLatency, setMaxLatency] = React.useState("");
const [minSpeed, setMinSpeed] = React.useState("");
const [limitN, setLimitN] = React.useState("");
@@ -190,6 +193,8 @@ function SubscriptionForm({
setProtocols(editing.filter_protocols);
setCountries(editing.filter_countries.join(", "));
setSources(editing.filter_sources);
setUrlSearch(editing.url_search ?? []);
setUrlInput("");
setMaxLatency(editing.max_latency_ms ? String(editing.max_latency_ms) : "");
setMinSpeed(editing.min_speed_mbps ? String(editing.min_speed_mbps) : "");
setLimitN(editing.limit_n ? String(editing.limit_n) : "");
@@ -204,6 +209,8 @@ function SubscriptionForm({
setProtocols([]);
setCountries("");
setSources([]);
setUrlSearch([]);
setUrlInput("");
setMaxLatency("");
setMinSpeed("");
setLimitN("");
@@ -237,6 +244,7 @@ function SubscriptionForm({
.map((c) => c.trim().toUpperCase())
.filter(Boolean),
filter_sources: sources,
url_search: urlSearch,
max_latency_ms: maxLatency ? Number(maxLatency) : null,
min_speed_mbps: minSpeed ? Number(minSpeed) : null,
limit_n: limitN ? Number(limitN) : null,
@@ -302,6 +310,46 @@ function SubscriptionForm({
</div>
</div>
<div>
<Label>Search in proxy_url (matches any)</Label>
<div className="rounded-lg border border-ink-700 bg-ink-900 p-2">
<div className="mb-2 flex flex-wrap gap-1.5">
{urlSearch.length === 0 && (
<span className="px-1 font-mono text-[11px] text-fog-faint">no url filters</span>
)}
{urlSearch.map((t) => (
<span
key={t}
className="inline-flex items-center gap-1 rounded-md border border-peri/30 bg-peri/10 px-2 py-0.5 font-mono text-[11px] text-peri"
>
{t}
<button
type="button"
onClick={() => setUrlSearch(urlSearch.filter((x) => x !== t))}
aria-label={`Remove ${t}`}
className="text-peri/70 hover:text-peri"
>
✕
</button>
</span>
))}
</div>
<Input
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === ",") && urlInput.trim()) {
e.preventDefault();
const v = urlInput.trim();
if (!urlSearch.includes(v)) setUrlSearch([...urlSearch, v]);
setUrlInput("");
}
}}
placeholder="e.g. reality, :443, sni= — press Enter to add"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<Label>Max latency (ms)</Label>
+1
View File
@@ -41,6 +41,7 @@ func (s *Server) handleSub(w http.ResponseWriter, r *http.Request) {
Protocols: sub.FilterProtocols,
Countries: sub.FilterCountries,
Sources: sub.FilterSources,
URLSearches: sub.URLSearch,
MaxLatencyMs: sub.MaxLatencyMs,
MinSpeedMbps: sub.MinSpeedMbps,
}
+2
View File
@@ -28,6 +28,7 @@ type subReq struct {
FilterProtocols []string `json:"filter_protocols"`
FilterCountries []string `json:"filter_countries"`
FilterSources []string `json:"filter_sources"`
URLSearch []string `json:"url_search"`
MaxLatencyMs *int `json:"max_latency_ms"`
MinSpeedMbps *float64 `json:"min_speed_mbps"`
LimitN *int `json:"limit_n"`
@@ -51,6 +52,7 @@ func (r subReq) apply(s *db.Subscription) {
s.FilterProtocols = nonNil(r.FilterProtocols)
s.FilterCountries = nonNil(r.FilterCountries)
s.FilterSources = nonNil(r.FilterSources)
s.URLSearch = nonNil(r.URLSearch)
s.MaxLatencyMs = r.MaxLatencyMs
s.MinSpeedMbps = r.MinSpeedMbps
s.LimitN = r.LimitN
+18
View File
@@ -3,7 +3,10 @@
package api
import (
"bufio"
"errors"
"log/slog"
"net"
"net/http"
"strings"
"time"
@@ -72,6 +75,13 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /api/v1/checker/run", s.auth(s.handleCheckerRun))
mux.Handle("POST /api/v1/checker/stop", s.auth(s.handleCheckerStop))
mux.Handle("GET /api/v1/sessions", s.auth(s.handleListSessions))
mux.Handle("GET /api/v1/sessions/{id}", s.auth(s.handleGetSession))
mux.Handle("GET /api/v1/sessions/{id}/proxies", s.auth(s.handleSessionProxies))
// Live status feed (websocket).
mux.Handle("GET /api/v1/ws/status", s.auth(s.handleWSStatus))
return s.cors(logRequests(s.log, mux))
}
@@ -115,3 +125,11 @@ func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
// Hijack lets the wrapped writer support WebSocket upgrades (the /ws endpoint).
func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if h, ok := w.ResponseWriter.(http.Hijacker); ok {
return h.Hijack()
}
return nil, nil, errors.New("ResponseWriter does not support hijacking")
}
+3
View File
@@ -64,6 +64,7 @@ type Subscription struct {
FilterProtocols []string `json:"filter_protocols"`
FilterCountries []string `json:"filter_countries"`
FilterSources []string `json:"filter_sources"`
URLSearch []string `json:"url_search"`
MaxLatencyMs *int `json:"max_latency_ms"`
MinSpeedMbps *float64 `json:"min_speed_mbps"`
LimitN *int `json:"limit_n"`
@@ -85,6 +86,8 @@ type ProxyFilter struct {
MaxLatencyMs *int
MinSpeedMbps *float64
Search string
// URLSearches matches canonical_url against any of these substrings (OR).
URLSearches []string
// UniqueIPs and Metric are applied in-memory after the DB scan.
Limit int
Offset int
+4
View File
@@ -142,6 +142,7 @@ CREATE TABLE IF NOT EXISTS subscriptions (
filter_protocols text[] NOT NULL DEFAULT '{}',
filter_countries text[] NOT NULL DEFAULT '{}',
filter_sources text[] NOT NULL DEFAULT '{}',
url_search text[] NOT NULL DEFAULT '{}', -- proxy_url substrings (match ANY)
max_latency_ms integer,
min_speed_mbps double precision,
limit_n integer,
@@ -154,6 +155,9 @@ CREATE TABLE IF NOT EXISTS subscriptions (
created_at timestamptz NOT NULL DEFAULT now()
);
-- Idempotent upgrade for databases created before url_search existed.
ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS url_search text[] NOT NULL DEFAULT '{}';
-- ---------------------------------------------------------------------------
-- Default settings (only inserted when missing).
-- ---------------------------------------------------------------------------
+13
View File
@@ -186,6 +186,11 @@ func (d *DB) GetCurrentSession(ctx context.Context) (*Session, error) {
`SELECT `+sessionCols+` FROM check_sessions WHERE is_current LIMIT 1`)
}
// GetSessionByID returns a specific session, or nil if not found.
func (d *DB) GetSessionByID(ctx context.Context, id int64) (*Session, error) {
return d.scanSession(ctx, `SELECT `+sessionCols+` FROM check_sessions WHERE id = $1`, id)
}
const sessionCols = `id, status, trigger_kind, is_current, cancel_requested, started_at,
finished_at, duration_ms, total_raw_lines, unique_candidates, collapsed,
progress_total, progress_done, valid, invalid, error`
@@ -272,6 +277,14 @@ func (d *DB) ListSessionProxies(ctx context.Context, sessionID int64, f ProxyFil
args = append(args, "%"+f.Search+"%")
b.WriteString(searchClause(len(args)))
}
if len(f.URLSearches) > 0 {
var ors []string
for _, term := range f.URLSearches {
args = append(args, "%"+term+"%")
ors = append(ors, fmt.Sprintf("sp.canonical_url ILIKE $%d", len(args)))
}
b.WriteString(" AND (" + strings.Join(ors, " OR ") + ")")
}
b.WriteString(` ORDER BY sp.speed_mbps DESC, sp.latency_ms ASC`)
if f.Limit > 0 {
args = append(args, f.Limit)
+8 -8
View File
@@ -8,13 +8,13 @@ import (
)
const subCols = `id, token, name, enabled, format, filter_protocols, filter_countries,
filter_sources, max_latency_ms, min_speed_mbps, limit_n, unique_ips, unique_ips_metric,
filter_sources, url_search, max_latency_ms, min_speed_mbps, limit_n, unique_ips, unique_ips_metric,
sort_by, expires_at, request_count, last_requested_at, created_at`
func scanSub(row pgx.Row) (*Subscription, error) {
var s Subscription
err := row.Scan(&s.ID, &s.Token, &s.Name, &s.Enabled, &s.Format,
&s.FilterProtocols, &s.FilterCountries, &s.FilterSources,
&s.FilterProtocols, &s.FilterCountries, &s.FilterSources, &s.URLSearch,
&s.MaxLatencyMs, &s.MinSpeedMbps, &s.LimitN, &s.UniqueIPs, &s.UniqueIPsMetric,
&s.SortBy, &s.ExpiresAt, &s.RequestCount, &s.LastRequestedAt, &s.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
@@ -54,11 +54,11 @@ func (d *DB) CreateSubscription(ctx context.Context, s *Subscription) (*Subscrip
row := d.pool.QueryRow(ctx,
`INSERT INTO subscriptions
(token, name, enabled, format, filter_protocols, filter_countries, filter_sources,
max_latency_ms, min_speed_mbps, limit_n, unique_ips, unique_ips_metric, sort_by, expires_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
url_search, max_latency_ms, min_speed_mbps, limit_n, unique_ips, unique_ips_metric, sort_by, expires_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
RETURNING `+subCols,
s.Token, s.Name, s.Enabled, s.Format, s.FilterProtocols, s.FilterCountries, s.FilterSources,
s.MaxLatencyMs, s.MinSpeedMbps, s.LimitN, s.UniqueIPs, s.UniqueIPsMetric, s.SortBy, s.ExpiresAt)
s.URLSearch, s.MaxLatencyMs, s.MinSpeedMbps, s.LimitN, s.UniqueIPs, s.UniqueIPsMetric, s.SortBy, s.ExpiresAt)
return scanSub(row)
}
@@ -67,11 +67,11 @@ func (d *DB) UpdateSubscription(ctx context.Context, s *Subscription) (*Subscrip
row := d.pool.QueryRow(ctx,
`UPDATE subscriptions SET
name=$2, enabled=$3, format=$4, filter_protocols=$5, filter_countries=$6, filter_sources=$7,
max_latency_ms=$8, min_speed_mbps=$9, limit_n=$10, unique_ips=$11, unique_ips_metric=$12,
sort_by=$13, expires_at=$14
url_search=$8, max_latency_ms=$9, min_speed_mbps=$10, limit_n=$11, unique_ips=$12, unique_ips_metric=$13,
sort_by=$14, expires_at=$15
WHERE id=$1 RETURNING `+subCols,
s.ID, s.Name, s.Enabled, s.Format, s.FilterProtocols, s.FilterCountries, s.FilterSources,
s.MaxLatencyMs, s.MinSpeedMbps, s.LimitN, s.UniqueIPs, s.UniqueIPsMetric, s.SortBy, s.ExpiresAt)
s.URLSearch, s.MaxLatencyMs, s.MinSpeedMbps, s.LimitN, s.UniqueIPs, s.UniqueIPsMetric, s.SortBy, s.ExpiresAt)
return scanSub(row)
}
+2
View File
@@ -141,6 +141,7 @@ CREATE TABLE IF NOT EXISTS subscriptions (
filter_protocols text[] NOT NULL DEFAULT '{}',
filter_countries text[] NOT NULL DEFAULT '{}',
filter_sources text[] NOT NULL DEFAULT '{}',
url_search text[] NOT NULL DEFAULT '{}', -- proxy_url substrings (match ANY)
max_latency_ms integer,
min_speed_mbps double precision,
limit_n integer,
@@ -152,6 +153,7 @@ CREATE TABLE IF NOT EXISTS subscriptions (
last_requested_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS url_search text[] NOT NULL DEFAULT '{}';
-- ---------------------------------------------------------------------------
-- Default settings (only inserted when missing).