feat(analytics): proxy analizi — provider/servis karnesi + banlı IP takibi

/analytics/proxy: Sase proxy_logs telemetrisinden (sase.tr dev 45f7e3f)
provider × servis karnesi (A-F: başarı/ban/transport/latency ağırlıklı),
banlı exit IP / sticky session tablosu, ≥400 status dağılımı (402 = proxy
bakiye), günlük seyir. Tablo prod'a promote edilene dek bilgilendirici boş
durum gösterir.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Semih
2026-06-11 17:30:07 +03:00
parent 4454547c59
commit b9ea801d0f
2 changed files with 503 additions and 0 deletions

View File

@@ -49,6 +49,12 @@ export default async function AnalyticsPage({
>
Boş kategori bildirimleri
</a>
<a
href="/analytics/proxy"
className={buttonVariants({ variant: "outline", size: "sm" })}
>
Proxy analizi
</a>
</div>
</div>
<p className="text-sm text-muted-foreground">

View File

@@ -0,0 +1,497 @@
import Link from "next/link";
import { PanelShell } from "@/components/panel-shell";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { saseDb } from "@/lib/db-sase";
export const dynamic = "force-dynamic";
// Proxy analizi — Sase API'nin proxy_logs telemetrisinden (sase.tr 45f7e3f):
// her proxied upstream denemesi bir satır. Burada provider+servis bazında
// karne (başarı/ban/latency → A-F) çıkarılır ve banlı exit IP'ler / sticky
// session'lar izlenir. banned = upstream 403/429 (IP-blok sinyali).
type ServiceRow = {
provider: string;
service: string;
attempts: number;
ok: number;
banned: number;
transport_errors: number;
timeouts: number;
p50: number | null;
p95: number | null;
ips: number;
last_seen: Date;
};
type BannedRow = {
identity: string;
is_ip: boolean;
provider: string;
service: string;
bans: number;
total: number;
statuses: number[] | null;
first_ban: Date | null;
last_ban: Date | null;
};
type StatusRow = { provider: string; status_code: number; n: number };
type DayRow = {
day: string;
attempts: number;
ok: number;
banned: number;
transport_errors: number;
};
const SERVICE_LABELS: Record<string, string> = {
pcat_call: "PCAT API çağrısı",
pcat_capture: "PCAT JWT yakalama",
pcat_validate: "PCAT token doğrulama",
emex_http: "EMEX HTTP",
};
// Ağırlıklar: ban en pahalı sinyal (IP havuzunu kirletir), sonra transport
// hatası, sonra latency. Skor 0-100 → harf.
function gradeOf(r: ServiceRow): { letter: string; score: number; tone: string } {
const okRate = r.attempts ? r.ok / r.attempts : 0;
const banRate = r.attempts ? r.banned / r.attempts : 0;
const errRate = r.attempts ? r.transport_errors / r.attempts : 0;
let score = okRate * 100 - banRate * 300 - errRate * 80;
if ((r.p95 ?? 0) > 10_000) score -= 15;
else if ((r.p95 ?? 0) > 5_000) score -= 7;
score = Math.max(0, Math.min(100, Math.round(score)));
const letter = score >= 93 ? "A" : score >= 85 ? "B" : score >= 70 ? "C" : score >= 55 ? "D" : "F";
const tone =
letter === "A" || letter === "B"
? "text-emerald-600"
: letter === "C"
? "text-amber-600"
: "text-red-600";
return { letter, score, tone };
}
function fmtMs(v: number | null): string {
if (v == null) return "—";
return v >= 1000 ? `${(v / 1000).toFixed(1)}s` : `${Math.round(v)}ms`;
}
function fmtTs(d: Date | null): string {
if (!d) return "—";
return d.toLocaleString("tr-TR", {
timeZone: "Europe/Istanbul",
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
export default async function ProxyAnalyticsPage({
searchParams,
}: {
searchParams: Promise<{ days?: string }>;
}) {
const sp = await searchParams;
const days = [1, 7, 30].includes(Number(sp.days)) ? Number(sp.days) : 7;
const since = new Date(Date.now() - days * 864e5);
let services: ServiceRow[] = [];
let bannedRows: BannedRow[] = [];
let statusRows: StatusRow[] = [];
let dayRows: DayRow[] = [];
let tableMissing = false;
try {
services = await saseDb.$queryRaw<ServiceRow[]>`
SELECT provider, service,
count(*)::int AS attempts,
count(*) FILTER (WHERE success)::int AS ok,
count(*) FILTER (WHERE banned)::int AS banned,
count(*) FILTER (WHERE error_kind IS NOT NULL)::int AS transport_errors,
count(*) FILTER (WHERE error_kind = 'timeout')::int AS timeouts,
percentile_cont(0.5) WITHIN GROUP (ORDER BY duration_ms)::float AS p50,
percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms)::float AS p95,
count(DISTINCT exit_ip) FILTER (WHERE exit_ip IS NOT NULL)::int AS ips,
max(created_at) AS last_seen
FROM proxy_logs
WHERE created_at > ${since}
GROUP BY 1, 2
ORDER BY attempts DESC
`;
bannedRows = await saseDb.$queryRaw<BannedRow[]>`
SELECT coalesce(exit_ip, session_key) AS identity,
(exit_ip IS NOT NULL) AS is_ip,
provider, service,
count(*) FILTER (WHERE banned)::int AS bans,
count(*)::int AS total,
array_agg(DISTINCT status_code) FILTER (WHERE banned) AS statuses,
min(created_at) FILTER (WHERE banned) AS first_ban,
max(created_at) FILTER (WHERE banned) AS last_ban
FROM proxy_logs
WHERE created_at > ${since}
AND coalesce(exit_ip, session_key) IS NOT NULL
AND coalesce(exit_ip, session_key) IN (
SELECT coalesce(exit_ip, session_key)
FROM proxy_logs
WHERE banned AND created_at > ${since}
)
GROUP BY 1, 2, 3, 4
ORDER BY bans DESC, last_ban DESC
LIMIT 50
`;
statusRows = await saseDb.$queryRaw<StatusRow[]>`
SELECT provider, status_code, count(*)::int AS n
FROM proxy_logs
WHERE created_at > ${since} AND status_code >= 400
GROUP BY 1, 2
ORDER BY n DESC
LIMIT 20
`;
dayRows = await saseDb.$queryRaw<DayRow[]>`
SELECT to_char(created_at AT TIME ZONE 'Europe/Istanbul', 'DD.MM') AS day,
count(*)::int AS attempts,
count(*) FILTER (WHERE success)::int AS ok,
count(*) FILTER (WHERE banned)::int AS banned,
count(*) FILTER (WHERE error_kind IS NOT NULL)::int AS transport_errors
FROM proxy_logs
WHERE created_at > ${since}
GROUP BY date_trunc('day', created_at AT TIME ZONE 'Europe/Istanbul'), 1
ORDER BY date_trunc('day', created_at AT TIME ZONE 'Europe/Istanbul')
`;
} catch (err) {
// Tablo prod'a henüz promote edilmediyse sayfa bilgilendirir, patlamaz.
if (/proxy_logs/.test((err as Error).message)) tableMissing = true;
else throw err;
}
const totalAttempts = services.reduce((s, r) => s + r.attempts, 0);
const totalOk = services.reduce((s, r) => s + r.ok, 0);
const totalBanned = services.reduce((s, r) => s + r.banned, 0);
const bannedIdentities = bannedRows.filter((r) => r.bans > 0).length;
return (
<PanelShell title="Analytics · Proxy analizi">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Link href="/analytics" className="hover:underline">
Analytics
</Link>
</div>
<div className="flex items-baseline justify-between">
<div>
<h2 className="text-xl font-semibold">Proxy analizi</h2>
<p className="text-sm text-muted-foreground">
Sase.tr proxy trafiği provider/servis karnesi ve banlı IP takibi. Son {days} gün.
(banned = upstream 403/429)
</p>
</div>
<div className="flex gap-1">
{[1, 7, 30].map((d) => (
<Link
key={d}
href={`?days=${d}`}
className={buttonVariants({
variant: d === days ? "default" : "outline",
size: "sm",
})}
>
{d}g
</Link>
))}
</div>
</div>
{tableMissing ? (
<Card>
<CardHeader>
<CardTitle className="text-base">Veri akışı henüz başlamadı</CardTitle>
<CardDescription>
proxy_logs tablosu prod Sase DB&apos;sinde yok telemetri sase.tr dev&apos;de
(commit 45f7e3f, migration 0014). Dev main promote PR&apos;ı merge edilince
tablo otomatik migration ile ılır ve bu sayfa dolmaya başlar.
dev.sase.tr şimdiden logluyor; prod görünürlüğü promote&apos;u bekliyor.
</CardDescription>
</CardHeader>
</Card>
) : (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<Kpi label="Proxied istek" value={totalAttempts.toLocaleString("tr-TR")} />
<Kpi
label="Başarı"
value={totalAttempts ? `${((totalOk / totalAttempts) * 100).toFixed(1)}%` : "—"}
/>
<Kpi
label="Ban sinyali (403/429)"
value={String(totalBanned)}
tone={totalBanned > 0 ? "text-red-600" : undefined}
/>
<Kpi
label="Banlı IP / session"
value={String(bannedIdentities)}
tone={bannedIdentities > 0 ? "text-red-600" : undefined}
/>
</div>
<Card>
<CardHeader>
<CardDescription>
Servis karnesi provider × entegrasyon, deneme bazında
</CardDescription>
</CardHeader>
<CardContent>
{services.length === 0 ? (
<p className="text-sm text-muted-foreground">
Bu pencerede proxied istek yok.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Not</TableHead>
<TableHead>Servis</TableHead>
<TableHead>Provider</TableHead>
<TableHead className="text-right">İstek</TableHead>
<TableHead className="text-right">Başarı</TableHead>
<TableHead className="text-right">Ban</TableHead>
<TableHead className="text-right">Transport hata</TableHead>
<TableHead className="text-right">P50</TableHead>
<TableHead className="text-right">P95</TableHead>
<TableHead className="text-right">IP çeşidi</TableHead>
<TableHead>Son istek</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{services.map((r) => {
const g = gradeOf(r);
return (
<TableRow key={`${r.provider}-${r.service}`}>
<TableCell>
<span className={`text-lg font-bold tabular-nums ${g.tone}`} title={`skor ${g.score}/100`}>
{g.letter}
</span>
</TableCell>
<TableCell className="text-xs">
{SERVICE_LABELS[r.service] ?? r.service}
</TableCell>
<TableCell>
<Badge variant="outline" className="font-mono text-xs">
{r.provider}
</Badge>
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{r.attempts.toLocaleString("tr-TR")}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{((r.ok / r.attempts) * 100).toFixed(1)}%
</TableCell>
<TableCell
className={`text-right tabular-nums text-xs ${r.banned ? "font-semibold text-red-600" : ""}`}
>
{r.banned}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{r.transport_errors}
{r.timeouts > 0 && (
<span className="text-muted-foreground"> ({r.timeouts} timeout)</span>
)}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">{fmtMs(r.p50)}</TableCell>
<TableCell className="text-right tabular-nums text-xs">{fmtMs(r.p95)}</TableCell>
<TableCell className="text-right tabular-nums text-xs">{r.ips || "—"}</TableCell>
<TableCell className="whitespace-nowrap font-mono text-xs">
{fmtTs(r.last_seen)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardDescription>
Banlı IP / session takibi bu pencerede en az bir 403/429 yiyen kimlikler
</CardDescription>
</CardHeader>
<CardContent>
{bannedRows.length === 0 ? (
<p className="text-sm text-muted-foreground">
Banlı IP yok bu pencerede hiçbir exit IP/session 403/429 görmedi. 🎉
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>IP / Session</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Servis</TableHead>
<TableHead className="text-right">Ban</TableHead>
<TableHead className="text-right">Toplam istek</TableHead>
<TableHead>Status</TableHead>
<TableHead>İlk ban</TableHead>
<TableHead>Son ban</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bannedRows.map((r) => (
<TableRow key={`${r.identity}-${r.provider}-${r.service}`}>
<TableCell className="font-mono text-xs">
{r.identity}
{!r.is_ip && (
<span className="ml-1 text-muted-foreground">(session)</span>
)}
</TableCell>
<TableCell>
<Badge variant="outline" className="font-mono text-xs">
{r.provider}
</Badge>
</TableCell>
<TableCell className="text-xs">
{SERVICE_LABELS[r.service] ?? r.service}
</TableCell>
<TableCell className="text-right tabular-nums text-xs font-semibold text-red-600">
{r.bans}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">{r.total}</TableCell>
<TableCell className="font-mono text-xs">
{(r.statuses ?? []).filter(Boolean).join(", ") || "—"}
</TableCell>
<TableCell className="whitespace-nowrap font-mono text-xs">
{fmtTs(r.first_ban)}
</TableCell>
<TableCell className="whitespace-nowrap font-mono text-xs">
{fmtTs(r.last_ban)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<div className="grid gap-3 md:grid-cols-2">
<Card>
<CardHeader>
<CardDescription>
HTTP 400 dağılımı 402 = proxy bakiyesi bitti (Floxy 2026-06-10&apos;da yaşandı)
</CardDescription>
</CardHeader>
<CardContent>
{statusRows.length === 0 ? (
<p className="text-sm text-muted-foreground">4xx/5xx yanıt yok.</p>
) : (
<div className="flex flex-wrap gap-2">
{statusRows.map((r) => (
<Badge
key={`${r.provider}-${r.status_code}`}
variant="outline"
className={`font-mono text-xs ${
r.status_code === 403 || r.status_code === 429 || r.status_code === 402
? "border-red-300 text-red-600"
: ""
}`}
>
{r.provider} · {r.status_code} × {r.n}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardDescription>Günlük seyir</CardDescription>
</CardHeader>
<CardContent>
{dayRows.length === 0 ? (
<p className="text-sm text-muted-foreground">Veri yok.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Gün</TableHead>
<TableHead className="text-right">İstek</TableHead>
<TableHead className="text-right">Başarı</TableHead>
<TableHead className="text-right">Ban</TableHead>
<TableHead className="text-right">Transport hata</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dayRows.map((r) => (
<TableRow key={r.day}>
<TableCell className="font-mono text-xs">{r.day}</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{r.attempts.toLocaleString("tr-TR")}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{r.attempts ? `${((r.ok / r.attempts) * 100).toFixed(1)}%` : "—"}
</TableCell>
<TableCell
className={`text-right tabular-nums text-xs ${r.banned ? "font-semibold text-red-600" : ""}`}
>
{r.banned}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{r.transport_errors}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
<p className="text-xs text-muted-foreground">
Exit IP yalnızca sticky leg&apos;lerde bilinir (PCAT JWT yakalama aynı sticky
session&apos;dan ipify ile çözülür). Rotating leg&apos;lerde (PCAT çağrı, her istekte
yeni IP) kimlik yerine ban <em>oranı</em> izlenir; EMEX Floxy sticky session
anahtarıyla izlenir.
</p>
</>
)}
</PanelShell>
);
}
function Kpi({ label, value, tone }: { label: string; value: string; tone?: string }) {
return (
<Card>
<CardHeader>
<CardDescription>{label}</CardDescription>
<CardTitle className={`text-2xl font-semibold tabular-nums ${tone ?? ""}`}>
{value}
</CardTitle>
</CardHeader>
</Card>
);
}