feat(sase): VIN Decode Observability dashboard — Phase 8 Faz 1

Süper Panel VIN Decode Observability Module (SP-VIN-001), Faz 1 MVP.
Reads directly from Sase.tr's query_logs via DATABASE_URL_SASE_RO —
no PostHog hop, no ingestion worker, no aggregation tables. Sase.tr's
existing query_logs.timings jsonb already captures the full chain
context (per-provider durations, cache source, circuit-breaker state).

Repo (apps/web/src/lib/sase/vin-decode.ts)
- getOperationalHealth(range) — total/success/failure counts, success
  & error rates, P50/P95/P99 + avg response time, cache hit rate,
  timeout rate, unknown-VIN rate. Single raw query with FILTER aggs.
- getWinningProviders(range) — group by source (decoded by), share +
  avg response time per provider.
- getProviderAttempts(range) — group by timings jsonb keys
  (pcat/emex/pl24/vin_api). A row may contribute to multiple providers
  if the chain ran through several.
- getFallbackChains(range) — multi-provider rows grouped into chain
  strings (e.g. 'pcat→emex→pl24') with frequency.
- getErrorBreakdown(range) — bucket errors via regex
  (BUDGET_EXCEEDED, UNKNOWN_VIN, TIMEOUT, …) on errorMessage.
- getDailyTrend(days) — date_trunc('day') volume + success + failure
  + avg latency. Filled with zero-rows so the sparkline is continuous.
- getBrandBreakdown(range, limit) — top brands joined to brands.name/slug
  with per-brand success rate.

Dashboard (/projects/sase/vin-decode)
- 4-button time range picker (1h/24h/7d/30d) via URL search-params.
- 6 KPI cards (Success, Error, P95 RT, Cache hit, Timeout, Unknown VIN)
  color-coded by threshold.
- Two parallel provider views: "winning provider" (decode resolver)
  vs "provider attempt" (timings keys — wider, includes fallthroughs).
- Fallback chain frequency table.
- Error bucket cards with example messages.
- Top brands by query volume with success rate cells (green/red).
- 30-day daily-volume sparkline with success/total ratio shading.

Sase landing gets a "VIN Decode →" link next to the Users link.

No new tables, no migrations, no workers. RO-only by design.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Semih
2026-05-18 12:06:41 +03:00
parent 096ca9b4a3
commit 66a5828ee6
3 changed files with 799 additions and 0 deletions

View File

