feat(sase): VIN observability Faz 2 — error drill-down + deploy regression + BIP

Three additions to the VIN Decode Observability dashboard, all read-only.

Faz 2a — Error fingerprint drill-down
- New route /projects/sase/vin-decode/errors/[key] where [key] is one
  of the eight bucket keys (BUDGET_EXCEEDED, UNKNOWN_VIN, TIMEOUT, …).
- getErrorBucketDetail(key, range) returns: total + unique-user count,
  hourly time series, top 10 affected brands (joined to brands.name/
  slug), top providers (winning + chain attempts from timings keys),
  top 20 affected users with link to their detail page, six distinct
  sample error messages, last 30 raw failures (sanitized VIN, source,
  RT, message).
- Error breakdown cards on the main dashboard now link to this page
  with the current time range preserved.
- ERROR_BUCKET_KEYS + ErrorBucketKey exported so the [key] route
  validates against the same list.

Faz 2b — Deploy regression analysis
- listSaseDeploys(limit) fetches the Sase.tr Coolify app's recent
  finished deployments via the Coolify API (COOLIFY_API_TOKEN).
- analyzeDeployRegressions(deploys) slices a 30-minute window
  before deploy.startedAt and after deploy.finishedAt, computes the
  success-rate delta, and flags rows where the drop ≥ 10pp (and
  both windows have ≥ 5 samples).
- Dashboard card lists last ~10 deploys with before/after rates and
  Δ pp, color-coded; "regression" badge on flagged rows.

Faz 2c — BIP integration
- getVinRelatedInsights() reads from the panel-pg `insights` table:
  type='provider_quality' (always VIN-related by prompt-tag routing)
  + bug_triage insights whose body mentions VIN/decode/provider
  keywords. Deduped, severity-then-recency sorted.
- New dashboard card surfaces insight titles with severity + type
  badges, occurrence/user counts, confidence, and a link to the
  Gitea issue if one was opened.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Semih
2026-05-18 12:31:27 +03:00
parent 4694c1ffdb
commit 717e4ae369
5 changed files with 912 additions and 27 deletions

View File

