Files
sp/apps/web/src/app/projects/sase/vin-decode/page.tsx
Semih 09438cf513 fix(vin-decode): repair Business page 500 + drop redundant VIN buttons
- Business page returned 500: getTrialFunnel's `ORDER BY CASE bucket ...`
  referenced the SELECT-list alias inside an expression, which Postgres
  can't resolve (error 42703 "column bucket does not exist"). Wrap the
  GROUP BY in a subquery so `bucket` is a real column the ORDER BY can use.
  Verified against prod: original errors, fixed query returns ordered buckets.
- Remove the "Çözülen / Çözülemeyen VIN'ler" dashboard buttons — the VIN
  list already has a success/error status filter, so they were redundant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:22:21 +03:00

593 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
import {
listSaseDeploys,
analyzeDeployRegressions,
} from "@/lib/sase/deploy-timeline";
import { getVinRelatedInsights } from "@/lib/sase/vin-insights";
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, deploys, insights] =
await Promise.all([
getOperationalHealth(range),
getWinningProviders(range),
getProviderAttempts(range),
getFallbackChains(range),
getErrorBreakdown(range),
getDailyTrend(30),
getBrandBreakdown(range, 12),
listSaseDeploys(10),
getVinRelatedInsights(8),
]);
const regressions = deploys.length > 0 ? await analyzeDeployRegressions(deploys) : [];
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 items-center gap-2">
<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>
<Link
href="/projects/sase/vin-decode/vins"
className={buttonVariants({ variant: "outline", size: "sm" })}
>
VIN list
</Link>
<Link
href="/projects/sase/vin-decode/business"
className={buttonVariants({ variant: "outline", size: "sm" })}
>
Business
</Link>
<Link
href="/projects/sase/vin-decode/trends"
className={buttonVariants({ variant: "outline", size: "sm" })}
>
Trends
</Link>
<Link
href="/projects/sase/vin-decode/trends#wmi"
className={buttonVariants({ variant: "outline", size: "sm" })}
title="Sıradaki eklenecek WMI adayları (talebe göre)"
>
WMI fırsatları
</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,
href: `/projects/sase/vin-decode/providers/${encodeURIComponent(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,
href: `/projects/sase/vin-decode/providers/${encodeURIComponent(a.provider === "pcat" ? "parts-catalogs" : a.provider === "vin_api" ? "vin-api" : a.provider)}`,
count: a.attemptCount,
meta: `P50 ${ms(a.p50Ms)} · 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} title={e.exampleMessage}>
<Link
href={`/projects/sase/vin-decode/errors/${e.errorKey}?range=${range}`}
className="block rounded-md border p-2 hover:bg-muted/30"
>
<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>
</Link>
</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>
BIP'ten VIN'le ilgili insight'lar (derin kök neden)
</CardDescription>
</CardHeader>
<CardContent>
{insights.length === 0 ? (
<p className="text-sm text-muted-foreground">
VIN-tag'li aktif insight yok.{" "}
<Link href="/insights" className="underline">
/insights
</Link>{" "}
tam listede.
</p>
) : (
<ul className="space-y-2">
{insights.map((i) => (
<li key={i.id} className="rounded-md border p-2 text-sm">
<div className="flex items-baseline gap-2">
<Badge
variant={
i.severity === "P0"
? "destructive"
: i.severity === "P1"
? "default"
: "outline"
}
>
{i.severity}
</Badge>
<Badge variant="outline" className="font-mono text-xs">
{i.type}
</Badge>
<Link
href={`/insights/i/${i.id}`}
className="flex-1 truncate hover:underline"
>
{i.title}
</Link>
{i.giteaIssueUrl && (
<a
href={i.giteaIssueUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:underline"
>
#{i.giteaIssueNumber}{" "}
{i.giteaIssueState && `(${i.giteaIssueState})`}
</a>
)}
</div>
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
<span>{i.occurrenceCount} occurrence</span>
<span>{i.uniqueUserCount} kullanıcı</span>
{i.confidence != null && <span>conf {i.confidence.toFixed(2)}</span>}
<span>son {i.lastSeenAt.toISOString().slice(0, 16).replace("T", " ")}</span>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardDescription>
Son deploy'lar + regresyon analizi (30dk önce/sonra)
</CardDescription>
</CardHeader>
<CardContent>
{deploys.length === 0 ? (
<p className="text-sm text-muted-foreground">
Coolify deploy bilgisi yok (COOLIFY_API_TOKEN gerekli).
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Tarih</TableHead>
<TableHead>Commit</TableHead>
<TableHead className="text-right">Önce (success)</TableHead>
<TableHead className="text-right">Sonra (success)</TableHead>
<TableHead className="text-right">Δ</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{regressions.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
Henüz post-deploy penceresi geçmemiş.
</TableCell>
</TableRow>
)}
{regressions.map((r) => {
const deltaTone =
r.regressed
? "text-destructive font-semibold"
: r.successRateDeltaPp > 5
? "text-emerald-600"
: "";
return (
<TableRow key={r.deploy.deploymentUuid}>
<TableCell className="font-mono text-xs">
{r.deploy.startedAt.toISOString().slice(5, 16).replace("T", " ")}
</TableCell>
<TableCell className="font-mono text-xs">
{r.deploy.commit?.slice(0, 8) ?? "—"}
{r.regressed && (
<Badge variant="destructive" className="ml-2">
regression
</Badge>
)}
</TableCell>
<TableCell className="text-right text-xs tabular-nums">
{(r.before.successRate * 100).toFixed(1)}%{" "}
<span className="text-muted-foreground">
(n={r.before.total})
</span>
</TableCell>
<TableCell className="text-right text-xs tabular-nums">
{(r.after.successRate * 100).toFixed(1)}%{" "}
<span className="text-muted-foreground">
(n={r.after.total})
</span>
</TableCell>
<TableCell className={`text-right tabular-nums ${deltaTone}`}>
{r.successRateDeltaPp > 0 ? "+" : ""}
{r.successRateDeltaPp.toFixed(1)}pp
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<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; href?: 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) => {
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>
);
}
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`;
}