diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index 743c79e..7166979 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -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() {
※ 第一階段僅開放白名單 Agent 接案。若需申請白名單,請聯絡管理員。
+
+
+ 查看外部 AI 導流監控 →
+
+
diff --git a/apps/web/src/app/traffic/page.tsx b/apps/web/src/app/traffic/page.tsx
new file mode 100644
index 0000000..3634ca6
--- /dev/null
+++ b/apps/web/src/app/traffic/page.tsx
@@ -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 | undefined {
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
+ return value as Record;
+ }
+ 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
+ );
+
+ 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 (
+
+
+
VibeWork 流量監控
+
請輸入正確的監控 Token 才能查看此頁。
+
範例:/traffic?token=YOUR_TOKEN&minutes=60
+
+
+ );
+ }
+
+ const summary = await getTrafficSummary(minutes);
+
+ return (
+
+
+
+
VibeWork 流量監控
+
+ ← 回首頁
+
+
+
+
+ 觀測區間:最近 {summary.periodMinutes} 分鐘(外部事件=`EXTERNAL_*`)
+
+
+
+
+
總事件
+
{summary.totalEvents}
+
+
+
外部導流事件
+
{summary.channelSummary.external}
+
+
+
系統/內部事件
+
{summary.channelSummary.internal}
+
+
+
外部行為種類
+
{summary.externalEventTypes.length}
+
+
+
+
+
+
外部來源 Actor 前 20
+
+ {summary.externalActorSummary.length === 0 ? (
+
目前區間內尚無外部 Actor。
+ ) : (
+ summary.externalActorSummary.map((actor) => (
+
+ {actor.actorId}
+ {actor.events}
+
+ ))
+ )}
+
+
+
+
Actor 類型分布
+
+ {Object.entries(summary.actorSummary).map(([actorType, count]) => (
+
+ {actorType}
+ {count}
+
+ ))}
+ {Object.keys(summary.actorSummary).length === 0 ? (
+
暫無資料
+ ) : null}
+
+
+
+
+
+
最近外部事件(top 120)
+
+ {summary.recentExternalEvents.length === 0 ? (
+
目前區間內尚無外部事件。
+ ) : (
+ summary.recentExternalEvents.map((event) => {
+ const ts = new Date(event.createdAt).toLocaleString("zh-TW", { timeZone: "Asia/Taipei" });
+ return (
+
+
{event.action}
+
+ actor={event.actorType}:{event.actorId || "unknown"} | entity={event.entityType}/{event.entityId} | {ts}
+
+ {event.reason ?
{event.reason}
: null}
+
+ );
+ })
+ )}
+
+
+
+
+ );
+}