feat(sase): VIN observability Faz 4 — trends + provider drill-down

Closes Phase 8: long-term capacity view + per-provider deep dive.
Alert-threshold config UI deferred to teknikborc — the cron defaults
are well-calibrated enough to ship without a config table.

Trends page — /projects/sase/vin-decode/trends
1. Son 90 gün — daily decode volume sparkline, success/total ratio
   shading.
2. Haftalık büyüme — WoW % change for last 12 weeks, color-coded.
3. Provider yük dağılımı — stacked area for the last 30 days, one bar
   per day decomposed by winning provider; legend below.
4. Düşük destekli markalar — brands with ≥20 30d volume but ≤75%
   success. New-provider/decoder investment signal.
5. Peak heatmap — 7×24 grid (day-of-week × hour) for the last 30d,
   intensity-shaded; B2B working-hours pattern + infra scaling guide.
6. Cache hit trend — daily cache hit rate sparkline + 30d average.
7. Verimlilik composite — daily score combining success (50%), cache
   (20%), latency (20%, 1.0 at <500ms decaying to 0 at >10s), and
   1-fallback (10%). 14-day table view.

Provider drill-down — /providers/[name]?range=7d|30d
- Four KPIs: wins total, success rate, avg/P95 latency, chain-attempt
  count (separately tracked for chain key vs winning-source).
- Daily wins sparkline scoped to the range.
- Top 10 brands the provider successfully decoded for.
- Fallback chains the provider participated in.
- 20 most recent failures with sanitized VIN + user-link drill.

Dashboard wiring
- Winning-provider and provider-attempts cards on the main VIN
  dashboard now link each provider row to /providers/[name]. The
  timings-key aliases (pcat → parts-catalogs, vin_api → vin-api) are
  remapped so the link reaches the right page.
- New "Trends →" pill in the header next to "Business →".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Semih
2026-05-18 13:13:19 +03:00
parent e583f6b6be
commit b5ba8919b8
4 changed files with 1244 additions and 13 deletions

View File

