feat: add conversion-oriented traffic dashboard
Some checks failed
Deploy to 110 WOOO Server / deploy (push) Failing after 8s

This commit is contained in:
OG T
2026-06-07 16:27:35 +08:00
parent 54608a3376
commit 5e3b8582eb
2 changed files with 362 additions and 11 deletions

View File

@@ -24,7 +24,16 @@ export async function GET(request: NextRequest) {
const minutes = Math.max(parseInt(query.get("minutes") || "1440", 10), 5);
const since = new Date(Date.now() - minutes * 60 * 1000);
const [summaryRows, actorSummaryRows, externalActorRows, totalRows, latestEvents] = await Promise.all([
const [
summaryRows,
actorSummaryRows,
externalActorRows,
totalRows,
latestEvents,
judgeCompleteRows,
capturedPayoutCount,
releasedPayoutCount,
] = await Promise.all([
prisma.auditEvent.groupBy({
by: ["action"],
where: {
@@ -72,6 +81,29 @@ export async function GET(request: NextRequest) {
createdAt: true,
},
}),
prisma.auditEvent.findMany({
where: {
createdAt: { gte: since },
action: "JUDGE_COMPLETE",
},
select: {
metadata: true,
},
}),
prisma.ledgerEntry.count({
where: {
createdAt: { gte: since },
phase: "CAPTURE",
response_status: "SUCCESS",
},
}),
prisma.ledgerEntry.count({
where: {
createdAt: { gte: since },
phase: "RELEASE",
response_status: "SUCCESS",
},
}),
]);
const actionSummary = Object.fromEntries(
@@ -133,6 +165,44 @@ export async function GET(request: NextRequest) {
{ external: 0, internal: 0 } as Record<string, number>
);
const discoveryEvents =
(actionSummary["EXTERNAL_LIST_OPEN_TASKS"] || 0) +
(actionSummary["EXTERNAL_LIST_OPEN_TASKS_MCP"] || 0);
const claimEvents = actionSummary["EXTERNAL_CLAIM_TASK_SUCCESS"] || 0;
const submitEvents = actionSummary["EXTERNAL_SUBMIT_SOLUTION_SUCCESS"] || 0;
const judgePassEvents = judgeCompleteRows.filter((row) => {
const metadata = asRecordJson(row.metadata);
return metadata?.overall_result === "PASS";
}).length;
const judgeFailEvents = judgeCompleteRows.filter((row) => {
const metadata = asRecordJson(row.metadata);
return metadata?.overall_result === "FAIL";
}).length;
const conversionRate = (numerator: number, denominator: number) => {
if (!denominator) return 0;
return Math.round((numerator / denominator) * 1000) / 10;
};
const externalFunnel = {
discovery_events: discoveryEvents,
claim_events: claimEvents,
submit_events: submitEvents,
judge_pass_events: judgePassEvents,
judge_fail_events: judgeFailEvents,
judge_total_events: judgePassEvents + judgeFailEvents,
payout_captured: capturedPayoutCount,
payout_released: releasedPayoutCount,
};
const conversionRates = {
claim_rate: conversionRate(claimEvents, discoveryEvents),
submit_rate: conversionRate(submitEvents, claimEvents),
pass_rate: conversionRate(judgePassEvents, submitEvents),
payout_rate: conversionRate(capturedPayoutCount, judgePassEvents),
};
const externalEventTypes = Object.entries(actionSummary)
.filter(([action]) => action.startsWith("EXTERNAL_"))
.map(([action, count]) => ({ action, count }));
@@ -172,6 +242,8 @@ export async function GET(request: NextRequest) {
channel_summary: channelSummary,
actor_summary: actorSummary,
external_actor_summary: externalActorSummary,
external_funnel: externalFunnel,
conversion_rates: conversionRates,
external_event_types: externalEventTypes,
internal_event_types: internalEventTypes,
recent_external_events: recentExternalEvents,

View File

@@ -6,6 +6,20 @@ export const dynamic = "force-dynamic";
const MONITOR_TOKEN = process.env.TRAFFIC_MONITOR_TOKEN;
const EVENT_LABELS: Record<string, string> = {
EXTERNAL_LIST_OPEN_TASKS: "外部公開流量頁讀取 open tasks",
EXTERNAL_LIST_OPEN_TASKS_SURGE: "外部公開流量突增告警",
EXTERNAL_LIST_OPEN_TASKS_MCP: "外部 MCP 入口讀取 open tasks",
EXTERNAL_LIST_OPEN_TASKS_MCP_SURGE: "外部 MCP 流量突增告警",
EXTERNAL_CLAIM_TASK_SUCCESS: "外部 AI 成功接單",
EXTERNAL_SUBMIT_SOLUTION_SUCCESS: "外部 AI 提交解法",
EXTERNAL_CLAIM_TASK_ERROR: "外部接單失敗",
EXTERNAL_SUBMIT_SOLUTION_ERROR: "外部提交失敗",
EXTERNAL_LIST_OPEN_TASKS_ERROR: "外部公開流量端點錯誤",
EXTERNAL_LIST_OPEN_TASKS_MCP_ERROR: "外部 MCP 流量端點錯誤",
JUDGE_COMPLETE: "AI 交件判定完成",
};
function asRecordJson(value: unknown): Record<string, unknown> | undefined {
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
return value as Record<string, unknown>;
@@ -13,6 +27,15 @@ function asRecordJson(value: unknown): Record<string, unknown> | undefined {
return undefined;
}
function percent(numerator: number, denominator: number) {
if (!denominator) return 0;
return Math.round((numerator / denominator) * 1000) / 10;
}
function fmtPercent(value: number) {
return `${value.toFixed(1)}%`;
}
function isInternalActorId(value: string | null | undefined) {
if (!value) return true;
const actorId = value.toLowerCase();
@@ -43,10 +66,28 @@ function isAuthorizedToken(token: string | undefined, tokenHeader: string | unde
return tokenHeader === token;
}
function eventDirection(action: string) {
if (action.startsWith("EXTERNAL_")) return "外部";
return "內部";
}
function explainAction(action: string) {
return EVENT_LABELS[action] || action;
}
async function getTrafficSummary(minutes: number) {
const since = new Date(Date.now() - minutes * 60 * 1000);
const [summaryRows, actorSummaryRows, externalActorRows, totalRows, latestEvents] = await Promise.all([
const [
summaryRows,
actorSummaryRows,
externalActorRows,
totalRows,
latestEvents,
judgeCompleteRows,
capturedPayoutCount,
releasedPayoutCount,
] = await Promise.all([
prisma.auditEvent.groupBy({
by: ["action"],
where: {
@@ -94,6 +135,29 @@ async function getTrafficSummary(minutes: number) {
createdAt: true,
},
}),
prisma.auditEvent.findMany({
where: {
createdAt: { gte: since },
action: "JUDGE_COMPLETE",
},
select: {
metadata: true,
},
}),
prisma.ledgerEntry.count({
where: {
createdAt: { gte: since },
phase: "CAPTURE",
response_status: "SUCCESS",
},
}),
prisma.ledgerEntry.count({
where: {
createdAt: { gte: since },
phase: "RELEASE",
response_status: "SUCCESS",
},
}),
]);
const actionSummary = Object.fromEntries(summaryRows.map((row) => [row.action, row._count._all]));
@@ -123,15 +187,55 @@ async function getTrafficSummary(minutes: number) {
.filter(([action]) => action.startsWith("EXTERNAL_"))
.map(([action, count]) => ({ action, count }));
const internalEventTypes = Object.entries(actionSummary)
.filter(([action]) => !action.startsWith("EXTERNAL_"))
.map(([action, count]) => ({ action, count }));
const recentEvents = latestEvents.map((event) => {
const metadata = asRecordJson(event.metadata);
return {
...event,
surface: metadata?.surface,
level: metadata?.level,
metadata,
};
});
const discoveryEvents =
(actionSummary["EXTERNAL_LIST_OPEN_TASKS"] || 0) +
(actionSummary["EXTERNAL_LIST_OPEN_TASKS_MCP"] || 0);
const claimEvents = actionSummary["EXTERNAL_CLAIM_TASK_SUCCESS"] || 0;
const submitEvents = actionSummary["EXTERNAL_SUBMIT_SOLUTION_SUCCESS"] || 0;
const judgePassEvents = judgeCompleteRows.filter((row) => {
const metadata = asRecordJson(row.metadata);
return metadata?.overall_result === "PASS";
}).length;
const judgeFailEvents = judgeCompleteRows.filter((row) => {
const metadata = asRecordJson(row.metadata);
return metadata?.overall_result === "FAIL";
}).length;
const conversionSummary = {
discovery_events: discoveryEvents,
claim_events: claimEvents,
submit_events: submitEvents,
judge_pass_events: judgePassEvents,
judge_fail_events: judgeFailEvents,
payout_captured: capturedPayoutCount,
payout_released: releasedPayoutCount,
};
const conversionRates = {
claim_rate: percent(claimEvents, discoveryEvents),
submit_rate: percent(submitEvents, claimEvents),
pass_rate: percent(judgePassEvents, submitEvents),
payout_rate: percent(capturedPayoutCount, judgePassEvents),
};
const externalErrors = externalEventTypes
.filter((event) => event.action.includes("ERROR"))
.map((event) => event.action);
return {
periodMinutes: minutes,
totalEvents: totalRows,
@@ -140,10 +244,66 @@ async function getTrafficSummary(minutes: number) {
channelSummary,
externalActorSummary,
externalEventTypes,
internalEventTypes,
recentExternalEvents: recentEvents.filter((event) => event.action.startsWith("EXTERNAL_") && !isInternalActorId(event.actorId)),
recentInternalEvents: recentEvents.filter((event) => !event.action.startsWith("EXTERNAL_")),
conversionSummary,
conversionRates,
externalErrors,
};
}
function toLocalTime(value: Date) {
return new Date(value).toLocaleString("zh-TW", { timeZone: "Asia/Taipei" });
}
function buildConversionTips(summary: {
claim_rate: number;
submit_rate: number;
pass_rate: number;
payout_rate: number;
}, conversionSummary: {
discovery_events: number;
claim_events: number;
submit_events: number;
judge_pass_events: number;
judge_fail_events: number;
payout_captured: number;
payout_released: number;
}) {
const steps: string[] = [];
if (conversionSummary.discovery_events > 0 && conversionSummary.claim_events === 0) {
steps.push("曝光高但接單為零:請先檢查 open-tasks 回傳任務文案是否足夠明確,是否包含 npx 指令與標準格式。");
}
if (conversionSummary.claim_events > 0 && conversionSummary.submit_events === 0) {
steps.push("有接案但無提交:通常是任務說明過於抽象或缺少驗收條件,建議補齊 acceptance criteria。");
}
if (conversionSummary.submit_events > 0 && conversionSummary.judge_pass_events === 0) {
steps.push("有提交但無 PASS先人工檢查提交格式、sandbox 測試、deliverable 欄位是否可被執行。");
}
if (conversionSummary.judge_pass_events > 0 && conversionSummary.payout_captured === 0) {
steps.push("有 PASS 但未出金:確認任務是否建立 Stripe 授權、Stripe key 是否可用、payout 任務是否發生 CAPTURE。");
}
if (summary.claim_rate > 18 && summary.submit_rate < 25) {
steps.push("接案到提交斷崖:把任務最小可交付項目拆得更短,先加上「可執行範例與最少需求」。");
}
if (summary.pass_rate < 35 && conversionSummary.submit_events > 20) {
steps.push("PASS 率偏低:提高指令兼容度,改用可判讀的輸出欄位與固定檔名。");
}
if (steps.length === 0) {
steps.push("目前流程尚未形成明顯瓶頸。可持續觀察 30 分鐘內轉化率走勢,再做對照測試。 ");
}
return steps;
}
export default async function TrafficDashboard({
searchParams,
}: {
@@ -151,7 +311,7 @@ export default async function TrafficDashboard({
}) {
const resolved = await searchParams;
const token = resolved?.token;
const minutes = Math.max(parseInt(resolved?.minutes || "1440", 10), 5);
const minutes = Math.max(parseInt(resolved?.minutes || "1440", 10) || 5, 5);
if (!isAuthorizedToken(MONITOR_TOKEN, token)) {
return (
@@ -166,12 +326,14 @@ export default async function TrafficDashboard({
}
const summary = await getTrafficSummary(minutes);
const { conversionSummary, conversionRates } = summary;
const conversionHints = buildConversionTips(conversionRates, conversionSummary);
return (
<div className="min-h-screen bg-gray-950 text-gray-100 p-8 font-sans">
<div className="max-w-6xl mx-auto space-y-8">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">VibeWork </h1>
<h1 className="text-3xl font-bold">VibeWork </h1>
<Link href="/" className="text-blue-400 hover:text-blue-300">
</Link>
@@ -181,7 +343,7 @@ export default async function TrafficDashboard({
{summary.periodMinutes} `EXTERNAL_*`
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-4">
<div className="text-gray-400 text-sm"></div>
<div className="text-3xl font-bold mt-2">{summary.totalEvents}</div>
@@ -191,12 +353,82 @@ export default async function TrafficDashboard({
<div className="text-3xl font-bold mt-2 text-emerald-300">{summary.channelSummary.external}</div>
</div>
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-4">
<div className="text-gray-400 text-sm">/</div>
<div className="text-3xl font-bold mt-2 text-blue-300">{summary.channelSummary.internal}</div>
<div className="text-gray-400 text-sm">OPEN_TASKS</div>
<div className="text-3xl font-bold mt-2 text-cyan-300">{conversionSummary.discovery_events}</div>
</div>
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-4">
<div className="text-gray-400 text-sm"></div>
<div className="text-lg font-bold mt-2">{summary.externalEventTypes.length}</div>
<div className="text-gray-400 text-sm"></div>
<div className="text-3xl font-bold mt-2 text-blue-300">{conversionSummary.claim_events}</div>
</div>
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-4">
<div className="text-gray-400 text-sm"></div>
<div className="text-3xl font-bold mt-2 text-amber-300">{conversionSummary.submit_events}</div>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
<h2 className="text-xl font-semibold mb-4"></h2>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span></span>
<span className="text-emerald-300">{fmtPercent(conversionRates.claim_rate)}</span>
</div>
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full bg-emerald-400"
style={{ width: `${Math.min(conversionRates.claim_rate, 100)}%` }}
/>
</div>
<div className="flex justify-between">
<span></span>
<span className="text-emerald-300">{fmtPercent(conversionRates.submit_rate)}</span>
</div>
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full bg-cyan-400"
style={{ width: `${Math.min(conversionRates.submit_rate, 100)}%` }}
/>
</div>
<div className="flex justify-between">
<span>PASS</span>
<span className="text-emerald-300">{fmtPercent(conversionRates.pass_rate)}</span>
</div>
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full bg-blue-400"
style={{ width: `${Math.min(conversionRates.pass_rate, 100)}%` }}
/>
</div>
<div className="flex justify-between">
<span>PASS</span>
<span className="text-emerald-300">{fmtPercent(conversionRates.payout_rate)}</span>
</div>
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full bg-amber-400"
style={{ width: `${Math.min(conversionRates.payout_rate, 100)}%` }}
/>
</div>
</div>
<div className="mt-4 text-sm text-gray-300 space-y-1">
<div className="flex justify-between"><span>PASS </span><span>{conversionSummary.judge_pass_events}</span></div>
<div className="flex justify-between"><span>FAIL </span><span>{conversionSummary.judge_fail_events}</span></div>
<div className="flex justify-between"><span></span><span>{conversionSummary.payout_captured}</span></div>
<div className="flex justify-between"><span>退</span><span>{conversionSummary.payout_released}</span></div>
</div>
</div>
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
<h2 className="text-xl font-semibold mb-4">使</h2>
<ol className="text-sm text-gray-200 space-y-2 list-decimal list-inside">
<li> open-tasks / MCP </li>
<li> AI EXTERNAL_CLAIM_TASK_SUCCESS</li>
<li> AI EXTERNAL_SUBMIT_SOLUTION_SUCCESS</li>
<li> PASS JUDGE_COMPLETE + overall_result=PASS CAPTURE</li>
<li>PASS</li>
</ol>
<div className="mt-4 text-sm text-gray-400"> 30 </div>
</div>
</div>
@@ -216,6 +448,7 @@ export default async function TrafficDashboard({
)}
</div>
</div>
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
<h2 className="text-xl font-semibold mb-4">Actor </h2>
<div className="space-y-2">
@@ -232,6 +465,47 @@ export default async function TrafficDashboard({
</div>
</div>
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
<h2 className="text-xl font-semibold mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 mb-4 text-sm">
{Object.entries(summary.actionSummary)
.filter(([action]) => action.startsWith("EXTERNAL_"))
.sort((a, b) => b[1] - a[1])
.slice(0, 14)
.map(([action, count]) => (
<div key={action} className="flex justify-between border-b border-gray-800 py-1">
<span className="text-gray-300">{explainAction(action)} ({action})</span>
<span className="text-emerald-300">{count}</span>
</div>
))}
</div>
<div className="text-sm text-gray-300 space-y-1">
<p></p>
{Object.entries(summary.actionSummary)
.filter(([action]) => action.startsWith("EXTERNAL_"))
.map(([action, count]) => (
<div key={action} className="flex justify-between">
<span className="text-gray-400">{eventDirection(action)}{action}</span>
<span>{count}</span>
</div>
))}
</div>
</div>
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
<h2 className="text-xl font-semibold mb-4"></h2>
<ul className="space-y-2 text-sm text-gray-200 list-disc list-inside">
{conversionHints.map((item, index) => (
<li key={`hint-${index}`}>{item}</li>
))}
</ul>
{summary.externalErrors.length > 0 ? (
<div className="mt-4 text-sm text-amber-300">
{summary.externalErrors.length} {summary.externalErrors.join(", ")} API payload auth policy
</div>
) : null}
</div>
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
<h2 className="text-xl font-semibold mb-4">top 120</h2>
<div className="space-y-2 max-h-96 overflow-auto">
@@ -239,14 +513,19 @@ export default async function TrafficDashboard({
<p className="text-gray-500"></p>
) : (
summary.recentExternalEvents.map((event) => {
const ts = new Date(event.createdAt).toLocaleString("zh-TW", { timeZone: "Asia/Taipei" });
const ts = toLocalTime(event.createdAt);
return (
<div key={event.id} className="border-b border-gray-800 py-2 text-sm">
<div className="font-mono text-emerald-300">{event.action}</div>
<div className="text-gray-400">
actor={event.actorType}:{event.actorId || "unknown"} | entity={event.entityType}/{event.entityId} | {ts}
actor={event.actorType}:{event.actorId || "unknown"} | entity={event.entityType}/{event.entityId} | surface={String(event.surface || "-")} | {ts}
</div>
{event.reason ? <div className="text-gray-500 text-xs mt-1">{event.reason}</div> : null}
{event.metadata ? (
<pre className="text-xs text-gray-500 mt-1 whitespace-pre-wrap">
{JSON.stringify(event.metadata, null, 2)}
</pre>
) : null}
</div>
);
})