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;
}