@@ -51,6 +51,12 @@ export async function SaseHealth() {
>
Users →
</Link>
<Link
href="/projects/sase/vin-decode"
className={buttonVariants({ variant: "outline", size: "sm" })}
>
VIN Decode →
</Link>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">

View File

@@ -0,0 +1,403 @@
import Link from "next/link";
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 {
ALL_RANGES,
type TimeRange,
getOperationalHealth,
getWinningProviders,
getProviderAttempts,
getFallbackChains,
getErrorBreakdown,
getDailyTrend,
getBrandBreakdown,
} from "@/lib/sase/vin-decode";
export const dynamic = "force-dynamic";
const RANGE_LABEL: Record<TimeRange, string> = {
"1h": "Son 1 saat",
"24h": "Son 24 saat",
"7d": "Son 7 gün",
"30d": "Son 30 gün",
};
function isRange(v: string | undefined): v is TimeRange {
return v === "1h" || v === "24h" || v === "7d" || v === "30d";
}
export default async function VinDecodePage({
searchParams,
}: {
searchParams: Promise<{ range?: string }>;
}) {
const sp = await searchParams;
const range: TimeRange = isRange(sp.range) ? sp.range : "24h";
const [health, winning, attempts, chains, errors, trend, brands] = await Promise.all([
getOperationalHealth(range),
getWinningProviders(range),
getProviderAttempts(range),
getFallbackChains(range),
getErrorBreakdown(range),
getDailyTrend(30),
getBrandBreakdown(range, 12),
]);
const trendMax = Math.max(1, ...trend.map((t) => t.total));
return (
<PanelShell title="Sase · VIN Decode Observability">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Link href="/projects/sase" className="hover:underline">
← Sase
</Link>
</div>
<div className="flex items-baseline justify-between">
<div>
<h2 className="text-xl font-semibold">VIN Decode Observability</h2>
<p className="text-sm text-muted-foreground">
{RANGE_LABEL[range]} · {health.totalCount.toLocaleString("tr-TR")} sorgu
</p>
</div>
<div className="flex gap-1">
{ALL_RANGES.map((r) => (
<Link
key={r}
href={`?range=${r}`}
className={buttonVariants({
variant: r === range ? "default" : "outline",
size: "sm",
})}
scroll={false}
>
{r}
</Link>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
<Kpi
label="Başarı"
value={pct(health.successRate)}
hint={`${health.successCount.toLocaleString("tr-TR")} ok`}
tone={
health.successRate >= 0.9
? "ok"
: health.successRate >= 0.75
? "warn"
: "bad"
}
/>
<Kpi
label="Hata"
value={pct(health.errorRate)}
hint={`${health.failureCount.toLocaleString("tr-TR")} fail`}
tone={
health.errorRate <= 0.05
? "ok"
: health.errorRate <= 0.15
? "warn"
: "bad"
}
/>
<Kpi
label="P95 yanıt"
value={ms(health.p95ResponseMs)}
hint={`P50 ${ms(health.p50ResponseMs)} · P99 ${ms(health.p99ResponseMs)}`}
tone={
!health.p95ResponseMs
? undefined
: health.p95ResponseMs <= 3000
? "ok"
: health.p95ResponseMs <= 8000
? "warn"
: "bad"
}
/>
<Kpi
label="Cache hit"
value={pct(health.cacheHitRate)}
hint={`${health.cacheHitCount.toLocaleString("tr-TR")}`}
/>
<Kpi
label="Timeout"
value={pct(health.timeoutRate)}
hint={`${health.timeoutCount.toLocaleString("tr-TR")}`}
tone={health.timeoutRate <= 0.05 ? "ok" : "warn"}
/>
<Kpi
label="Unknown VIN"
value={pct(health.unknownVinRate)}
hint={`${health.unknownVinCount.toLocaleString("tr-TR")}`}
/>
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<Card>
<CardHeader>
<CardDescription>Kazanan provider (decode'u sonuçlandıran)</CardDescription>
</CardHeader>
<CardContent>
<DistList
items={winning.map((w) => ({
label: w.provider,
count: w.successCount,
meta: `Ø ${ms(w.avgResponseMs)} · %${(w.share * 100).toFixed(1)}`,
}))}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardDescription>Provider deneme (timings'ten — chain'de yer alan)</CardDescription>
</CardHeader>
<CardContent>
<DistList
items={attempts.map((a) => ({
label: a.provider,
count: a.attemptCount,
meta: `Ø ${ms(a.avgMs)} · P95 ${ms(a.p95Ms)}`,
}))}
/>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardDescription>Fallback zincirleri (timings keys)</CardDescription>
</CardHeader>
<CardContent>
{chains.length === 0 ? (
<p className="text-sm text-muted-foreground">Bu pencerede provider chain kullanılmamış.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Chain</TableHead>
<TableHead className="text-right">Adet</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{chains.map((c) => (
<TableRow key={c.chain}>
<TableCell className="font-mono text-xs">{c.chain}</TableCell>
<TableCell className="text-right tabular-nums">
{c.occurrences.toLocaleString("tr-TR")}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<Card>
<CardHeader>
<CardDescription>Hata kodu breakdown</CardDescription>
</CardHeader>
<CardContent>
{errors.length === 0 ? (
<p className="text-sm text-muted-foreground">Hata yok.</p>
) : (
<ul className="space-y-2 text-sm">
{errors.map((e) => (
<li
key={e.errorKey}
className="rounded-md border p-2"
title={e.exampleMessage}
>
<div className="flex items-center justify-between">
<Badge variant="outline" className="font-mono">
{e.errorKey}
</Badge>
<span className="tabular-nums">
{e.count.toLocaleString("tr-TR")}
</span>
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">
{e.exampleMessage}
</p>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardDescription>En çok sorgulanan markalar (success rate'li)</CardDescription>
</CardHeader>
<CardContent>
{brands.length === 0 ? (
<p className="text-sm text-muted-foreground">Marka eşleşmiş sorgu yok.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Marka</TableHead>
<TableHead className="text-right">Sorgu</TableHead>
<TableHead className="text-right">Başarı</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{brands.map((b) => (
<TableRow key={b.brandSlug ?? "unknown"}>
<TableCell>
<span className="font-mono text-xs">{b.brandSlug ?? "?"}</span>
{b.brandName && (
<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 " +
(b.successRate >= 0.9
? "text-emerald-600"
: b.successRate >= 0.75
? ""
: "text-destructive")
}
>
{pct(b.successRate)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardDescription>Son 30 gün — günlük sorgu hacmi</CardDescription>
</CardHeader>
<CardContent>
<div className="flex h-24 items-end gap-px">
{trend.map((t) => (
<div
key={t.date}
title={`${t.date}: ${t.total} (${t.succeeded} ok / ${t.failed} fail · Ø ${ms(t.avgResponseMs)})`}
style={{ height: `${(t.total / trendMax) * 100}%` }}
className="relative flex-1 min-w-[2px] overflow-hidden rounded-sm bg-muted"
>
{t.succeeded > 0 && (
<div
style={{ height: `${(t.succeeded / t.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>{trend[0]?.date}</span>
<span>maks {trendMax}/gün</span>
<span>{trend[trend.length - 1]?.date}</span>
</div>
</CardContent>
</Card>
</PanelShell>
);
}
function Kpi({
label,
value,
hint,
tone,
}: {
label: string;
value: string;
hint?: string;
tone?: "ok" | "warn" | "bad";
}) {
const toneColor =
tone === "ok"
? "text-emerald-600"
: tone === "warn"
? "text-yellow-600"
: tone === "bad"
? "text-destructive"
: "";
return (
<Card>
<CardHeader>
<CardDescription>{label}</CardDescription>
<CardTitle className={`text-2xl font-semibold tabular-nums ${toneColor}`}>
{value}
</CardTitle>
</CardHeader>
{hint && (
<CardContent className="text-xs text-muted-foreground">{hint}</CardContent>
)}
</Card>
);
}
function DistList({
items,
}: {
items: Array<{ label: string; count: number; meta?: string }>;
}) {
if (items.length === 0) {
return <p className="text-sm text-muted-foreground">Veri yok.</p>;
}
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>
))}
</ul>
);
}
function pct(v: number): string {
return `${(v * 100).toFixed(1)}%`;
}
function ms(v: number | null): string {
if (v == null) return "—";
if (v >= 1000) return `${(v / 1000).toFixed(1)}s`;
return `${v}ms`;
}