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:
310
apps/web/src/app/projects/sase/vin-decode/errors/[key]/page.tsx
Normal file
310
apps/web/src/app/projects/sase/vin-decode/errors/[key]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user