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,
|
getDailyTrend,
|
||||||
getBrandBreakdown,
|
getBrandBreakdown,
|
||||||
} from "@/lib/sase/vin-decode";
|
} 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";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -50,15 +55,20 @@ export default async function VinDecodePage({
|
|||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const range: TimeRange = isRange(sp.range) ? sp.range : "24h";
|
const range: TimeRange = isRange(sp.range) ? sp.range : "24h";
|
||||||
|
|
||||||
const [health, winning, attempts, chains, errors, trend, brands] = await Promise.all([
|
const [health, winning, attempts, chains, errors, trend, brands, deploys, insights] =
|
||||||
getOperationalHealth(range),
|
await Promise.all([
|
||||||
getWinningProviders(range),
|
getOperationalHealth(range),
|
||||||
getProviderAttempts(range),
|
getWinningProviders(range),
|
||||||
getFallbackChains(range),
|
getProviderAttempts(range),
|
||||||
getErrorBreakdown(range),
|
getFallbackChains(range),
|
||||||
getDailyTrend(30),
|
getErrorBreakdown(range),
|
||||||
getBrandBreakdown(range, 12),
|
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));
|
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">
|
<ul className="space-y-2 text-sm">
|
||||||
{errors.map((e) => (
|
{errors.map((e) => (
|
||||||
<li
|
<li key={e.errorKey} title={e.exampleMessage}>
|
||||||
key={e.errorKey}
|
<Link
|
||||||
className="rounded-md border p-2"
|
href={`/projects/sase/vin-decode/errors/${e.errorKey}?range=${range}`}
|
||||||
title={e.exampleMessage}
|
className="block rounded-md border p-2 hover:bg-muted/30"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Badge variant="outline" className="font-mono">
|
<Badge variant="outline" className="font-mono">
|
||||||
{e.errorKey}
|
{e.errorKey}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="tabular-nums">
|
<span className="tabular-nums">
|
||||||
{e.count.toLocaleString("tr-TR")}
|
{e.count.toLocaleString("tr-TR")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||||
{e.exampleMessage}
|
{e.exampleMessage}
|
||||||
</p>
|
</p>
|
||||||
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -298,6 +309,146 @@ export default async function VinDecodePage({
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardDescription>Son 30 gün — günlük sorgu hacmi</CardDescription>
|
<CardDescription>Son 30 gün — günlük sorgu hacmi</CardDescription>
|
||||||
|
|||||||
126
apps/web/src/lib/sase/deploy-timeline.ts
Normal file
126
apps/web/src/lib/sase/deploy-timeline.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { saseDb } from "@/lib/db-sase";
|
||||||
|
|
||||||
|
// Sase.tr's Coolify app UUID (sparkling-snake-...). Hard-coded to scope this
|
||||||
|
// helper; if we ever multi-project, lift to env per-spoke.
|
||||||
|
const SASE_APP_UUID = "ro48g8ooo0gk4kskog0oo8s8";
|
||||||
|
const COOLIFY_BASE = process.env.COOLIFY_API_BASE ?? "https://cool.semih.ai/api/v1";
|
||||||
|
const COOLIFY_TOKEN = process.env.COOLIFY_API_TOKEN ?? "";
|
||||||
|
|
||||||
|
export type SaseDeploy = {
|
||||||
|
deploymentUuid: string;
|
||||||
|
status: string;
|
||||||
|
commit: string | null;
|
||||||
|
startedAt: Date;
|
||||||
|
finishedAt: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CoolifyDeployment = {
|
||||||
|
deployment_uuid: string;
|
||||||
|
status?: string;
|
||||||
|
commit?: string | null;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listSaseDeploys(limit = 10): Promise<SaseDeploy[]> {
|
||||||
|
if (!COOLIFY_TOKEN) return [];
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${COOLIFY_BASE}/deployments/applications/${SASE_APP_UUID}`, {
|
||||||
|
headers: { Authorization: `Bearer ${COOLIFY_TOKEN}` },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = (await res.json()) as
|
||||||
|
| CoolifyDeployment[]
|
||||||
|
| { deployments: CoolifyDeployment[] };
|
||||||
|
const list = Array.isArray(data) ? data : (data.deployments ?? []);
|
||||||
|
|
||||||
|
const finished = list.filter((d) => d.status === "finished" && d.created_at);
|
||||||
|
return finished
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((d) => ({
|
||||||
|
deploymentUuid: d.deployment_uuid,
|
||||||
|
status: d.status ?? "?",
|
||||||
|
commit: d.commit ?? null,
|
||||||
|
startedAt: new Date(d.created_at!),
|
||||||
|
finishedAt: d.updated_at ? new Date(d.updated_at) : null,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.startedAt.getTime() - a.startedAt.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeployRegressionRow = {
|
||||||
|
deploy: SaseDeploy;
|
||||||
|
before: { total: number; successRate: number; p95: number | null };
|
||||||
|
after: { total: number; successRate: number; p95: number | null };
|
||||||
|
successRateDeltaPp: number; // negative = regression
|
||||||
|
regressed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WINDOW_MIN = 30;
|
||||||
|
|
||||||
|
async function windowSlice(start: Date, end: Date) {
|
||||||
|
const rows = await saseDb.$queryRaw<
|
||||||
|
Array<{
|
||||||
|
total: bigint;
|
||||||
|
succeeded: bigint;
|
||||||
|
p95: number | null;
|
||||||
|
}>
|
||||||
|
>`
|
||||||
|
SELECT
|
||||||
|
count(*) AS total,
|
||||||
|
count(*) FILTER (WHERE success = true) AS succeeded,
|
||||||
|
percentile_cont(0.95) WITHIN GROUP (ORDER BY response_time_ms)
|
||||||
|
FILTER (WHERE success = true AND response_time_ms IS NOT NULL)::int AS p95
|
||||||
|
FROM query_logs
|
||||||
|
WHERE created_at >= ${start} AND created_at < ${end}
|
||||||
|
`;
|
||||||
|
const r = rows[0] ?? { total: 0n, succeeded: 0n, p95: null };
|
||||||
|
const total = Number(r.total);
|
||||||
|
const succeeded = Number(r.succeeded);
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
successRate: total > 0 ? succeeded / total : 0,
|
||||||
|
p95: r.p95,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function analyzeDeployRegressions(
|
||||||
|
deploys: SaseDeploy[],
|
||||||
|
regressionDropPp = 10,
|
||||||
|
): Promise<DeployRegressionRow[]> {
|
||||||
|
const out: DeployRegressionRow[] = [];
|
||||||
|
// The "before" window stops at the deploy's start; "after" begins where the
|
||||||
|
// deploy finished (or 30s after start if finished missing) so the container
|
||||||
|
// restart isn't counted as the post-deploy state.
|
||||||
|
for (const d of deploys) {
|
||||||
|
const finishedAt = d.finishedAt ?? new Date(d.startedAt.getTime() + 30_000);
|
||||||
|
const beforeEnd = d.startedAt;
|
||||||
|
const beforeStart = new Date(beforeEnd.getTime() - WINDOW_MIN * 60_000);
|
||||||
|
const afterStart = finishedAt;
|
||||||
|
const afterEnd = new Date(afterStart.getTime() + WINDOW_MIN * 60_000);
|
||||||
|
|
||||||
|
if (afterEnd > new Date()) {
|
||||||
|
// post-window hasn't elapsed yet; skip — too early to judge
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [before, after] = await Promise.all([
|
||||||
|
windowSlice(beforeStart, beforeEnd),
|
||||||
|
windowSlice(afterStart, afterEnd),
|
||||||
|
]);
|
||||||
|
const deltaPp = (after.successRate - before.successRate) * 100;
|
||||||
|
out.push({
|
||||||
|
deploy: d,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
successRateDeltaPp: deltaPp,
|
||||||
|
regressed:
|
||||||
|
before.total >= 5 &&
|
||||||
|
after.total >= 5 &&
|
||||||
|
deltaPp <= -regressionDropPp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -249,7 +249,20 @@ export type ErrorBucketRow = {
|
|||||||
exampleMessage: string;
|
exampleMessage: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ERROR_PATTERNS: Array<{ key: string; matchers: RegExp[] }> = [
|
export const ERROR_BUCKET_KEYS = [
|
||||||
|
"BUDGET_EXCEEDED",
|
||||||
|
"UNKNOWN_VIN",
|
||||||
|
"TIMEOUT",
|
||||||
|
"INVALID_VIN",
|
||||||
|
"FORBIDDEN",
|
||||||
|
"RATE_LIMITED",
|
||||||
|
"PROVIDER_ERROR",
|
||||||
|
"OTHER",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ErrorBucketKey = (typeof ERROR_BUCKET_KEYS)[number];
|
||||||
|
|
||||||
|
const ERROR_PATTERNS: Array<{ key: ErrorBucketKey; matchers: RegExp[] }> = [
|
||||||
{ key: "BUDGET_EXCEEDED", matchers: [/budget/i, /aborted/i] },
|
{ key: "BUDGET_EXCEEDED", matchers: [/budget/i, /aborted/i] },
|
||||||
{ key: "UNKNOWN_VIN", matchers: [/unknown vin/i, /tanınamadı/i, /destekl/i] },
|
{ key: "UNKNOWN_VIN", matchers: [/unknown vin/i, /tanınamadı/i, /destekl/i] },
|
||||||
{ key: "TIMEOUT", matchers: [/timeout/i, /timed out/i] },
|
{ key: "TIMEOUT", matchers: [/timeout/i, /timed out/i] },
|
||||||
@@ -259,7 +272,18 @@ const ERROR_PATTERNS: Array<{ key: string; matchers: RegExp[] }> = [
|
|||||||
{ key: "PROVIDER_ERROR", matchers: [/pl24/i, /emex/i, /partscatalogs/i, /pcat/i] },
|
{ key: "PROVIDER_ERROR", matchers: [/pl24/i, /emex/i, /partscatalogs/i, /pcat/i] },
|
||||||
];
|
];
|
||||||
|
|
||||||
function bucketError(msg: string | null): string {
|
// Build a SQL `WHERE` fragment that matches an error bucket. Returns a tagged
|
||||||
|
// template part for $queryRaw. Each bucket is encoded as a single regex so
|
||||||
|
// Postgres can apply it once over error_message.
|
||||||
|
function bucketSqlMatcher(key: ErrorBucketKey): { pattern: string; isOther: boolean } {
|
||||||
|
const found = ERROR_PATTERNS.find((p) => p.key === key);
|
||||||
|
if (!found) return { pattern: "", isOther: true };
|
||||||
|
// join individual patterns with | for a single ILIKE regex
|
||||||
|
const sources = found.matchers.map((m) => m.source);
|
||||||
|
return { pattern: sources.join("|"), isOther: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function bucketError(msg: string | null): ErrorBucketKey {
|
||||||
if (!msg) return "OTHER";
|
if (!msg) return "OTHER";
|
||||||
for (const { key, matchers } of ERROR_PATTERNS) {
|
for (const { key, matchers } of ERROR_PATTERNS) {
|
||||||
if (matchers.some((m) => m.test(msg))) return key;
|
if (matchers.some((m) => m.test(msg))) return key;
|
||||||
@@ -267,6 +291,195 @@ function bucketError(msg: string | null): string {
|
|||||||
return "OTHER";
|
return "OTHER";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ErrorBucketDetail = {
|
||||||
|
key: ErrorBucketKey;
|
||||||
|
range: TimeRange;
|
||||||
|
total: number;
|
||||||
|
uniqueUsers: number;
|
||||||
|
hourlySeries: Array<{ hour: string; count: number }>;
|
||||||
|
topBrands: Array<{ brandId: string | null; brandSlug: string | null; brandName: string | null; count: number }>;
|
||||||
|
topProviders: Array<{ provider: string; count: number }>;
|
||||||
|
affectedUsers: Array<{
|
||||||
|
userId: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string | null;
|
||||||
|
failureCount: number;
|
||||||
|
lastFailureAt: Date;
|
||||||
|
}>;
|
||||||
|
recent: Array<{
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
vinSanitized: string;
|
||||||
|
brandId: string | null;
|
||||||
|
source: string | null;
|
||||||
|
errorMessage: string;
|
||||||
|
responseTimeMs: number | null;
|
||||||
|
timings: Record<string, unknown> | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}>;
|
||||||
|
sampleMessages: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function sanitizeVin(vin: string): string {
|
||||||
|
const upper = vin.toUpperCase();
|
||||||
|
return upper.length >= 11 ? `${upper.slice(0, 11)}******` : `${upper.slice(0, 3)}***`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getErrorBucketDetail(
|
||||||
|
key: ErrorBucketKey,
|
||||||
|
range: TimeRange,
|
||||||
|
recentLimit = 30,
|
||||||
|
): Promise<ErrorBucketDetail> {
|
||||||
|
const start = rangeStart(range);
|
||||||
|
|
||||||
|
const failed = await saseDb.queryLog.findMany({
|
||||||
|
where: { createdAt: { gte: start }, success: false },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
vin: true,
|
||||||
|
brandId: true,
|
||||||
|
source: true,
|
||||||
|
errorMessage: true,
|
||||||
|
responseTimeMs: true,
|
||||||
|
timings: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const matching = failed.filter((f) => bucketError(f.errorMessage) === key);
|
||||||
|
|
||||||
|
// Hourly time series
|
||||||
|
const hourlyMap = new Map<string, number>();
|
||||||
|
for (const m of matching) {
|
||||||
|
const hourKey = new Date(
|
||||||
|
Math.floor(m.createdAt.getTime() / (60 * 60_000)) * 60 * 60_000,
|
||||||
|
).toISOString();
|
||||||
|
hourlyMap.set(hourKey, (hourlyMap.get(hourKey) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const hourlySeries = Array.from(hourlyMap.entries())
|
||||||
|
.map(([hour, count]) => ({ hour, count }))
|
||||||
|
.sort((a, b) => a.hour.localeCompare(b.hour));
|
||||||
|
|
||||||
|
// Top brands
|
||||||
|
const brandCounts = new Map<string | null, number>();
|
||||||
|
for (const m of matching) {
|
||||||
|
brandCounts.set(m.brandId, (brandCounts.get(m.brandId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const brandIds = Array.from(brandCounts.keys()).filter(
|
||||||
|
(id): id is string => !!id,
|
||||||
|
);
|
||||||
|
const brandsRows = brandIds.length
|
||||||
|
? await saseDb.brand.findMany({
|
||||||
|
where: { id: { in: brandIds } },
|
||||||
|
select: { id: true, name: true, slug: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const brandMap = new Map(brandsRows.map((b) => [b.id, b]));
|
||||||
|
const topBrands = Array.from(brandCounts.entries())
|
||||||
|
.map(([brandId, count]) => {
|
||||||
|
const b = brandId ? brandMap.get(brandId) : null;
|
||||||
|
return {
|
||||||
|
brandId,
|
||||||
|
brandSlug: b?.slug ?? null,
|
||||||
|
brandName: b?.name ?? null,
|
||||||
|
count,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
// Top providers — from timings keys (chain attempts) + source for the row
|
||||||
|
const providerCounts = new Map<string, number>();
|
||||||
|
for (const m of matching) {
|
||||||
|
if (m.source) providerCounts.set(m.source, (providerCounts.get(m.source) ?? 0) + 1);
|
||||||
|
const t = m.timings as Record<string, unknown> | null;
|
||||||
|
if (t && typeof t === "object") {
|
||||||
|
for (const k of ["pcat", "emex", "pl24", "vin_api"]) {
|
||||||
|
if (typeof t[k] === "number") {
|
||||||
|
providerCounts.set(k, (providerCounts.get(k) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const topProviders = Array.from(providerCounts.entries())
|
||||||
|
.map(([provider, count]) => ({ provider, count }))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
|
// Affected users — aggregate user IDs + recency
|
||||||
|
const userAgg = new Map<string, { count: number; last: Date }>();
|
||||||
|
for (const m of matching) {
|
||||||
|
const cur = userAgg.get(m.userId);
|
||||||
|
if (cur) {
|
||||||
|
cur.count += 1;
|
||||||
|
if (m.createdAt > cur.last) cur.last = m.createdAt;
|
||||||
|
} else {
|
||||||
|
userAgg.set(m.userId, { count: 1, last: m.createdAt });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const userIds = Array.from(userAgg.keys());
|
||||||
|
const userRows = userIds.length
|
||||||
|
? await saseDb.user.findMany({
|
||||||
|
where: { id: { in: userIds } },
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const userMap = new Map(userRows.map((u) => [u.id, u]));
|
||||||
|
const affectedUsers = Array.from(userAgg.entries())
|
||||||
|
.map(([userId, { count, last }]) => {
|
||||||
|
const u = userMap.get(userId);
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
name: u?.name ?? null,
|
||||||
|
email: u?.email ?? null,
|
||||||
|
failureCount: count,
|
||||||
|
lastFailureAt: last,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.failureCount - a.failureCount)
|
||||||
|
.slice(0, 20);
|
||||||
|
|
||||||
|
// Recent failures
|
||||||
|
const recent = matching.slice(0, recentLimit).map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
userId: m.userId,
|
||||||
|
vinSanitized: sanitizeVin(m.vin),
|
||||||
|
brandId: m.brandId,
|
||||||
|
source: m.source,
|
||||||
|
errorMessage: m.errorMessage ?? "",
|
||||||
|
responseTimeMs: m.responseTimeMs,
|
||||||
|
timings: m.timings as Record<string, unknown> | null,
|
||||||
|
createdAt: m.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Sample distinct error messages
|
||||||
|
const seenMsgs = new Set<string>();
|
||||||
|
const sampleMessages: string[] = [];
|
||||||
|
for (const m of matching) {
|
||||||
|
const msg = m.errorMessage?.slice(0, 240) ?? "";
|
||||||
|
if (msg && !seenMsgs.has(msg)) {
|
||||||
|
seenMsgs.add(msg);
|
||||||
|
sampleMessages.push(msg);
|
||||||
|
if (sampleMessages.length >= 6) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
range,
|
||||||
|
total: matching.length,
|
||||||
|
uniqueUsers: userAgg.size,
|
||||||
|
hourlySeries,
|
||||||
|
topBrands,
|
||||||
|
topProviders,
|
||||||
|
affectedUsers,
|
||||||
|
recent,
|
||||||
|
sampleMessages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function getErrorBreakdown(range: TimeRange): Promise<ErrorBucketRow[]> {
|
export async function getErrorBreakdown(range: TimeRange): Promise<ErrorBucketRow[]> {
|
||||||
const start = rangeStart(range);
|
const start = rangeStart(range);
|
||||||
const errors = await saseDb.queryLog.findMany({
|
const errors = await saseDb.queryLog.findMany({
|
||||||
|
|||||||
85
apps/web/src/lib/sase/vin-insights.ts
Normal file
85
apps/web/src/lib/sase/vin-insights.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
// Surfaces BIP (Behavioral Insight Pipeline) insights that are relevant to
|
||||||
|
// the VIN decode flow on the VIN Observability dashboard. The BIP runs in
|
||||||
|
// the worker; we just read its emitted `insights` rows here.
|
||||||
|
//
|
||||||
|
// Heuristic: insights produced by the `provider_quality` prompt tag are the
|
||||||
|
// VIN-focused ones (session-tag rules route vin_decode_fail_pattern,
|
||||||
|
// provider_reliability_issue, vin_decode_repeated_failure to that template).
|
||||||
|
//
|
||||||
|
// We also pull bug_triage insights whose body mentions VIN/decode/provider,
|
||||||
|
// as a wider catch — those are the ones the dashboard cares about that
|
||||||
|
// happen not to hit the provider_quality routing.
|
||||||
|
|
||||||
|
export type VinInsight = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
severity: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
fingerprint: string;
|
||||||
|
occurrenceCount: number;
|
||||||
|
uniqueUserCount: number;
|
||||||
|
lastSeenAt: Date;
|
||||||
|
confidence: number | null;
|
||||||
|
giteaIssueUrl: string | null;
|
||||||
|
giteaIssueNumber: number | null;
|
||||||
|
giteaIssueState: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VIN_KEYWORDS = ["vin", "decode", "provider", "pl24", "emex", "pcat", "parts-catalogs"];
|
||||||
|
|
||||||
|
export async function getVinRelatedInsights(limit = 8): Promise<VinInsight[]> {
|
||||||
|
// Cheap path: type='provider_quality' is always VIN-related by design.
|
||||||
|
const direct = await prisma.insight.findMany({
|
||||||
|
where: {
|
||||||
|
projectKey: "sase",
|
||||||
|
type: "provider_quality",
|
||||||
|
status: { in: ["new", "in_backlog", "investigating", "shipped"] },
|
||||||
|
},
|
||||||
|
orderBy: [{ severity: "asc" }, { lastSeenAt: "desc" }],
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Widen with bug_triage insights mentioning VIN keywords. Body is jsonb;
|
||||||
|
// for a cheap MVP scan we pull the most recent N rows and filter in code.
|
||||||
|
const recent = await prisma.insight.findMany({
|
||||||
|
where: {
|
||||||
|
projectKey: "sase",
|
||||||
|
type: "bug_triage",
|
||||||
|
status: { in: ["new", "in_backlog", "investigating", "shipped"] },
|
||||||
|
},
|
||||||
|
orderBy: { lastSeenAt: "desc" },
|
||||||
|
take: 30,
|
||||||
|
});
|
||||||
|
const widened = recent.filter((r) => {
|
||||||
|
const blob = JSON.stringify(r.body).toLowerCase();
|
||||||
|
return VIN_KEYWORDS.some((k) => blob.includes(k));
|
||||||
|
});
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const merged: typeof direct = [];
|
||||||
|
for (const i of [...direct, ...widened]) {
|
||||||
|
if (seen.has(i.id)) continue;
|
||||||
|
seen.add(i.id);
|
||||||
|
merged.push(i);
|
||||||
|
if (merged.length >= limit) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged.map((i) => ({
|
||||||
|
id: i.id,
|
||||||
|
title: i.title,
|
||||||
|
severity: i.severity,
|
||||||
|
type: i.type,
|
||||||
|
status: i.status,
|
||||||
|
fingerprint: i.fingerprint,
|
||||||
|
occurrenceCount: i.occurrenceCount,
|
||||||
|
uniqueUserCount: i.uniqueUserCount,
|
||||||
|
lastSeenAt: i.lastSeenAt,
|
||||||
|
confidence: i.confidence,
|
||||||
|
giteaIssueUrl: i.githubIssueUrl,
|
||||||
|
giteaIssueNumber: i.githubIssueNumber,
|
||||||
|
giteaIssueState: i.githubIssueState,
|
||||||
|
}));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user