feat(vin-decode): VIN row detail modal with timings + behaviour
Click a VIN in the list to open a modal with the full decode internals: - Proxy times per provider (pcat/emex/pl24/vin-api) from query_logs.timings - Race/lock status (lock_wait ms + cache_source='lock_wait' → "aynı VIN paralelde çözülüyordu"), candidate_pick, pl24 circuit, aborted - Candidate counts (pcat_car_count, emex_candidate_count), result_kind, wmi - User behaviour for that vehicle, correlated from PostHog custom events in the panel DB by user_id + vehicle_id: categories opened (parts_panel_viewed) and OEM copied (oem_code_copied + distinct codes) New: getVinDecodeDetail/getVinUserBehavior (cross-DB: sase RO + panel), GET /api/sase/query-log/[id] (auth-guarded), client VinDetailButton modal. Both queries verified against prod data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
36
apps/web/src/app/api/sase/query-log/[id]/route.ts
Normal file
36
apps/web/src/app/api/sase/query-log/[id]/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getVinDecodeDetail, getVinUserBehavior } from "@/lib/sase/vin-detail";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, error: "unauthenticated" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await ctx.params;
|
||||
if (!UUID_RE.test(id)) {
|
||||
return NextResponse.json({ ok: false, error: "bad_id" }, { status: 400 });
|
||||
}
|
||||
|
||||
const detail = await getVinDecodeDetail(id);
|
||||
if (!detail) {
|
||||
return NextResponse.json({ ok: false, error: "not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Behaviour is best-effort: if the panel DB query fails, still return the
|
||||
// decode detail rather than 500 the whole modal.
|
||||
let behavior = null;
|
||||
try {
|
||||
behavior = await getVinUserBehavior(detail.userId, detail.vehicleId);
|
||||
} catch {
|
||||
behavior = null;
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, detail, behavior });
|
||||
}
|
||||
307
apps/web/src/app/projects/sase/vin-decode/vins/_detail-modal.tsx
Normal file
307
apps/web/src/app/projects/sase/vin-decode/vins/_detail-modal.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
type Timings = {
|
||||
pcat?: number;
|
||||
emex?: number;
|
||||
pl24?: number;
|
||||
vin_api?: number;
|
||||
lock_wait?: number;
|
||||
cache_source?: string;
|
||||
cache_neg_hit?: boolean;
|
||||
pcat_car_count?: number;
|
||||
emex_candidate_count?: number;
|
||||
candidate_pick?: string;
|
||||
pl24_circuit_open?: boolean;
|
||||
aborted?: boolean;
|
||||
result_kind?: string;
|
||||
wmi?: string;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
|
||||
type Detail = {
|
||||
id: string;
|
||||
vin: string;
|
||||
userId: string;
|
||||
userEmail: string | null;
|
||||
userName: string | null;
|
||||
brandSlug: string | null;
|
||||
brandName: string | null;
|
||||
source: string | null;
|
||||
success: boolean;
|
||||
responseTimeMs: number | null;
|
||||
errorMessage: string | null;
|
||||
createdAt: string;
|
||||
vehicleId: string | null;
|
||||
timings: Timings | null;
|
||||
};
|
||||
|
||||
type Behavior = {
|
||||
oemCopiedCount: number;
|
||||
oemCodes: string[];
|
||||
categoriesOpened: number;
|
||||
partsPanelViews: number;
|
||||
firstAt: string | null;
|
||||
lastAt: string | null;
|
||||
} | null;
|
||||
|
||||
type ApiResponse =
|
||||
| { ok: true; detail: Detail; behavior: Behavior }
|
||||
| { ok: false; error: string };
|
||||
|
||||
function ms(v: number | null | undefined): string {
|
||||
if (v == null) return "—";
|
||||
if (v >= 1000) return `${(v / 1000).toFixed(2)}s`;
|
||||
return `${v}ms`;
|
||||
}
|
||||
|
||||
const PROVIDER_KEYS: Array<[keyof Timings, string]> = [
|
||||
["pcat", "parts-catalogs"],
|
||||
["emex", "emex"],
|
||||
["pl24", "pl24"],
|
||||
["vin_api", "vin-api"],
|
||||
];
|
||||
|
||||
export function VinDetailButton({ id, vin }: { id: string; vin: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<Detail | null>(null);
|
||||
const [behavior, setBehavior] = useState<Behavior>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || detail || loading) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetch(`/api/sase/query-log/${id}`, { cache: "no-store" })
|
||||
.then(async (r) => (await r.json()) as ApiResponse)
|
||||
.then((data) => {
|
||||
if (data.ok) {
|
||||
setDetail(data.detail);
|
||||
setBehavior(data.behavior);
|
||||
} else {
|
||||
setError(data.error);
|
||||
}
|
||||
})
|
||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [open, id, detail, loading]);
|
||||
|
||||
const t = detail?.timings ?? null;
|
||||
const proxies = t ? PROVIDER_KEYS.filter(([k]) => typeof t[k] === "number") : [];
|
||||
const raced = t ? (typeof t.lock_wait === "number" || t.cache_source === "lock_wait") : false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="cursor-pointer font-mono text-xs hover:underline"
|
||||
title={`${vin} — detay`}
|
||||
>
|
||||
{vin}
|
||||
</button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-mono text-base">{vin}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{detail
|
||||
? new Date(detail.createdAt).toISOString().slice(0, 19).replace("T", " ")
|
||||
: "Decode detayı"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading && <p className="text-sm text-muted-foreground">Yükleniyor…</p>}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">Hata: {error}</p>
|
||||
)}
|
||||
|
||||
{detail && (
|
||||
<div className="space-y-4 text-sm">
|
||||
{/* Decode özet */}
|
||||
<Section title="Decode">
|
||||
<Row label="Durum">
|
||||
{detail.success ? (
|
||||
<Badge variant="default">ok</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">fail</Badge>
|
||||
)}
|
||||
</Row>
|
||||
<Row label="Kazanan provider">
|
||||
{detail.source ? (
|
||||
<Badge variant="outline" className="font-mono">{detail.source}</Badge>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Row>
|
||||
<Row label="Toplam yanıt">{ms(detail.responseTimeMs)}</Row>
|
||||
<Row label="Marka">
|
||||
{detail.brandSlug ? (
|
||||
<span className="font-mono text-xs">
|
||||
{detail.brandSlug}
|
||||
{detail.brandName && (
|
||||
<span className="ml-1 text-muted-foreground">{detail.brandName}</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Row>
|
||||
<Row label="Kullanıcı">
|
||||
<span className="font-mono text-xs">{detail.userEmail ?? detail.userId.slice(0, 8)}</span>
|
||||
</Row>
|
||||
{detail.errorMessage && (
|
||||
<Row label="Hata mesajı">
|
||||
<span className="text-xs text-destructive">{detail.errorMessage}</span>
|
||||
</Row>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Proxy süreleri */}
|
||||
<Section title="Proxy süreleri">
|
||||
{proxies.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Provider denemesi yok (cache'ten döndü).
|
||||
</p>
|
||||
) : (
|
||||
proxies.map(([k, label]) => (
|
||||
<Row key={String(k)} label={label}>
|
||||
<span className="tabular-nums">{ms(t?.[k] as number)}</span>
|
||||
</Row>
|
||||
))
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Race / kilit */}
|
||||
<Section title="Race / kilit durumu">
|
||||
{raced ? (
|
||||
<Row label="Eşzamanlı istek">
|
||||
<span className="text-yellow-600">
|
||||
Aynı VIN paralelde çözülüyordu — {ms(t?.lock_wait)} kilit beklendi
|
||||
</span>
|
||||
</Row>
|
||||
) : (
|
||||
<Row label="Eşzamanlı istek">
|
||||
<span className="text-muted-foreground">Race yok (kilit beklenmedi)</span>
|
||||
</Row>
|
||||
)}
|
||||
<Row label="cache_source">
|
||||
<span className="font-mono text-xs">{t?.cache_source ?? "—"}</span>
|
||||
</Row>
|
||||
<Row label="candidate_pick">
|
||||
<span className="font-mono text-xs">{t?.candidate_pick ?? "—"}</span>
|
||||
</Row>
|
||||
{t?.pl24_circuit_open && (
|
||||
<Row label="PL24 circuit">
|
||||
<Badge variant="destructive">açık (circuit open)</Badge>
|
||||
</Row>
|
||||
)}
|
||||
{t?.aborted && (
|
||||
<Row label="Abort">
|
||||
<Badge variant="destructive">bütçe/timeout ile iptal</Badge>
|
||||
</Row>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Aday / sonuç */}
|
||||
<Section title="Aday & sonuç">
|
||||
<Row label="result_kind">
|
||||
<span className="font-mono text-xs">{t?.result_kind ?? "—"}</span>
|
||||
</Row>
|
||||
<Row label="PCAT araç adayı">
|
||||
<span className="tabular-nums">
|
||||
{typeof t?.pcat_car_count === "number" ? t.pcat_car_count : "—"}
|
||||
</span>
|
||||
</Row>
|
||||
<Row label="EMEX adayı">
|
||||
<span className="tabular-nums">
|
||||
{typeof t?.emex_candidate_count === "number" ? t.emex_candidate_count : "—"}
|
||||
</span>
|
||||
</Row>
|
||||
<Row label="WMI">
|
||||
<span className="font-mono text-xs">{t?.wmi ?? "—"}</span>
|
||||
</Row>
|
||||
{t?.cache_neg_hit && (
|
||||
<Row label="Negatif cache">
|
||||
<Badge variant="outline">negatif cache hit</Badge>
|
||||
</Row>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Kullanıcı davranışı (bu araç) */}
|
||||
<Section title="Kullanıcı davranışı (bu araç)">
|
||||
{!detail.vehicleId ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Decode bir araç kaydı üretmedi — kategori/OEM davranışı yok.
|
||||
</p>
|
||||
) : behavior == null ? (
|
||||
<p className="text-xs text-muted-foreground">Davranış verisi alınamadı.</p>
|
||||
) : (
|
||||
<>
|
||||
<Row label="Kategori açıldı">
|
||||
<span className="tabular-nums">{behavior.categoriesOpened}</span>
|
||||
{behavior.partsPanelViews > 0 && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
({behavior.partsPanelViews} görüntüleme)
|
||||
</span>
|
||||
)}
|
||||
</Row>
|
||||
<Row label="OEM kopyalandı">
|
||||
{behavior.oemCopiedCount > 0 ? (
|
||||
<span className="text-emerald-600">
|
||||
Evet · {behavior.oemCopiedCount} kez ({behavior.oemCodes.length} farklı kod)
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Hayır</span>
|
||||
)}
|
||||
</Row>
|
||||
{behavior.oemCodes.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{behavior.oemCodes.map((c) => (
|
||||
<Badge key={c} variant="secondary" className="font-mono text-xs">
|
||||
{c}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-md border p-3">
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{title}
|
||||
</p>
|
||||
<div className="space-y-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-right">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { listVinDecodes } from "@/lib/sase/vin-list";
|
||||
import { saseAdminWired } from "@/lib/admin-sdk/sase";
|
||||
import { VinsFilterBar } from "./_filter-bar";
|
||||
import { VinActions } from "./_actions";
|
||||
import { VinDetailButton } from "./_detail-modal";
|
||||
import { Pager } from "./_pager";
|
||||
import { buildHref, type VinsSearchParams } from "./_query";
|
||||
|
||||
@@ -115,8 +116,8 @@ export default async function VinsListPage({
|
||||
<TableCell className="font-mono text-xs">
|
||||
{r.createdAt.toISOString().slice(5, 16).replace("T", " ")}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs" title={r.vin}>
|
||||
{r.vin}
|
||||
<TableCell className="font-mono text-xs">
|
||||
<VinDetailButton id={r.id} vin={r.vin} />
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{r.brandSlug ? (
|
||||
|
||||
161
apps/web/src/lib/sase/vin-detail.ts
Normal file
161
apps/web/src/lib/sase/vin-detail.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { saseDb } from "@/lib/db-sase";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
// Single-decode drill-down for the VIN list modal. Pulls the full query_logs
|
||||
// row + parsed timings from the Sase RO DB, resolves the vehicle_id (so we can
|
||||
// correlate frontend behaviour), then joins PostHog custom events from the
|
||||
// PANEL DB (cross-database — two clients, not one SQL join).
|
||||
|
||||
export type DecodeTimings = {
|
||||
// provider proxy times (ms) — present only when that provider was attempted
|
||||
pcat?: number;
|
||||
emex?: number;
|
||||
pl24?: number;
|
||||
vin_api?: number;
|
||||
// race / lock: ms waited because another request was decoding the same VIN
|
||||
lock_wait?: number;
|
||||
// 'miss' | 'db_hit' | 'redis_positive' | 'lock_wait' | 'redis_negative' ...
|
||||
cache_source?: string;
|
||||
cache_neg_hit?: boolean;
|
||||
// candidate disambiguation counts returned by each provider
|
||||
pcat_car_count?: number;
|
||||
emex_candidate_count?: number;
|
||||
candidate_pick?: string; // 'none' | 'auto' | 'single' ...
|
||||
pl24_circuit_open?: boolean;
|
||||
aborted?: boolean;
|
||||
result_kind?: string; // 'vehicle' | 'unknown' ...
|
||||
wmi?: string;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
|
||||
export type VinDecodeDetail = {
|
||||
id: string;
|
||||
vin: string;
|
||||
userId: string;
|
||||
userEmail: string | null;
|
||||
userName: string | null;
|
||||
brandSlug: string | null;
|
||||
brandName: string | null;
|
||||
source: string | null;
|
||||
success: boolean;
|
||||
responseTimeMs: number | null;
|
||||
errorMessage: string | null;
|
||||
createdAt: Date;
|
||||
vehicleId: string | null;
|
||||
timings: DecodeTimings | null;
|
||||
};
|
||||
|
||||
export async function getVinDecodeDetail(id: string): Promise<VinDecodeDetail | null> {
|
||||
const rows = await saseDb.$queryRaw<
|
||||
Array<{
|
||||
id: string;
|
||||
vin: string;
|
||||
user_id: string;
|
||||
user_email: string | null;
|
||||
user_name: string | null;
|
||||
brand_slug: string | null;
|
||||
brand_name: string | null;
|
||||
source: string | null;
|
||||
success: boolean;
|
||||
response_time_ms: number | null;
|
||||
error_message: string | null;
|
||||
timings: DecodeTimings | null;
|
||||
created_at: Date;
|
||||
vehicle_id: string | null;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
q.id, q.vin, q.user_id,
|
||||
u.email AS user_email, u.name AS user_name,
|
||||
b.slug AS brand_slug, b.name AS brand_name,
|
||||
q.source, q.success, q.response_time_ms, q.error_message,
|
||||
q.timings, q.created_at,
|
||||
v.id AS vehicle_id
|
||||
FROM query_logs q
|
||||
LEFT JOIN users u ON u.id = q.user_id
|
||||
LEFT JOIN brands b ON b.id = q.brand_id
|
||||
LEFT JOIN vehicles v ON v.vin = q.vin
|
||||
WHERE q.id = ${id}::uuid
|
||||
LIMIT 1
|
||||
`;
|
||||
const r = rows[0];
|
||||
if (!r) return null;
|
||||
return {
|
||||
id: r.id,
|
||||
vin: r.vin,
|
||||
userId: r.user_id,
|
||||
userEmail: r.user_email,
|
||||
userName: r.user_name,
|
||||
brandSlug: r.brand_slug,
|
||||
brandName: r.brand_name,
|
||||
source: r.source,
|
||||
success: r.success,
|
||||
responseTimeMs: r.response_time_ms,
|
||||
errorMessage: r.error_message,
|
||||
createdAt: r.created_at,
|
||||
vehicleId: r.vehicle_id,
|
||||
timings: r.timings,
|
||||
};
|
||||
}
|
||||
|
||||
export type VinUserBehavior = {
|
||||
oemCopiedCount: number;
|
||||
oemCodes: string[];
|
||||
categoriesOpened: number;
|
||||
partsPanelViews: number;
|
||||
firstAt: Date | null;
|
||||
lastAt: Date | null;
|
||||
};
|
||||
|
||||
// Correlate post-decode frontend behaviour for THIS vehicle. PostHog custom
|
||||
// events live in the panel DB (session_custom_events). They carry the Sase
|
||||
// user uuid (distinct_id / $user_id) and the vehicle_id, so we scope to this
|
||||
// user + vehicle. Returns null when the decode produced no vehicle row.
|
||||
export async function getVinUserBehavior(
|
||||
userId: string,
|
||||
vehicleId: string | null,
|
||||
): Promise<VinUserBehavior | null> {
|
||||
if (!vehicleId) return null;
|
||||
|
||||
const [agg] = await prisma.$queryRaw<
|
||||
Array<{
|
||||
oem_copied: bigint;
|
||||
categories_opened: bigint;
|
||||
parts_views: bigint;
|
||||
first_at: Date | null;
|
||||
last_at: Date | null;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
count(*) FILTER (WHERE "eventName" = 'oem_code_copied') AS oem_copied,
|
||||
count(DISTINCT (properties->>'category_id'))
|
||||
FILTER (WHERE "eventName" = 'parts_panel_viewed'
|
||||
AND properties->>'category_id' IS NOT NULL) AS categories_opened,
|
||||
count(*) FILTER (WHERE "eventName" = 'parts_panel_viewed') AS parts_views,
|
||||
min("timestamp") AS first_at,
|
||||
max("timestamp") AS last_at
|
||||
FROM session_custom_events
|
||||
WHERE properties->>'vehicle_id' = ${vehicleId}
|
||||
AND (properties->>'distinct_id' = ${userId} OR properties->>'$user_id' = ${userId})
|
||||
AND "eventName" IN ('oem_code_copied', 'parts_panel_viewed')
|
||||
`;
|
||||
|
||||
const codeRows = await prisma.$queryRaw<Array<{ code: string }>>`
|
||||
SELECT DISTINCT properties->>'oem_code' AS code
|
||||
FROM session_custom_events
|
||||
WHERE "eventName" = 'oem_code_copied'
|
||||
AND properties->>'vehicle_id' = ${vehicleId}
|
||||
AND (properties->>'distinct_id' = ${userId} OR properties->>'$user_id' = ${userId})
|
||||
AND properties->>'oem_code' IS NOT NULL
|
||||
LIMIT 20
|
||||
`;
|
||||
|
||||
return {
|
||||
oemCopiedCount: Number(agg?.oem_copied ?? 0n),
|
||||
oemCodes: codeRows.map((r) => r.code),
|
||||
categoriesOpened: Number(agg?.categories_opened ?? 0n),
|
||||
partsPanelViews: Number(agg?.parts_views ?? 0n),
|
||||
firstAt: agg?.first_at ?? null,
|
||||
lastAt: agg?.last_at ?? null,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user