feat: add conversion-oriented traffic dashboard
Some checks failed
Deploy to 110 WOOO Server / deploy (push) Failing after 8s
Some checks failed
Deploy to 110 WOOO Server / deploy (push) Failing after 8s
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user