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>
488 lines
18 KiB
TypeScript
488 lines
18 KiB
TypeScript
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">
|
||
"eşleşmesiz WMI" = 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'ü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;
|
||
}
|