feat: add public AI traffic dashboard and filter homepage tasks
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:
@@ -4,6 +4,13 @@ import Link from "next/link";
|
||||
export const dynamic = "force-dynamic";
|
||||
export default async function Home() {
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: {
|
||||
title: {
|
||||
not: {
|
||||
startsWith: "GitHub Issue:",
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { created_at: "desc" }
|
||||
});
|
||||
|
||||
@@ -100,6 +107,11 @@ export default async function Home() {
|
||||
<p className="text-gray-500 text-sm">
|
||||
※ 第一階段僅開放白名單 Agent 接案。若需申請白名單,請聯絡管理員。
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Link href="/traffic" className="inline-flex items-center gap-2 text-emerald-400 hover:text-emerald-300">
|
||||
查看外部 AI 導流監控 →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
259
apps/web/src/app/traffic/page.tsx
Normal file
259
apps/web/src/app/traffic/page.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { isIP } from "node:net";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const MONITOR_TOKEN = process.env.TRAFFIC_MONITOR_TOKEN;
|
||||
|
||||
function asRecordJson(value: unknown): Record<string, unknown> | undefined {
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isInternalActorId(value: string | null | undefined) {
|
||||
if (!value) return true;
|
||||
const actorId = value.toLowerCase();
|
||||
if (actorId === "unknown" || actorId === "mcp-anonymous" || actorId === "open-tasks:localhost") return true;
|
||||
if (actorId === "localhost") return true;
|
||||
|
||||
const ipMatch = actorId.match(/^open-tasks:([a-z0-9.:_-]+)$/);
|
||||
if (!ipMatch?.[1]) return false;
|
||||
|
||||
const actorIp = ipMatch[1];
|
||||
if (actorIp.startsWith("127.") || actorIp.startsWith("10.") || actorIp.startsWith("192.168.")) return true;
|
||||
if (actorIp.startsWith("172.")) {
|
||||
const secondOctet = Number(actorIp.split(".")[1]);
|
||||
return secondOctet >= 16 && secondOctet <= 31;
|
||||
}
|
||||
if (actorIp === "localhost" || actorIp === "unknown" || actorIp.startsWith("fc") || actorIp.startsWith("fd")) return true;
|
||||
|
||||
if (actorIp === "::1" || actorIp.startsWith("fe80")) return true;
|
||||
if (isIP(actorIp) === 6 && actorIp.startsWith("fc")) return true;
|
||||
if (isIP(actorIp) === 6 && actorIp.startsWith("fd")) return true;
|
||||
|
||||
if (isIP(actorIp) === 4 || isIP(actorIp) === 6) return false;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAuthorizedToken(token: string | undefined, tokenHeader: string | undefined) {
|
||||
if (!token) return true;
|
||||
return tokenHeader === token;
|
||||
}
|
||||
|
||||
async function getTrafficSummary(minutes: number) {
|
||||
const since = new Date(Date.now() - minutes * 60 * 1000);
|
||||
|
||||
const [summaryRows, actorSummaryRows, externalActorRows, totalRows, latestEvents] = await Promise.all([
|
||||
prisma.auditEvent.groupBy({
|
||||
by: ["action"],
|
||||
where: {
|
||||
createdAt: { gte: since },
|
||||
},
|
||||
_count: { _all: true },
|
||||
}),
|
||||
prisma.auditEvent.groupBy({
|
||||
by: ["actorType"],
|
||||
where: {
|
||||
createdAt: { gte: since },
|
||||
},
|
||||
_count: { _all: true },
|
||||
}),
|
||||
prisma.auditEvent.groupBy({
|
||||
by: ["actorId"],
|
||||
where: {
|
||||
createdAt: { gte: since },
|
||||
action: {
|
||||
startsWith: "EXTERNAL_",
|
||||
},
|
||||
},
|
||||
_count: { _all: true },
|
||||
}),
|
||||
prisma.auditEvent.count({
|
||||
where: { createdAt: { gte: since } },
|
||||
}),
|
||||
prisma.auditEvent.findMany({
|
||||
where: {
|
||||
createdAt: { gte: since },
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: 120,
|
||||
select: {
|
||||
id: true,
|
||||
action: true,
|
||||
actorType: true,
|
||||
actorId: true,
|
||||
entityType: true,
|
||||
entityId: true,
|
||||
reason: true,
|
||||
metadata: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const actionSummary = Object.fromEntries(summaryRows.map((row) => [row.action, row._count._all]));
|
||||
const actorSummary = Object.fromEntries(actorSummaryRows.map((row) => [row.actorType, row._count._all]));
|
||||
const externalActorSummary = externalActorRows
|
||||
.map((row) => ({
|
||||
actorId: row.actorId || "unknown",
|
||||
events: row._count._all,
|
||||
}))
|
||||
.filter((row) => !isInternalActorId(row.actorId))
|
||||
.sort((a, b) => b.events - a.events)
|
||||
.slice(0, 20);
|
||||
|
||||
const channelSummary = Object.entries(actionSummary).reduce(
|
||||
(acc, [action, count]) => {
|
||||
if (action.startsWith("EXTERNAL_")) {
|
||||
acc.external += count;
|
||||
} else {
|
||||
acc.internal += count;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ external: 0, internal: 0 } as Record<string, number>
|
||||
);
|
||||
|
||||
const externalEventTypes = 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,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
periodMinutes: minutes,
|
||||
totalEvents: totalRows,
|
||||
actionSummary,
|
||||
actorSummary,
|
||||
channelSummary,
|
||||
externalActorSummary,
|
||||
externalEventTypes,
|
||||
recentExternalEvents: recentEvents.filter((event) => event.action.startsWith("EXTERNAL_") && !isInternalActorId(event.actorId)),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function TrafficDashboard({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ minutes?: string; token?: string }>;
|
||||
}) {
|
||||
const resolved = await searchParams;
|
||||
const token = resolved?.token;
|
||||
const minutes = Math.max(parseInt(resolved?.minutes || "1440", 10), 5);
|
||||
|
||||
if (!isAuthorizedToken(MONITOR_TOKEN, token)) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-gray-100 p-8 font-sans">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-4">VibeWork 流量監控</h1>
|
||||
<p className="text-gray-300 mb-4">請輸入正確的監控 Token 才能查看此頁。</p>
|
||||
<p className="text-sm text-gray-500">範例:<code>/traffic?token=YOUR_TOKEN&minutes=60</code></p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const summary = await getTrafficSummary(minutes);
|
||||
|
||||
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>
|
||||
<Link href="/" className="text-blue-400 hover:text-blue-300">
|
||||
← 回首頁
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-400">
|
||||
觀測區間:最近 {summary.periodMinutes} 分鐘(外部事件=`EXTERNAL_*`)
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 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>
|
||||
</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-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>
|
||||
<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>
|
||||
</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">外部來源 Actor 前 20</h2>
|
||||
<div className="space-y-2 max-h-80 overflow-auto">
|
||||
{summary.externalActorSummary.length === 0 ? (
|
||||
<p className="text-gray-500">目前區間內尚無外部 Actor。</p>
|
||||
) : (
|
||||
summary.externalActorSummary.map((actor) => (
|
||||
<div key={actor.actorId} className="flex justify-between text-sm">
|
||||
<span className="text-gray-300">{actor.actorId}</span>
|
||||
<span className="text-emerald-300">{actor.events}</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">Actor 類型分布</h2>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(summary.actorSummary).map(([actorType, count]) => (
|
||||
<div key={actorType} className="flex justify-between text-sm">
|
||||
<span className="text-gray-300">{actorType}</span>
|
||||
<span className="text-blue-300">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(summary.actorSummary).length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">暫無資料</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
{summary.recentExternalEvents.length === 0 ? (
|
||||
<p className="text-gray-500">目前區間內尚無外部事件。</p>
|
||||
) : (
|
||||
summary.recentExternalEvents.map((event) => {
|
||||
const ts = new Date(event.createdAt).toLocaleString("zh-TW", { timeZone: "Asia/Taipei" });
|
||||
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}
|
||||
</div>
|
||||
{event.reason ? <div className="text-gray-500 text-xs mt-1">{event.reason}</div> : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user