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:
@@ -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">
|
||||
|
||||
497
apps/web/src/app/analytics/proxy/page.tsx
Normal file
497
apps/web/src/app/analytics/proxy/page.tsx
Normal 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'sinde yok — telemetri sase.tr dev'de
|
||||
(commit 45f7e3f, migration 0014). Dev → main promote PR'ı merge edilince
|
||||
tablo otomatik migration ile açılır ve bu sayfa dolmaya başlar.
|
||||
dev.sase.tr şimdiden logluyor; prod görünürlüğü promote'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'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'lerde bilinir (PCAT JWT yakalama — aynı sticky
|
||||
session'dan ipify ile çözülür). Rotating leg'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user