Files
sp/apps/web/src/app/projects/sase/vin-decode/trends/page.tsx
Semih 985592aa73 feat(vin-decode): WMI opportunity radar, per-source p50, unknown-VIN spike alarm
Systematize Faz 4 + close three observability gaps:

- WMI opportunity radar (getWmiOpportunities): group query_logs by
  substring(vin,1,3) = WMI, rank low-success WMIs by distinct-user demand.
  brandMatched=0 flags fully-uncovered manufacturer codes. Surfaced on the
  Trends page (#wmi) + a dashboard header shortcut. Replaces the manual Faz 4.
- Per-source p50: add percentile_cont(0.50) to getProviderAttempts and
  getProviderDeepStats; show P50·P95 on the dashboard and provider drill-down.
- unknown_vin_spike anomaly: track "tanınamadı" rate in the 15min/baseline
  windows, fire when it jumps >=2x baseline (or surges from ~0), and attribute
  the dominant failing source (+aborted count) in the Telegram message. Runs
  in the existing 5min anomaly cron; worker/telegram unchanged (generic type).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 01:58:26 +03:00

488 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Link from "next/link";
import { PanelShell } from "@/components/panel-shell";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
getLongTermVolume,
getWeeklyGrowth,
getProviderShareTrend,
getUnderSupportedBrands,
getWmiOpportunities,
getPeakHeatmap,
getCacheHitTrend,
getEfficiencyTrend,
} from "@/lib/sase/vin-trends";
export const dynamic = "force-dynamic";
const DAYS_OF_WEEK = ["Pzt", "Sal", "Çar", "Per", "Cum", "Cmt", "Paz"];
const PROVIDER_COLORS: Record<string, string> = {
cache: "bg-slate-400",
"parts-catalogs": "bg-blue-500",
pcat: "bg-blue-500",
pl24: "bg-emerald-500",
emex: "bg-amber-500",
"vin-api": "bg-purple-500",
corgi: "bg-pink-500",
none: "bg-muted",
aborted: "bg-destructive",
unknown: "bg-zinc-400",
};
export default async function TrendsPage() {
const [volume, growth, shareTrend, underSupported, wmiOpps, heatmap, cache, efficiency] =
await Promise.all([
getLongTermVolume(90),
getWeeklyGrowth(12),
getProviderShareTrend(30),
getUnderSupportedBrands(30, 20, 0.75),
getWmiOpportunities(30, 15, 0.7),
getPeakHeatmap(30),
getCacheHitTrend(30),
getEfficiencyTrend(30),
]);
const volMax = Math.max(1, ...volume.map((v) => v.total));
const cacheMax = Math.max(1, ...cache.map((c) => c.total));
return (
<PanelShell title="Sase · VIN Trends">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Link href="/projects/sase/vin-decode" className="hover:underline">
VIN Decode dashboard
</Link>
</div>
<div>
<h2 className="text-xl font-semibold">Trends & Kapasite</h2>
<p className="text-sm text-muted-foreground">
Uzun dönem volume, provider mix, peak saatler, cache trend, verimlilik
skoru.
</p>
</div>
{/* 1. Long-term volume */}
<Card>
<CardHeader>
<CardDescription>1. Son 90 gün günlük decode hacmi</CardDescription>
</CardHeader>
<CardContent>
<div className="flex h-24 items-end gap-px">
{volume.map((v) => (
<div
key={v.date}
title={`${v.date}: ${v.total} (${v.succeeded} ok / ${v.failed} fail)`}
style={{ height: `${(v.total / volMax) * 100}%` }}
className="relative flex-1 min-w-[2px] overflow-hidden rounded-sm bg-muted"
>
{v.succeeded > 0 && (
<div
style={{ height: `${(v.succeeded / v.total) * 100}%` }}
className="absolute bottom-0 left-0 right-0 bg-primary/70"
/>
)}
</div>
))}
</div>
<div className="flex justify-between pt-1 text-xs text-muted-foreground">
<span>{volume[0]?.date}</span>
<span>maks {volMax}/gün</span>
<span>{volume[volume.length - 1]?.date}</span>
</div>
</CardContent>
</Card>
{/* 2. Weekly growth */}
<Card>
<CardHeader>
<CardDescription>2. Haftalık büyüme (son 12 hafta)</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Hafta başı</TableHead>
<TableHead className="text-right">Sorgu</TableHead>
<TableHead className="text-right">WoW büyüme</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{growth.map((g) => (
<TableRow key={g.weekStart}>
<TableCell className="font-mono text-xs">{g.weekStart}</TableCell>
<TableCell className="text-right tabular-nums">
{g.volume.toLocaleString("tr-TR")}
</TableCell>
<TableCell
className={`text-right tabular-nums ${
g.growthPct == null
? "text-muted-foreground"
: g.growthPct > 0
? "text-emerald-600"
: "text-destructive"
}`}
>
{g.growthPct == null
? "—"
: `${g.growthPct > 0 ? "+" : ""}${(g.growthPct * 100).toFixed(1)}%`}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 3. Provider share trend */}
<Card>
<CardHeader>
<CardDescription>3. Provider yük dağılımı (son 30g)</CardDescription>
</CardHeader>
<CardContent>
{shareTrend.length === 0 ? (
<p className="text-sm text-muted-foreground">Veri yok.</p>
) : (
<>
<div className="flex h-24 items-end gap-px">
{shareTrend.map((d) => {
const providers = Object.entries(d.byProvider).sort(
([a], [b]) => a.localeCompare(b),
);
return (
<div
key={d.date}
title={`${d.date}\n${providers
.map(([p, c]) => ` ${p}: ${c}`)
.join("\n")}`}
className="flex flex-1 min-w-[3px] flex-col-reverse overflow-hidden rounded-sm bg-muted"
style={{ height: "100%" }}
>
{providers.map(([p, c]) => (
<div
key={p}
style={{ height: `${(c / d.total) * 100}%` }}
className={PROVIDER_COLORS[p] ?? "bg-zinc-500"}
/>
))}
</div>
);
})}
</div>
<div className="mt-2 flex flex-wrap gap-3 text-xs">
{Array.from(
new Set(shareTrend.flatMap((d) => Object.keys(d.byProvider))),
)
.sort()
.map((p) => (
<div key={p} className="flex items-center gap-1">
<div
className={`size-3 rounded ${PROVIDER_COLORS[p] ?? "bg-zinc-500"}`}
/>
<span className="font-mono">{p}</span>
</div>
))}
</div>
</>
)}
</CardContent>
</Card>
{/* 4. Under-supported brands */}
<Card>
<CardHeader>
<CardDescription>
4. Düşük destekli markalar (son 30g, hacim 20, başarı 75%)
</CardDescription>
</CardHeader>
<CardContent>
{underSupported.length === 0 ? (
<p className="text-sm text-muted-foreground">
Düşük destekli marka yok tüm markalarda başarı yüksek.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Marka</TableHead>
<TableHead className="text-right">Toplam</TableHead>
<TableHead className="text-right">Fail</TableHead>
<TableHead className="text-right">Kullanıcı</TableHead>
<TableHead className="text-right">Başarı</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{underSupported.map((b) => (
<TableRow key={b.brandSlug ?? "?"}>
<TableCell>
<span className="font-mono text-xs">{b.brandSlug ?? "?"}</span>
<span className="ml-2 text-xs text-muted-foreground">
{b.brandName}
</span>
</TableCell>
<TableCell className="text-right tabular-nums">
{b.total.toLocaleString("tr-TR")}
</TableCell>
<TableCell className="text-right tabular-nums text-destructive">
{b.failed.toLocaleString("tr-TR")}
</TableCell>
<TableCell className="text-right tabular-nums">{b.uniqueUsers}</TableCell>
<TableCell className="text-right tabular-nums text-destructive">
{(b.successRate * 100).toFixed(1)}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<p className="mt-2 text-xs text-muted-foreground">
Yeni provider / decoder yatırım sinyali. Üst sıradakilere ek kapasite
(PCAT/PL24 hesabı, ML decoder fine-tune, vb.).
</p>
</CardContent>
</Card>
{/* 4b. WMI opportunity radar — data-driven Faz 4 */}
<Card id="wmi">
<CardHeader>
<CardDescription>
4b. WMI fırsat radarı (son 30g, hacim 15, başarı 70%) sıradaki
ekleme adayları, talebe göre sıralı
</CardDescription>
</CardHeader>
<CardContent>
{wmiOpps.length === 0 ? (
<p className="text-sm text-muted-foreground">
Düşük başarılı WMI yok talep edilen üretici kodları yeterince
karşılanıyor.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>WMI</TableHead>
<TableHead>Durum / marka</TableHead>
<TableHead className="text-right">Talep (kullanıcı)</TableHead>
<TableHead className="text-right">Toplam</TableHead>
<TableHead className="text-right">Fail</TableHead>
<TableHead className="text-right">Başarı</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{wmiOpps.map((w) => (
<TableRow key={w.wmi}>
<TableCell className="font-mono text-xs">{w.wmi}</TableCell>
<TableCell className="text-xs">
{w.brandMatched === 0 ? (
<Badge variant="destructive">eşleşmesiz WMI</Badge>
) : (
<>
<span className="font-mono">{w.topBrandSlug ?? "?"}</span>
{w.topBrandName && (
<span className="ml-1 text-muted-foreground">
{w.topBrandName}
</span>
)}
<span className="ml-1 text-muted-foreground">· decode zayıf</span>
</>
)}
{w.topResultKind && (
<span className="ml-1 text-muted-foreground">
({w.topResultKind})
</span>
)}
</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{w.uniqueUsers}
</TableCell>
<TableCell className="text-right tabular-nums">
{w.total.toLocaleString("tr-TR")}
</TableCell>
<TableCell className="text-right tabular-nums text-destructive">
{w.failed.toLocaleString("tr-TR")}
</TableCell>
<TableCell className="text-right tabular-nums text-destructive">
{(w.successRate * 100).toFixed(1)}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<p className="mt-2 text-xs text-muted-foreground">
&quot;eşleşmesiz WMI&quot; = hiç markaya bağlanamayan üretici kodu
eklenince tamamen yeni kapsama. Talep (farklı kullanıcı) sütununa
göre sıralı: en çok kişinin isteyip alamadığı WMI en üstte. Faz 4&apos;ün
el yordamı yerine veri-güdümlü hali.
</p>
</CardContent>
</Card>
{/* 5. Peak heatmap */}
<Card>
<CardHeader>
<CardDescription>
5. Peak saat heatmap (son 30g, saat × gün)
</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="text-xs">
<thead>
<tr>
<th className="px-1 text-left">·</th>
{Array.from({ length: 24 }, (_, h) => (
<th key={h} className="px-1 text-center tabular-nums text-muted-foreground">
{h.toString().padStart(2, "0")}
</th>
))}
</tr>
</thead>
<tbody>
{DAYS_OF_WEEK.map((dayLabel, day) => (
<tr key={day}>
<td className="px-1 font-mono text-muted-foreground">{dayLabel}</td>
{Array.from({ length: 24 }, (_, hour) => {
const cell = heatmap.cells.find(
(c) => c.dayOfWeek === day && c.hour === hour,
);
const count = cell?.count ?? 0;
const intensity = heatmap.max > 0 ? count / heatmap.max : 0;
return (
<td
key={hour}
className="size-5 border border-background"
style={{
backgroundColor: intensity > 0
? `rgba(59, 130, 246, ${0.1 + intensity * 0.8})`
: "transparent",
}}
title={`${dayLabel} ${hour}:00 → ${count}`}
/>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Maks {heatmap.max}/saat. B2B çalışma saatlerinde yoğunluk infra
ölçekleme planlaması.
</p>
</CardContent>
</Card>
{/* 6. Cache hit trend */}
<Card>
<CardHeader>
<CardDescription>6. Cache hit trendi (son 30g)</CardDescription>
</CardHeader>
<CardContent>
<div className="flex h-20 items-end gap-px">
{cache.map((c) => (
<div
key={c.date}
title={`${c.date}: ${(c.cacheHitRate * 100).toFixed(1)}% (${c.cacheHits}/${c.total})`}
style={{
height: c.total > 0 ? `${c.cacheHitRate * 100}%` : "1px",
opacity: c.total > 0 ? 1 : 0.2,
}}
className="flex-1 min-w-[3px] rounded-sm bg-emerald-500"
/>
))}
</div>
<div className="flex justify-between pt-1 text-xs text-muted-foreground">
<span>{cache[0]?.date}</span>
<span>30g ortalaması {pct(avgCacheRate(cache))}</span>
<span>{cache[cache.length - 1]?.date}</span>
</div>
</CardContent>
</Card>
{/* 7. Efficiency composite */}
<Card>
<CardHeader>
<CardDescription>
7. Verimlilik composite skoru (success 50% · cache 20% · latency 20% · 1-fallback 10%)
</CardDescription>
</CardHeader>
<CardContent>
{efficiency.length === 0 ? (
<p className="text-sm text-muted-foreground">Veri yok.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Gün</TableHead>
<TableHead className="text-right">Skor</TableHead>
<TableHead className="text-right">Success</TableHead>
<TableHead className="text-right">Cache</TableHead>
<TableHead className="text-right">P95</TableHead>
<TableHead className="text-right">Fallback</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{efficiency
.slice()
.reverse()
.slice(0, 14)
.map((e) => (
<TableRow key={e.date}>
<TableCell className="font-mono text-xs">{e.date}</TableCell>
<TableCell
className={`text-right tabular-nums ${
e.score >= 0.8
? "text-emerald-600"
: e.score >= 0.6
? ""
: "text-destructive"
}`}
>
{(e.score * 100).toFixed(0)}
</TableCell>
<TableCell className="text-right tabular-nums">
{pct(e.successRate)}
</TableCell>
<TableCell className="text-right tabular-nums">
{pct(e.cacheHitRate)}
</TableCell>
<TableCell className="text-right tabular-nums">
{e.p95 != null ? `${e.p95}ms` : "—"}
</TableCell>
<TableCell className="text-right tabular-nums">
{pct(e.fallbackRate)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</PanelShell>
);
}
function pct(v: number): string {
return `${(v * 100).toFixed(1)}%`;
}
function avgCacheRate(rows: Array<{ total: number; cacheHits: number }>): number {
const total = rows.reduce((a, r) => a + r.total, 0);
const hits = rows.reduce((a, r) => a + r.cacheHits, 0);
return total > 0 ? hits / total : 0;
}