- 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>
593 lines
20 KiB
TypeScript
593 lines
20 KiB
TypeScript
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`;
|
||
}
|