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:
@@ -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">
|
||||
|
||||
403
apps/web/src/app/projects/sase/vin-decode/page.tsx
Normal file
403
apps/web/src/app/projects/sase/vin-decode/page.tsx
Normal 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user