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;
|
||||
}
|
||||
Reference in New Issue
Block a user