@@ -109,6 +109,12 @@ export default async function VinDecodePage({
>
Business
</Link>
<Link
href="/projects/sase/vin-decode/trends"
className={buttonVariants({ variant: "outline", size: "sm" })}
>
Trends
</Link>
</div>
</div>
@@ -178,6 +184,7 @@ export default async function VinDecodePage({
<DistList
items={winning.map((w) => ({
label: w.provider,
href: `/projects/sase/vin-decode/providers/${encodeURIComponent(w.provider)}`,
count: w.successCount,
meta: `Ø ${ms(w.avgResponseMs)} · %${(w.share * 100).toFixed(1)}`,
}))}
@@ -193,6 +200,7 @@ export default async function VinDecodePage({
<DistList
items={attempts.map((a) => ({
label: a.provider,
href: `/projects/sase/vin-decode/providers/${encodeURIComponent(a.provider === "pcat" ? "parts-catalogs" : a.provider === "vin_api" ? "vin-api" : a.provider)}`,
count: a.attemptCount,
meta: `Ø ${ms(a.avgMs)} · P95 ${ms(a.p95Ms)}`,
}))}
@@ -527,7 +535,7 @@ function Kpi({
function DistList({
items,
}: {
items: Array<{ label: string; count: number; meta?: string }>;
items: Array<{ label: string; count: number; meta?: string; href?: string }>;
}) {
if (items.length === 0) {
return <p className="text-sm text-muted-foreground">Veri yok.</p>;
@@ -535,18 +543,27 @@ function DistList({
const max = Math.max(1, ...items.map((i) => i.count));
return (
<ul className="space-y-2">
{items.map((i) => (
<li key={i.label} className="space-y-1 text-sm">
<div className="flex items-baseline justify-between gap-2">
<span className="font-mono text-xs">{i.label}</span>
<span className="text-xs text-muted-foreground">{i.meta}</span>
<span className="w-16 text-right tabular-nums">{i.count.toLocaleString("tr-TR")}</span>
</div>
<div className="h-2 overflow-hidden rounded bg-muted">
<div className="h-full bg-primary" style={{ width: `${(i.count / max) * 100}%` }} />
</div>
</li>
))}
{items.map((i) => {
const labelNode = i.href ? (
<Link href={i.href} className="font-mono text-xs hover:underline">
{i.label}
</Link>
) : (
<span className="font-mono text-xs">{i.label}</span>
);
return (
<li key={i.label} className="space-y-1 text-sm">
<div className="flex items-baseline justify-between gap-2">
{labelNode}
<span className="text-xs text-muted-foreground">{i.meta}</span>
<span className="w-16 text-right tabular-nums">{i.count.toLocaleString("tr-TR")}</span>
</div>
<div className="h-2 overflow-hidden rounded bg-muted">
<div className="h-full bg-primary" style={{ width: `${(i.count / max) * 100}%` }} />
</div>
</li>
);
})}
</ul>
);
}

View File

@@ -0,0 +1,280 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { PanelShell } from "@/components/panel-shell";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { buttonVariants } from "@/components/ui/button";
import { getProviderDeepStats } from "@/lib/sase/vin-trends";
export const dynamic = "force-dynamic";
const KNOWN_PROVIDERS = [
"parts-catalogs",
"pl24",
"emex",
"vin-api",
"cache",
"corgi",
];
function isProvider(v: string): boolean {
// Allow any string but log a warning if unfamiliar — the route still works
// because the queries match `source = $name` literally.
return /^[a-z0-9_-]+$/i.test(v) && v.length <= 32;
}
export default async function ProviderDeepPage({
params,
searchParams,
}: {
params: Promise<{ name: string }>;
searchParams: Promise<{ range?: string }>;
}) {
const { name } = await params;
const sp = await searchParams;
if (!isProvider(name)) notFound();
const range: "7d" | "30d" = sp.range === "30d" ? "30d" : "7d";
const stats = await getProviderDeepStats(name, range);
if (!stats) notFound();
const dailyMax = Math.max(1, ...stats.dailyVolume.map((d) => d.count));
return (
<PanelShell title={`Sase · provider ${name}`}>
<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 className="flex items-baseline justify-between">
<div>
<h2 className="text-xl font-semibold">
<Badge variant="outline" className="mr-2 font-mono">
{stats.provider}
</Badge>
provider drill-down
</h2>
<p className="text-sm text-muted-foreground">
Son {range} · {stats.wins.total.toLocaleString("tr-TR")} kazanılmış decode
</p>
</div>
<div className="flex gap-1">
{(["7d", "30d"] as const).map((r) => (
<Link
key={r}
href={`?range=${r}`}
className={buttonVariants({
variant: r === range ? "default" : "outline",
size: "sm",
})}
scroll={false}
>
{r}
</Link>
))}
</div>
</div>
{!KNOWN_PROVIDERS.includes(stats.provider) && (
<p className="text-xs text-muted-foreground">
Bilinmeyen provider adı (chain key'ı yok); chain-attempt ve fallback
istatistikleri bu sayfa için sınırlı.
</p>
)}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<Kpi
label="Kazanılan decode"
value={stats.wins.total.toLocaleString("tr-TR")}
hint={`${stats.wins.succeeded.toLocaleString("tr-TR")} ok`}
/>
<Kpi
label="Başarı"
value={`${(stats.wins.successRate * 100).toFixed(1)}%`}
/>
<Kpi
label="Avg RT"
value={stats.wins.avgMs != null ? `${stats.wins.avgMs}ms` : "—"}
hint={`P95 ${stats.wins.p95 != null ? `${stats.wins.p95}ms` : "—"}`}
/>
<Kpi
label="Chain'de görüldü"
value={stats.attempts.total.toLocaleString("tr-TR")}
hint={
stats.attempts.total > 0
? `Ø ${stats.attempts.avgMs}ms · P95 ${stats.attempts.p95 ?? ""}ms`
: "chain key'ı yok"
}
/>
</div>
<Card>
<CardHeader>
<CardDescription>Günlük kazanılan decode</CardDescription>
</CardHeader>
<CardContent>
<div className="flex h-20 items-end gap-1">
{stats.dailyVolume.map((d) => (
<div
key={d.date}
title={`${d.date}: ${d.count}`}
style={{ height: `${(d.count / dailyMax) * 100}%`, minHeight: "1px" }}
className="flex-1 rounded-sm bg-primary/70 hover:bg-primary"
/>
))}
</div>
<div className="flex justify-between pt-1 text-xs text-muted-foreground">
<span>{stats.dailyVolume[0]?.date}</span>
<span>maks {dailyMax}/gün</span>
<span>{stats.dailyVolume[stats.dailyVolume.length - 1]?.date}</span>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<Card>
<CardHeader>
<CardDescription>Top 10 brand (bu provider tarafından kazanılan)</CardDescription>
</CardHeader>
<CardContent>
{stats.topBrands.length === 0 ? (
<p className="text-sm text-muted-foreground">Brand eşleşmesi yok.</p>
) : (
<ul className="space-y-1 text-sm">
{stats.topBrands.map((b) => (
<li key={b.brandSlug ?? "?"} className="flex justify-between">
<span>
<span className="font-mono text-xs">{b.brandSlug ?? "?"}</span>{" "}
<span className="text-xs text-muted-foreground">{b.brandName}</span>
</span>
<span className="tabular-nums">{b.count.toLocaleString("tr-TR")}</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardDescription>Fallback chain'leri (bu provider'ın bulunduğu)</CardDescription>
</CardHeader>
<CardContent>
{stats.fallbackChains.length === 0 ? (
<p className="text-sm text-muted-foreground">Fallback yok.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Chain</TableHead>
<TableHead className="text-right">Adet</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stats.fallbackChains.map((c) => (
<TableRow key={c.chain}>
<TableCell className="font-mono text-xs">{c.chain}</TableCell>
<TableCell className="text-right tabular-nums">
{c.count.toLocaleString("tr-TR")}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardDescription>
Son {stats.recentFailures.length} fail (bu provider source iken)
</CardDescription>
</CardHeader>
<CardContent>
{stats.recentFailures.length === 0 ? (
<p className="text-sm text-muted-foreground">Fail yok.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Tarih</TableHead>
<TableHead>Kullanıcı</TableHead>
<TableHead>VIN</TableHead>
<TableHead className="text-right">RT</TableHead>
<TableHead>Mesaj</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stats.recentFailures.map((f) => (
<TableRow key={f.id}>
<TableCell className="font-mono text-xs">
{f.createdAt.toISOString().slice(5, 16).replace("T", " ")}
</TableCell>
<TableCell className="font-mono text-xs">
<Link
href={`/projects/sase/users/${f.userId}`}
className="hover:underline"
>
{f.userId.slice(0, 8)}
</Link>
</TableCell>
<TableCell className="font-mono text-xs">{f.vinSanitized}</TableCell>
<TableCell className="text-right text-xs tabular-nums">
{f.responseTimeMs ? `${f.responseTimeMs}ms` : "—"}
</TableCell>
<TableCell
className="max-w-md truncate text-xs text-muted-foreground"
title={f.errorMessage}
>
{f.errorMessage}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</PanelShell>
);
}
function Kpi({
label,
value,
hint,
}: {
label: string;
value: string;
hint?: string;
}) {
return (
<Card>
<CardHeader>
<CardDescription>{label}</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums">{value}</CardTitle>
</CardHeader>
{hint && (
<CardContent className="text-xs text-muted-foreground">{hint}</CardContent>
)}
</Card>
);
}

View File

@@ -0,0 +1,409 @@
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,
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, heatmap, cache, efficiency] =
await Promise.all([
getLongTermVolume(90),
getWeeklyGrowth(12),
getProviderShareTrend(30),
getUnderSupportedBrands(30, 20, 0.75),
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>
{/* 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;
}

View File

@@ -0,0 +1,525 @@
import { saseDb } from "@/lib/db-sase";
// ─── Long-term volume trend ───────────────────────────────────────────────
export type DailyVolumeRow = {
date: string;
total: number;
succeeded: number;
failed: number;
successRate: number;
};
export async function getLongTermVolume(days = 90): Promise<DailyVolumeRow[]> {
const start = new Date(Date.now() - days * 24 * 60 * 60_000);
const rows = await saseDb.$queryRaw<
Array<{ day: Date; total: bigint; succeeded: bigint; failed: bigint }>
>`
SELECT
date_trunc('day', created_at) AS day,
count(*) AS total,
count(*) FILTER (WHERE success = true) AS succeeded,
count(*) FILTER (WHERE success = false) AS failed
FROM query_logs
WHERE created_at >= ${start}
GROUP BY date_trunc('day', created_at)
ORDER BY day ASC
`;
const filled: DailyVolumeRow[] = [];
const today = new Date();
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today.getTime() - i * 24 * 60 * 60_000);
const dayKey = d.toISOString().slice(0, 10);
const found = rows.find((r) => r.day.toISOString().slice(0, 10) === dayKey);
const total = found ? Number(found.total) : 0;
const succeeded = found ? Number(found.succeeded) : 0;
filled.push({
date: dayKey,
total,
succeeded,
failed: found ? Number(found.failed) : 0,
successRate: total > 0 ? succeeded / total : 0,
});
}
return filled;
}
export type WeeklyGrowth = {
weekStart: string;
volume: number;
growthPct: number | null;
};
export async function getWeeklyGrowth(weeks = 12): Promise<WeeklyGrowth[]> {
const start = new Date(Date.now() - weeks * 7 * 24 * 60 * 60_000);
const rows = await saseDb.$queryRaw<Array<{ week: Date; total: bigint }>>`
SELECT
date_trunc('week', created_at) AS week,
count(*) AS total
FROM query_logs
WHERE created_at >= ${start}
GROUP BY date_trunc('week', created_at)
ORDER BY week ASC
`;
return rows.map((r, i) => {
const prev = i > 0 ? Number(rows[i - 1].total) : null;
const cur = Number(r.total);
const growth = prev != null && prev > 0 ? (cur - prev) / prev : null;
return {
weekStart: r.week.toISOString().slice(0, 10),
volume: cur,
growthPct: growth,
};
});
}
// ─── Provider share over time ─────────────────────────────────────────────
export type ProviderShareDay = {
date: string;
byProvider: Record<string, number>;
total: number;
};
export async function getProviderShareTrend(days = 30): Promise<ProviderShareDay[]> {
const start = new Date(Date.now() - days * 24 * 60 * 60_000);
const rows = await saseDb.$queryRaw<
Array<{ day: Date; provider: string | null; cnt: bigint }>
>`
SELECT
date_trunc('day', created_at) AS day,
coalesce(source, 'unknown') AS provider,
count(*) AS cnt
FROM query_logs
WHERE created_at >= ${start} AND success = true
GROUP BY date_trunc('day', created_at), source
ORDER BY day ASC
`;
const byDay = new Map<string, ProviderShareDay>();
for (const r of rows) {
const dayKey = r.day.toISOString().slice(0, 10);
const entry = byDay.get(dayKey) ?? { date: dayKey, byProvider: {}, total: 0 };
const cnt = Number(r.cnt);
entry.byProvider[r.provider ?? "unknown"] = cnt;
entry.total += cnt;
byDay.set(dayKey, entry);
}
return Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date));
}
// ─── Under-supported brands (volume yes, success no) ──────────────────────
export type UnderSupportedBrand = {
brandSlug: string | null;
brandName: string | null;
total: number;
succeeded: number;
failed: number;
successRate: number;
uniqueUsers: number;
};
export async function getUnderSupportedBrands(
days = 30,
minVolume = 20,
maxSuccessRate = 0.75,
): Promise<UnderSupportedBrand[]> {
const start = new Date(Date.now() - days * 24 * 60 * 60_000);
const rows = await saseDb.$queryRaw<
Array<{
brand_slug: string | null;
brand_name: string | null;
total: bigint;
succeeded: bigint;
failed: bigint;
users: bigint;
}>
>`
SELECT
b.slug AS brand_slug,
b.name AS brand_name,
count(*) AS total,
count(*) FILTER (WHERE q.success = true) AS succeeded,
count(*) FILTER (WHERE q.success = false) AS failed,
count(DISTINCT q.user_id) AS users
FROM query_logs q
LEFT JOIN brands b ON b.id = q.brand_id
WHERE q.created_at >= ${start}
AND q.brand_id IS NOT NULL
GROUP BY b.slug, b.name
HAVING count(*) >= ${minVolume}
AND count(*) FILTER (WHERE q.success = true)::float / count(*) <= ${maxSuccessRate}
ORDER BY count(*) FILTER (WHERE q.success = false) DESC
LIMIT 20
`;
return rows.map((r) => {
const total = Number(r.total);
const succeeded = Number(r.succeeded);
return {
brandSlug: r.brand_slug,
brandName: r.brand_name,
total,
succeeded,
failed: Number(r.failed),
successRate: total > 0 ? succeeded / total : 0,
uniqueUsers: Number(r.users),
};
});
}
// ─── Peak heatmap (hour-of-day × day-of-week, last 30d) ───────────────────
export type HeatmapCell = {
dayOfWeek: number; // 0 = Mon, 6 = Sun (ISO style)
hour: number; // 0..23
count: number;
};
export async function getPeakHeatmap(days = 30): Promise<{
cells: HeatmapCell[];
max: number;
}> {
const start = new Date(Date.now() - days * 24 * 60 * 60_000);
// Postgres' isodow: 1=Mon..7=Sun; subtract 1 to align with our 0=Mon.
const rows = await saseDb.$queryRaw<
Array<{ dow: number; hour: number; cnt: bigint }>
>`
SELECT
(extract(isodow FROM created_at)::int - 1) AS dow,
extract(hour FROM created_at)::int AS hour,
count(*) AS cnt
FROM query_logs
WHERE created_at >= ${start}
GROUP BY dow, hour
ORDER BY dow, hour
`;
let max = 0;
const cells: HeatmapCell[] = rows.map((r) => {
const count = Number(r.cnt);
if (count > max) max = count;
return { dayOfWeek: r.dow, hour: r.hour, count };
});
return { cells, max };
}
// ─── Cache hit rate over time ─────────────────────────────────────────────
export type CacheTrendDay = {
date: string;
total: number;
cacheHits: number;
cacheHitRate: number;
};
export async function getCacheHitTrend(days = 30): Promise<CacheTrendDay[]> {
const start = new Date(Date.now() - days * 24 * 60 * 60_000);
const rows = await saseDb.$queryRaw<
Array<{ day: Date; total: bigint; cache_hits: bigint }>
>`
SELECT
date_trunc('day', created_at) AS day,
count(*) AS total,
count(*) FILTER (
WHERE source = 'cache'
OR (timings->>'cache_source') IN ('db_hit', 'redis_positive')
) AS cache_hits
FROM query_logs
WHERE created_at >= ${start}
GROUP BY date_trunc('day', created_at)
ORDER BY day ASC
`;
const filled: CacheTrendDay[] = [];
const today = new Date();
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today.getTime() - i * 24 * 60 * 60_000);
const dayKey = d.toISOString().slice(0, 10);
const found = rows.find((r) => r.day.toISOString().slice(0, 10) === dayKey);
const total = found ? Number(found.total) : 0;
const hits = found ? Number(found.cache_hits) : 0;
filled.push({
date: dayKey,
total,
cacheHits: hits,
cacheHitRate: total > 0 ? hits / total : 0,
});
}
return filled;
}
// ─── Efficiency composite (single weighted score over time) ───────────────
export type EfficiencyDay = {
date: string;
successRate: number;
cacheHitRate: number;
p95: number | null;
fallbackRate: number;
score: number; // 0..1
};
export async function getEfficiencyTrend(days = 30): Promise<EfficiencyDay[]> {
const start = new Date(Date.now() - days * 24 * 60 * 60_000);
const rows = await saseDb.$queryRaw<
Array<{
day: Date;
total: bigint;
succeeded: bigint;
cache_hits: bigint;
fallback_rows: bigint;
p95: number | null;
}>
>`
WITH per_row AS (
SELECT
date_trunc('day', created_at) AS day,
success,
response_time_ms,
(source = 'cache' OR (timings->>'cache_source') IN ('db_hit', 'redis_positive')) AS cache_hit,
((timings ? 'pcat')::int + (timings ? 'emex')::int + (timings ? 'pl24')::int + (timings ? 'vin_api')::int) > 1 AS fallback
FROM query_logs
WHERE created_at >= ${start}
)
SELECT
day,
count(*) AS total,
count(*) FILTER (WHERE success = true) AS succeeded,
count(*) FILTER (WHERE cache_hit) AS cache_hits,
count(*) FILTER (WHERE fallback) AS fallback_rows,
percentile_cont(0.95) WITHIN GROUP (ORDER BY response_time_ms)
FILTER (WHERE success = true AND response_time_ms IS NOT NULL)::int AS p95
FROM per_row
GROUP BY day
ORDER BY day ASC
`;
const out: EfficiencyDay[] = rows.map((r) => {
const total = Number(r.total);
const succeeded = Number(r.succeeded);
const cacheHits = Number(r.cache_hits);
const fallback = Number(r.fallback_rows);
const successRate = total > 0 ? succeeded / total : 0;
const cacheRate = total > 0 ? cacheHits / total : 0;
const fallbackRate = total > 0 ? fallback / total : 0;
// Latency scoring: 1.0 if p95 < 500ms; 0.0 if >= 10s; linear in between.
const latencyScore =
r.p95 == null ? 0.5 : Math.max(0, Math.min(1, 1 - (r.p95 - 500) / 9500));
const score =
successRate * 0.5 +
cacheRate * 0.2 +
latencyScore * 0.2 +
(1 - fallbackRate) * 0.1;
return {
date: r.day.toISOString().slice(0, 10),
successRate,
cacheHitRate: cacheRate,
p95: r.p95,
fallbackRate,
score,
};
});
return out;
}
// ─── Single-provider drill-down (used by /providers/[name] page) ──────────
export type ProviderDeepStats = {
provider: string;
range: "7d" | "30d";
// Won-by-this-provider window
wins: { total: number; succeeded: number; successRate: number; avgMs: number | null; p95: number | null };
// Attempted-in-chain window (timings key present)
attempts: { total: number; avgMs: number; p95: number | null };
// Daily win volume for the window
dailyVolume: Array<{ date: string; count: number }>;
topBrands: Array<{ brandSlug: string | null; brandName: string | null; count: number }>;
fallbackChains: Array<{ chain: string; count: number }>;
recentFailures: Array<{
id: string;
userId: string;
vinSanitized: string;
errorMessage: string;
responseTimeMs: number | null;
createdAt: Date;
}>;
};
function sanitizeVin(vin: string): string {
const upper = vin.toUpperCase();
return upper.length >= 11 ? `${upper.slice(0, 11)}******` : `${upper.slice(0, 3)}***`;
}
const TIMINGS_KEY_BY_PROVIDER: Record<string, string> = {
"parts-catalogs": "pcat",
emex: "emex",
pl24: "pl24",
"vin-api": "vin_api",
};
export async function getProviderDeepStats(
provider: string,
range: "7d" | "30d" = "7d",
): Promise<ProviderDeepStats | null> {
const days = range === "30d" ? 30 : 7;
const start = new Date(Date.now() - days * 24 * 60 * 60_000);
const timingsKey = TIMINGS_KEY_BY_PROVIDER[provider];
const [winsRow] = await saseDb.$queryRaw<
Array<{
total: bigint;
succeeded: bigint;
avg_ms: number | null;
p95: number | null;
}>
>`
SELECT
count(*) AS total,
count(*) FILTER (WHERE success = true) AS succeeded,
avg(response_time_ms)::int AS avg_ms,
percentile_cont(0.95) WITHIN GROUP (ORDER BY response_time_ms)::int AS p95
FROM query_logs
WHERE created_at >= ${start} AND source = ${provider}
`;
if (!winsRow || Number(winsRow.total) === 0) {
if (!timingsKey) return null;
}
const winsTotal = Number(winsRow?.total ?? 0n);
const winsSucceeded = Number(winsRow?.succeeded ?? 0n);
// Attempts: timings key present (provider showed up in the chain even if it
// didn't win). Some providers have no timings key (e.g. 'cache'), so this
// can be empty.
let attempts = { total: 0, avgMs: 0, p95: null as number | null };
if (timingsKey) {
const [attemptsRow] = await saseDb.$queryRaw<
Array<{ total: bigint; avg_ms: number | null; p95_ms: number | null }>
>`
SELECT
count(*) AS total,
avg((timings->>${timingsKey})::numeric)::int AS avg_ms,
percentile_cont(0.95) WITHIN GROUP (ORDER BY (timings->>${timingsKey})::numeric)::int AS p95_ms
FROM query_logs
WHERE created_at >= ${start}
AND timings ? ${timingsKey}
AND jsonb_typeof(timings -> ${timingsKey}) = 'number'
`;
if (attemptsRow) {
attempts = {
total: Number(attemptsRow.total),
avgMs: attemptsRow.avg_ms ?? 0,
p95: attemptsRow.p95_ms,
};
}
}
const dailyRows = await saseDb.$queryRaw<
Array<{ day: Date; cnt: bigint }>
>`
SELECT date_trunc('day', created_at) AS day, count(*) AS cnt
FROM query_logs
WHERE created_at >= ${start} AND source = ${provider}
GROUP BY date_trunc('day', created_at)
ORDER BY day ASC
`;
const dailyVolume: Array<{ date: string; count: number }> = [];
const today = new Date();
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today.getTime() - i * 24 * 60 * 60_000);
const dayKey = d.toISOString().slice(0, 10);
const found = dailyRows.find((r) => r.day.toISOString().slice(0, 10) === dayKey);
dailyVolume.push({ date: dayKey, count: found ? Number(found.cnt) : 0 });
}
const brandRows = await saseDb.$queryRaw<
Array<{ brand_slug: string | null; brand_name: string | null; cnt: bigint }>
>`
SELECT b.slug AS brand_slug, b.name AS brand_name, count(*) AS cnt
FROM query_logs q
LEFT JOIN brands b ON b.id = q.brand_id
WHERE q.created_at >= ${start} AND q.source = ${provider}
AND q.brand_id IS NOT NULL
GROUP BY b.slug, b.name
ORDER BY count(*) DESC
LIMIT 10
`;
const topBrands = brandRows.map((r) => ({
brandSlug: r.brand_slug,
brandName: r.brand_name,
count: Number(r.cnt),
}));
// Fallback chains that involve this provider
let fallbackChains: Array<{ chain: string; count: number }> = [];
if (timingsKey) {
const chainRows = await saseDb.$queryRaw<
Array<{ chain: string; cnt: bigint }>
>`
WITH provider_keys AS (
SELECT
id,
(timings ? 'pcat')::int AS pcat,
(timings ? 'emex')::int AS emex,
(timings ? 'pl24')::int AS pl24,
(timings ? 'vin_api')::int AS vin_api
FROM query_logs
WHERE created_at >= ${start}
AND timings ? ${timingsKey}
)
SELECT
concat_ws(
'→',
CASE WHEN pcat = 1 THEN 'pcat' END,
CASE WHEN emex = 1 THEN 'emex' END,
CASE WHEN pl24 = 1 THEN 'pl24' END,
CASE WHEN vin_api = 1 THEN 'vin_api' END
) AS chain,
count(*) AS cnt
FROM provider_keys
WHERE pcat + emex + pl24 + vin_api > 1
GROUP BY chain
ORDER BY count(*) DESC
LIMIT 10
`;
fallbackChains = chainRows.map((r) => ({ chain: r.chain, count: Number(r.cnt) }));
}
const failRows = await saseDb.queryLog.findMany({
where: {
createdAt: { gte: start },
success: false,
...(timingsKey
? {
// Provider was either the winning attempt or appeared in the chain.
OR: [{ source: provider }],
}
: { source: provider }),
},
orderBy: { createdAt: "desc" },
take: 20,
select: {
id: true,
userId: true,
vin: true,
errorMessage: true,
responseTimeMs: true,
createdAt: true,
},
});
const recentFailures = failRows.map((f) => ({
id: f.id,
userId: f.userId,
vinSanitized: sanitizeVin(f.vin),
errorMessage: f.errorMessage ?? "",
responseTimeMs: f.responseTimeMs,
createdAt: f.createdAt,
}));
return {
provider,
range,
wins: {
total: winsTotal,
succeeded: winsSucceeded,
successRate: winsTotal > 0 ? winsSucceeded / winsTotal : 0,
avgMs: winsRow?.avg_ms ?? null,
p95: winsRow?.p95 ?? null,
},
attempts,
dailyVolume,
topBrands,
fallbackChains,
recentFailures,
};
}