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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
409
apps/web/src/app/projects/sase/vin-decode/trends/page.tsx
Normal file
409
apps/web/src/app/projects/sase/vin-decode/trends/page.tsx
Normal 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;
|
||||
}
|
||||
525
apps/web/src/lib/sase/vin-trends.ts
Normal file
525
apps/web/src/lib/sase/vin-trends.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user