Add sessions page, live websocket, subscription URL search; fix Settings toggles
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:
+11
-1
@@ -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;
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}`),
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user