@@ -0,0 +1,310 @@
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 {
ALL_RANGES,
ERROR_BUCKET_KEYS,
type ErrorBucketKey,
type TimeRange,
getErrorBucketDetail,
} 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";
}
function isBucketKey(v: string): v is ErrorBucketKey {
return (ERROR_BUCKET_KEYS as readonly string[]).includes(v);
}
export default async function ErrorBucketPage({
params,
searchParams,
}: {
params: Promise<{ key: string }>;
searchParams: Promise<{ range?: string }>;
}) {
const { key } = await params;
if (!isBucketKey(key)) notFound();
const sp = await searchParams;
const range: TimeRange = isRange(sp.range) ? sp.range : "24h";
const detail = await getErrorBucketDetail(key, range);
const seriesMax = Math.max(1, ...detail.hourlySeries.map((s) => s.count));
const brandsMax = Math.max(1, ...detail.topBrands.map((b) => b.count));
return (
<PanelShell title={`Sase · VIN errors · ${key}`}>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Link href={`/projects/sase/vin-decode?range=${range}`} 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">
{key}
</Badge>
error bucket
</h2>
<p className="text-sm text-muted-foreground">
{RANGE_LABEL[range]} · {detail.total.toLocaleString("tr-TR")} fail ·{" "}
{detail.uniqueUsers.toLocaleString("tr-TR")} kullanıcı
</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>
<Card>
<CardHeader>
<CardDescription>Saatlik dağılım</CardDescription>
</CardHeader>
<CardContent>
{detail.hourlySeries.length === 0 ? (
<p className="text-sm text-muted-foreground">Bu pencerede hata yok.</p>
) : (
<>
<div className="flex h-20 items-end gap-px">
{detail.hourlySeries.map((s) => (
<div
key={s.hour}
title={`${s.hour}: ${s.count}`}
style={{ height: `${(s.count / seriesMax) * 100}%` }}
className="flex-1 min-w-[3px] bg-destructive/70 hover:bg-destructive"
/>
))}
</div>
<div className="flex justify-between pt-1 text-xs text-muted-foreground">
<span>{detail.hourlySeries[0]?.hour.slice(0, 16).replace("T", " ")}</span>
<span>maks {seriesMax}/saat</span>
<span>
{detail.hourlySeries[detail.hourlySeries.length - 1]?.hour
.slice(0, 16)
.replace("T", " ")}
</span>
</div>
</>
)}
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<Card>
<CardHeader>
<CardDescription>Etkilenen markalar (top 10)</CardDescription>
</CardHeader>
<CardContent>
{detail.topBrands.length === 0 ? (
<p className="text-sm text-muted-foreground">Marka eşleşmiş hata yok.</p>
) : (
<ul className="space-y-1 text-sm">
{detail.topBrands.map((b) => (
<li
key={b.brandId ?? "none"}
className="flex items-center gap-2"
>
<span className="w-20 truncate font-mono text-xs">
{b.brandSlug ?? "—"}
</span>
<span className="flex-1 truncate text-xs text-muted-foreground">
{b.brandName ?? "(brand-id yok)"}
</span>
<div className="h-2 w-24 overflow-hidden rounded bg-muted">
<div
className="h-full bg-destructive"
style={{ width: `${(b.count / brandsMax) * 100}%` }}
/>
</div>
<span className="w-12 text-right tabular-nums text-xs">
{b.count}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardDescription>Provider'lar (kazanan + denenen)</CardDescription>
</CardHeader>
<CardContent>
{detail.topProviders.length === 0 ? (
<p className="text-sm text-muted-foreground">Provider bilgisi yok.</p>
) : (
<ul className="space-y-1 text-sm">
{detail.topProviders.map((p) => (
<li key={p.provider} className="flex items-center justify-between">
<Badge variant="outline" className="font-mono">
{p.provider}
</Badge>
<span className="tabular-nums">{p.count.toLocaleString("tr-TR")}</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardDescription>Etkilenen kullanıcılar (top 20)</CardDescription>
</CardHeader>
<CardContent>
{detail.affectedUsers.length === 0 ? (
<p className="text-sm text-muted-foreground">Kullanıcı yok.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>İsim</TableHead>
<TableHead className="text-right">Fail</TableHead>
<TableHead>Son</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detail.affectedUsers.map((u) => (
<TableRow key={u.userId}>
<TableCell className="font-mono text-xs">
<Link
href={`/projects/sase/users/${u.userId}`}
className="hover:underline"
>
{u.email ?? u.userId.slice(0, 8)}
</Link>
</TableCell>
<TableCell className="text-sm">{u.name ?? "—"}</TableCell>
<TableCell className="text-right tabular-nums">
{u.failureCount}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{u.lastFailureAt.toISOString().slice(0, 16).replace("T", " ")}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardDescription>Örnek hata mesajları</CardDescription>
</CardHeader>
<CardContent>
{detail.sampleMessages.length === 0 ? (
<p className="text-sm text-muted-foreground">Mesaj yok.</p>
) : (
<ul className="space-y-1 text-sm">
{detail.sampleMessages.map((m) => (
<li key={m} className="rounded-md border bg-muted/30 p-2 font-mono text-xs">
{m}
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardDescription>
Son {detail.recent.length} fail
</CardDescription>
</CardHeader>
<CardContent>
{detail.recent.length === 0 ? (
<p className="text-sm text-muted-foreground">Yok.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Tarih</TableHead>
<TableHead>Kullanıcı</TableHead>
<TableHead>VIN</TableHead>
<TableHead>Source</TableHead>
<TableHead className="text-right">RT</TableHead>
<TableHead>Mesaj</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detail.recent.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-mono text-xs">
{r.createdAt.toISOString().slice(5, 16).replace("T", " ")}
</TableCell>
<TableCell className="font-mono text-xs">
<Link
href={`/projects/sase/users/${r.userId}`}
className="hover:underline"
>
{r.userId.slice(0, 8)}
</Link>
</TableCell>
<TableCell className="font-mono text-xs">{r.vinSanitized}</TableCell>
<TableCell className="font-mono text-xs">{r.source ?? "—"}</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{r.responseTimeMs ? `${r.responseTimeMs}ms` : "—"}
</TableCell>
<TableCell
className="max-w-md truncate text-xs text-muted-foreground"
title={r.errorMessage}
>
{r.errorMessage}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</PanelShell>
);
}

View File

@@ -28,6 +28,11 @@ import {
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";
@@ -50,15 +55,20 @@ export default async function VinDecodePage({
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 [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));
@@ -224,22 +234,23 @@ export default async function VinDecodePage({
) : (
<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 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>
@@ -298,6 +309,146 @@ export default async function VinDecodePage({
</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>