feat: Enhance login page UI with delayed redirect instead of transparent 307
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:
18
apps/web/src/app/admin/page.tsx
Normal file
18
apps/web/src/app/admin/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function AdminLandingPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-gray-100 p-8 font-sans">
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
<h1 className="text-3xl font-bold">VibeWork 後台</h1>
|
||||
<p className="text-gray-300">請使用 wooo 帳號登入後前往後台頁面。</p>
|
||||
<div>
|
||||
<Link href="/admin/traffic" className="text-emerald-400 hover:text-emerald-300">
|
||||
前往流量監控後台
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
2
apps/web/src/app/admin/traffic/page.tsx
Normal file
2
apps/web/src/app/admin/traffic/page.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from "../../traffic/page";
|
||||
|
||||
48
apps/web/src/app/api/admin/health/route.ts
Normal file
48
apps/web/src/app/api/admin/health/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { isAdminRequestAuthorized, resolveAdminAccount } from "@/lib/admin-auth";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!isAdminRequestAuthorized(request)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const checks = {
|
||||
db: { ok: false as boolean, error: undefined as string | undefined, task_count: 0 },
|
||||
recent_audit_events: { ok: false as boolean, error: undefined as string | undefined, count: 0 },
|
||||
};
|
||||
|
||||
try {
|
||||
checks.db.task_count = await prisma.task.count();
|
||||
checks.db.ok = true;
|
||||
} catch (error) {
|
||||
checks.db.error = error instanceof Error ? error.message : "unknown";
|
||||
}
|
||||
|
||||
try {
|
||||
checks.recent_audit_events.count = await prisma.auditEvent.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: new Date(Date.now() - 10 * 60 * 1000),
|
||||
},
|
||||
},
|
||||
});
|
||||
checks.recent_audit_events.ok = true;
|
||||
} catch (error) {
|
||||
checks.recent_audit_events.error = error instanceof Error ? error.message : "unknown";
|
||||
}
|
||||
|
||||
const admin = resolveAdminAccount();
|
||||
|
||||
return NextResponse.json({
|
||||
service: "agent-bounty-web",
|
||||
healthy: checks.db.ok && checks.recent_audit_events.ok,
|
||||
timestamp: new Date().toISOString(),
|
||||
admin: {
|
||||
username: admin.username,
|
||||
using_default_account: admin.isDefaultCredentials,
|
||||
},
|
||||
checks,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -148,6 +148,15 @@ function resolveSourceIp(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
function isPublicRequest(request: NextRequest) {
|
||||
const sourceIp = resolveSourceIp(request);
|
||||
return !isPrivateIp(sourceIp);
|
||||
}
|
||||
|
||||
function scopeTrafficAction(baseAction: string, isPublicIp: boolean) {
|
||||
return `${isPublicIp ? "EXTERNAL" : "INTERNAL"}_${baseAction}`;
|
||||
}
|
||||
|
||||
async function ensureBuilderAgent(
|
||||
agentId: string,
|
||||
requestContext?: {
|
||||
@@ -155,7 +164,8 @@ async function ensureBuilderAgent(
|
||||
source_ip?: string;
|
||||
user_agent?: string;
|
||||
request_actor_headers?: Record<string, unknown>;
|
||||
}
|
||||
},
|
||||
isPublicIp = false
|
||||
) {
|
||||
const existingAgent = await prisma.agentProfile.findUnique({ where: { agent_id: agentId } });
|
||||
if (existingAgent) {
|
||||
@@ -177,11 +187,11 @@ async function ensureBuilderAgent(
|
||||
|
||||
void sendTrafficAlert({
|
||||
level: "info",
|
||||
action: "EXTERNAL_AGENT_AUTO_WHITELIST",
|
||||
action: scopeTrafficAction("AGENT_AUTO_WHITELIST", isPublicIp),
|
||||
surface: "mcp/claim_task",
|
||||
actorType: "AGENT",
|
||||
actorId: `agent:${normalizeActorId(agentId, "agent")}`,
|
||||
message: `外部 Agent 首次接案已自動白名單: ${agentId}`,
|
||||
message: `Agent 首次接案已自動白名單: ${agentId}`,
|
||||
metadata: {
|
||||
...requestContext,
|
||||
agent_id: agentId,
|
||||
@@ -238,6 +248,11 @@ function normalizeActorId(value: string, fallback: string) {
|
||||
return normalized.slice(0, 64) || fallback;
|
||||
}
|
||||
|
||||
function asAgentActorId(value: string | undefined, fallback = "agent") {
|
||||
const normalized = normalizeActorId(value || fallback, fallback);
|
||||
return normalized.startsWith("agent:") ? normalized : `agent:${normalized}`;
|
||||
}
|
||||
|
||||
function resolveActorFromMcpRequest(request: NextRequest) {
|
||||
for (const headerName of MCP_AGENT_HEADERS) {
|
||||
const headerValue = request.headers.get(headerName);
|
||||
@@ -260,12 +275,13 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
const tool = params.tool;
|
||||
const actor = resolveActorFromMcpRequest(request);
|
||||
const requestContext = resolveRequestTrace(request);
|
||||
const isPublicIp = isPublicRequest(request);
|
||||
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
void sendTrafficAlert({
|
||||
level: "warning",
|
||||
action: "EXTERNAL_MCP_AUTH_MISSING",
|
||||
action: scopeTrafficAction("MCP_AUTH_MISSING", isPublicIp),
|
||||
surface: `mcp/${tool}`,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
@@ -292,7 +308,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
if (!isValidServerKey && !isBetaToken) {
|
||||
void sendTrafficAlert({
|
||||
level: "warning",
|
||||
action: "EXTERNAL_MCP_AUTH_FORBIDDEN",
|
||||
action: scopeTrafficAction("MCP_AUTH_FORBIDDEN", isPublicIp),
|
||||
surface: `mcp/${tool}`,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
@@ -301,6 +317,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
metadata: {
|
||||
...requestContext,
|
||||
auth_issue: "invalid_bearer_token",
|
||||
payload_summary: summarizeRequestPayload(tool, null),
|
||||
response_summary: "invalid_bearer_token",
|
||||
response_status: 403,
|
||||
},
|
||||
@@ -325,9 +342,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
(body as Record<string, unknown>).skills = [];
|
||||
}
|
||||
ListOpenTasksRequestSchema.parse(body);
|
||||
const sourceIp = resolveSourceIp(request);
|
||||
const isPublicIp = !isPrivateIp(sourceIp);
|
||||
const trafficAction = isPublicIp ? "EXTERNAL_LIST_OPEN_TASKS_MCP" : "INTERNAL_LIST_OPEN_TASKS_MCP";
|
||||
const trafficAction = scopeTrafficAction("LIST_OPEN_TASKS_MCP", isPublicIp);
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: {
|
||||
status: TaskStatus.OPEN,
|
||||
@@ -393,7 +408,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
if (eventCount > 0 && eventCount % MCP_SURGE_INTERVAL === 0) {
|
||||
void sendTrafficAlert({
|
||||
level: "warning",
|
||||
action: isPublicIp ? "EXTERNAL_LIST_OPEN_TASKS_SURGE" : "INTERNAL_LIST_OPEN_TASKS_SURGE",
|
||||
action: scopeTrafficAction("LIST_OPEN_TASKS_SURGE", isPublicIp),
|
||||
surface: "mcp/list_open_tasks",
|
||||
actorType: "SYSTEM",
|
||||
actorId: "traffic-monitor",
|
||||
@@ -420,11 +435,11 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
const parsed = ClaimTaskRequestSchema.parse(body);
|
||||
|
||||
// Verify Agent Whitelist
|
||||
const agent = await ensureBuilderAgent(parsed.agent_id, requestContext);
|
||||
const agent = await ensureBuilderAgent(parsed.agent_id, requestContext, isPublicIp);
|
||||
if (!agent) {
|
||||
void sendTrafficAlert({
|
||||
level: "warning",
|
||||
action: "EXTERNAL_CLAIM_TASK_FORBIDDEN",
|
||||
action: scopeTrafficAction("CLAIM_TASK_FORBIDDEN", isPublicIp),
|
||||
surface: "mcp/claim_task",
|
||||
actorType: "AGENT",
|
||||
actorId: `agent:${normalizeActorId(parsed.agent_id, "agent")}`,
|
||||
@@ -444,7 +459,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
if (agent.status !== "WHITELISTED") {
|
||||
void sendTrafficAlert({
|
||||
level: "warning",
|
||||
action: "EXTERNAL_CLAIM_TASK_FORBIDDEN",
|
||||
action: scopeTrafficAction("CLAIM_TASK_FORBIDDEN", isPublicIp),
|
||||
surface: "mcp/claim_task",
|
||||
actorType: "AGENT",
|
||||
actorId: `agent:${normalizeActorId(parsed.agent_id, "agent")}`,
|
||||
@@ -510,7 +525,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
|
||||
void sendTrafficAlert({
|
||||
level: "info",
|
||||
action: "EXTERNAL_CLAIM_TASK_SUCCESS",
|
||||
action: scopeTrafficAction("CLAIM_TASK_SUCCESS", isPublicIp),
|
||||
surface: "mcp/claim_task",
|
||||
actorType: "AGENT",
|
||||
actorId: `agent:${normalizeActorId(parsed.agent_id, "agent")}`,
|
||||
@@ -528,10 +543,12 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
},
|
||||
});
|
||||
|
||||
void evaluateExternalFunnelHealth({
|
||||
surface: "mcp/claim_task",
|
||||
periodMinutes: 10,
|
||||
});
|
||||
if (isPublicIp) {
|
||||
void evaluateExternalFunnelHealth({
|
||||
surface: "mcp/claim_task",
|
||||
periodMinutes: 10,
|
||||
});
|
||||
}
|
||||
|
||||
// Set Redis TTL key (3600 seconds)
|
||||
await redis.set(`vw:task:${claim.task_id}:executing`, claim.claim_token, "EX", 3600);
|
||||
@@ -608,10 +625,10 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
|
||||
void sendTrafficAlert({
|
||||
level: "info",
|
||||
action: "EXTERNAL_SUBMIT_SOLUTION_SUCCESS",
|
||||
action: scopeTrafficAction("SUBMIT_SOLUTION_SUCCESS", isPublicIp),
|
||||
surface: "mcp/submit_solution",
|
||||
actorType: "AGENT",
|
||||
actorId: submittedClaim.agent_id,
|
||||
actorId: asAgentActorId(submittedClaim.agent_id),
|
||||
taskId: submission.task_id,
|
||||
message: `Agent 提交解法: ${parsed.task_id}`,
|
||||
metadata: {
|
||||
@@ -626,10 +643,12 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
},
|
||||
});
|
||||
|
||||
void evaluateExternalFunnelHealth({
|
||||
surface: "mcp/submit_solution",
|
||||
periodMinutes: 10,
|
||||
});
|
||||
if (isPublicIp) {
|
||||
void evaluateExternalFunnelHealth({
|
||||
surface: "mcp/submit_solution",
|
||||
periodMinutes: 10,
|
||||
});
|
||||
}
|
||||
|
||||
// Async trigger E2B Sandbox evaluation
|
||||
const taskObj = await prisma.task.findUnique({ where: { id: submission.task_id }});
|
||||
@@ -784,10 +803,10 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
|
||||
void sendTrafficAlert({
|
||||
level: "info",
|
||||
action: "EXTERNAL_CREATE_SUB_TASK_SUCCESS",
|
||||
action: scopeTrafficAction("CREATE_SUB_TASK_SUCCESS", isPublicIp),
|
||||
surface: "mcp/create_sub_task",
|
||||
actorType: "AGENT",
|
||||
actorId: subTask.created_by_agent!,
|
||||
actorId: asAgentActorId(subTask.created_by_agent || parsed.parent_task_id),
|
||||
taskId: subTask.id,
|
||||
message: `A2A 內循環!Agent 發佈了子任務: ${subTask.id}`,
|
||||
metadata: {
|
||||
@@ -863,10 +882,10 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
|
||||
void sendTrafficAlert({
|
||||
level: "info",
|
||||
action: "EXTERNAL_PEER_REVIEW_REQUEST",
|
||||
action: scopeTrafficAction("PEER_REVIEW_REQUEST", isPublicIp),
|
||||
surface: "mcp/request_peer_review",
|
||||
actorType: "AGENT",
|
||||
actorId: reviewTask.created_by_agent!,
|
||||
actorId: asAgentActorId(reviewTask.created_by_agent || undefined),
|
||||
taskId: reviewTask.id,
|
||||
message: `A2A 互助!Agent 發佈了 Code Review 任務: ${reviewTask.id}`,
|
||||
metadata: {
|
||||
@@ -958,7 +977,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
|
||||
void sendTrafficAlert({
|
||||
level: "info",
|
||||
action: "EXTERNAL_AGENT_MEMORY_QUERY",
|
||||
action: scopeTrafficAction("AGENT_MEMORY_QUERY", isPublicIp),
|
||||
surface: "mcp/query_agent_memory",
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
@@ -1071,7 +1090,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
if (!ledger) {
|
||||
void sendTrafficAlert({
|
||||
level: "info",
|
||||
action: "EXTERNAL_CHECK_PAYOUT_STATUS_SUCCESS",
|
||||
action: scopeTrafficAction("CHECK_PAYOUT_STATUS_SUCCESS", isPublicIp),
|
||||
surface: "mcp/check_payout_status",
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
@@ -1097,7 +1116,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
|
||||
void sendTrafficAlert({
|
||||
level: "info",
|
||||
action: "EXTERNAL_CHECK_PAYOUT_STATUS_SUCCESS",
|
||||
action: scopeTrafficAction("CHECK_PAYOUT_STATUS_SUCCESS", isPublicIp),
|
||||
surface: "mcp/check_payout_status",
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
@@ -1127,7 +1146,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
const parsed = CreateBountyRequestSchema.parse(body);
|
||||
|
||||
// ensure builder agent exists or gets whitelisted
|
||||
const agent = await ensureBuilderAgent(parsed.agent_id, requestContext);
|
||||
const agent = await ensureBuilderAgent(parsed.agent_id, requestContext, isPublicIp);
|
||||
if (!agent) {
|
||||
return NextResponse.json({ error: "Forbidden: Agent is not whitelisted" }, { status: 403 });
|
||||
}
|
||||
@@ -1166,7 +1185,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
|
||||
void sendTrafficAlert({
|
||||
level: "info",
|
||||
action: "EXTERNAL_CREATE_BOUNTY_SUCCESS",
|
||||
action: scopeTrafficAction("CREATE_BOUNTY_SUCCESS", isPublicIp),
|
||||
surface: "mcp/create_bounty",
|
||||
actorType: "AGENT",
|
||||
actorId: agent.agent_id,
|
||||
@@ -1199,7 +1218,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
default:
|
||||
void sendTrafficAlert({
|
||||
level: "warning",
|
||||
action: "EXTERNAL_MCP_TOOL_UNKNOWN",
|
||||
action: scopeTrafficAction("MCP_TOOL_UNKNOWN", isPublicIp),
|
||||
surface: `mcp/${tool}`,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
@@ -1231,7 +1250,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool
|
||||
|
||||
void sendTrafficAlert({
|
||||
level: "error",
|
||||
action: `EXTERNAL_${tool.toUpperCase()}_ERROR`,
|
||||
action: scopeTrafficAction(`${tool.toUpperCase()}_ERROR`, isPublicIp),
|
||||
surface: `mcp/${tool}`,
|
||||
actorType: "AGENT",
|
||||
actorId: actorInCatch.actorId,
|
||||
|
||||
@@ -51,6 +51,23 @@ function normalizeUserAgent(value: unknown) {
|
||||
return topToken.length > 48 ? `${topToken.slice(0, 45)}...` : topToken;
|
||||
}
|
||||
|
||||
function normalizePayloadSummary(value: unknown) {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : "unknown";
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const AI_USER_AGENT_HINTS = [
|
||||
"gpt",
|
||||
"chatgpt",
|
||||
@@ -68,6 +85,100 @@ const AI_USER_AGENT_HINTS = [
|
||||
"copilot",
|
||||
];
|
||||
|
||||
type TrafficActorClass = "a2a" | "external_ai_agent" | "likely_ai_agent" | "other_external";
|
||||
|
||||
function resolveActorClass(
|
||||
action: string,
|
||||
actorType: string | null | undefined,
|
||||
actorId: string | null | undefined,
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
surface: string | undefined
|
||||
) {
|
||||
const normalizedSurface = (surface || "").toLowerCase();
|
||||
if (normalizedSurface.startsWith("mcp/")) {
|
||||
return "a2a";
|
||||
}
|
||||
|
||||
const normalizedActorId = (actorId || "").toLowerCase();
|
||||
if (actorType === "AGENT" || normalizedActorId.startsWith("agent:")) {
|
||||
if (action.startsWith("EXTERNAL_") && normalizedSurface.startsWith("mcp/")) {
|
||||
return "a2a";
|
||||
}
|
||||
return "external_ai_agent";
|
||||
}
|
||||
|
||||
if (isLikelyAIAgentActor(actorType, actorId, metadata)) {
|
||||
return "likely_ai_agent";
|
||||
}
|
||||
|
||||
return "other_external";
|
||||
}
|
||||
|
||||
function resolveMetadata(value: unknown): Record<string, unknown> | undefined {
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveDisplayIp(event: { actorType: string | null; actorId: string | null; metadata: unknown }) {
|
||||
const metadata = resolveMetadata(event.metadata);
|
||||
const metadataIp = typeof metadata?.source_ip === "string" ? metadata.source_ip.trim() : undefined;
|
||||
if (metadataIp) {
|
||||
return metadataIp;
|
||||
}
|
||||
|
||||
if ((event.actorType || "").toUpperCase() === "USER" && event.actorId) {
|
||||
const marker = event.actorId.lastIndexOf(":");
|
||||
if (marker >= 0) {
|
||||
const actorIp = event.actorId.slice(marker + 1).trim();
|
||||
if (actorIp) {
|
||||
return actorIp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ((event.actorType || "").toUpperCase() === "SYSTEM") {
|
||||
return "system";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function resolveDisplayUserAgent(event: { actorType: string | null; metadata: unknown }) {
|
||||
const metadata = resolveMetadata(event.metadata);
|
||||
const metadataUa =
|
||||
typeof metadata?.user_agent === "string"
|
||||
? metadata.user_agent
|
||||
: typeof metadata?.userAgent === "string"
|
||||
? metadata.userAgent
|
||||
: undefined;
|
||||
|
||||
if (!metadataUa && (event.actorType || "").toUpperCase() === "SYSTEM") {
|
||||
return "system";
|
||||
}
|
||||
|
||||
return normalizeUserAgent(metadataUa);
|
||||
}
|
||||
|
||||
function resolveResponseStatus(event: { metadata: unknown }) {
|
||||
const metadata = resolveMetadata(event.metadata);
|
||||
const value = metadata?.response_status;
|
||||
|
||||
if (typeof value === "number") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isLikelyAIAgentActor(
|
||||
actorType: string | null | undefined,
|
||||
actorId: string | null | undefined,
|
||||
@@ -432,6 +543,17 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const recentEvents = latestEvents.map((event) => {
|
||||
const metadata = asRecordJson(event.metadata);
|
||||
const actorClass =
|
||||
event.action.startsWith("EXTERNAL_") && !isInternalActor({ actorType: event.actorType, actorId: event.actorId })
|
||||
? resolveActorClass(
|
||||
event.action,
|
||||
event.actorType,
|
||||
event.actorId,
|
||||
metadata,
|
||||
typeof metadata?.surface === "string" ? metadata.surface : undefined
|
||||
)
|
||||
: "other_external";
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
action: event.action,
|
||||
@@ -444,6 +566,7 @@ export async function GET(request: NextRequest) {
|
||||
surface: metadata?.surface,
|
||||
level: metadata?.level,
|
||||
actorSource: classifyActorSource(event.actorType, event.actorId, metadata),
|
||||
actorClass,
|
||||
metadata,
|
||||
};
|
||||
});
|
||||
@@ -468,6 +591,7 @@ export async function GET(request: NextRequest) {
|
||||
const externalActorActivities: Map<string, {
|
||||
actor_id: string;
|
||||
events: number;
|
||||
actor_class: TrafficActorClass;
|
||||
latest_action: string;
|
||||
latest_surface: string;
|
||||
latest_source_ip: string;
|
||||
@@ -480,25 +604,75 @@ export async function GET(request: NextRequest) {
|
||||
latest_request_id: string;
|
||||
latest_created_at_ms: number;
|
||||
}> = new Map();
|
||||
const externalActorClassSummary = new Map<TrafficActorClass, { events: number; actors: Set<string> }>();
|
||||
|
||||
recentEvents.forEach((event) => {
|
||||
const actorId = event.actorId || "agent:unknown";
|
||||
const metadata = asRecordJson(event.metadata);
|
||||
const actorClass = resolveActorClass(
|
||||
event.action,
|
||||
event.actorType,
|
||||
event.actorId,
|
||||
metadata,
|
||||
typeof event.surface === "string" ? event.surface : undefined
|
||||
);
|
||||
if (event.action.startsWith("EXTERNAL_")) {
|
||||
const bucket = externalActorClassSummary.get(actorClass);
|
||||
if (!bucket) {
|
||||
externalActorClassSummary.set(actorClass, { events: 1, actors: new Set([actorId]) });
|
||||
} else {
|
||||
bucket.events += 1;
|
||||
bucket.actors.add(actorId);
|
||||
}
|
||||
}
|
||||
const normalizedSurface = normalizeSurface(event.surface);
|
||||
const normalizedIp = normalizeSourceIp(metadata?.source_ip);
|
||||
const normalizedUa = normalizeUserAgent(metadata?.user_agent);
|
||||
const isExternalAgent = event.action.startsWith("EXTERNAL_") &&
|
||||
event.actorType === "AGENT" &&
|
||||
!isInternalActor({ actorType: event.actorType, actorId: event.actorId });
|
||||
const isTrackedExternalActor =
|
||||
event.action.startsWith("EXTERNAL_") &&
|
||||
!isInternalActor({ actorType: event.actorType, actorId: event.actorId }) &&
|
||||
(actorClass === "a2a" || actorClass === "external_ai_agent" || actorClass === "likely_ai_agent");
|
||||
|
||||
if (!isExternalAgent) {
|
||||
if (!isTrackedExternalActor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventAt = event.createdAt.getTime();
|
||||
const responseStatus = typeof metadata?.response_status === "number" ? metadata.response_status : null;
|
||||
const errorName = typeof metadata?.error_name === "string" ? metadata.error_name : "";
|
||||
const errorMessage = typeof metadata?.error_message === "string" ? metadata.error_message : "";
|
||||
const responseStatus =
|
||||
typeof metadata?.response_status === "number"
|
||||
? metadata.response_status
|
||||
: typeof metadata?.response_status === "string"
|
||||
? Number.parseInt(metadata.response_status, 10)
|
||||
: null;
|
||||
|
||||
const fallbackErrorName =
|
||||
event.action === "EXTERNAL_MCP_AUTH_MISSING"
|
||||
? "AUTH_MISSING"
|
||||
: event.action === "EXTERNAL_MCP_AUTH_FORBIDDEN"
|
||||
? "AUTH_FORBIDDEN"
|
||||
: event.action.includes("FAIL")
|
||||
? "INTERNAL_ERROR"
|
||||
: ""
|
||||
;
|
||||
|
||||
const fallbackErrorMessage =
|
||||
typeof metadata?.auth_issue === "string"
|
||||
? metadata.auth_issue
|
||||
: typeof event.reason === "string"
|
||||
? event.reason
|
||||
: typeof metadata?.response_summary === "string"
|
||||
? metadata.response_summary
|
||||
: ""
|
||||
;
|
||||
|
||||
const errorName =
|
||||
typeof metadata?.error_name === "string" && metadata.error_name.length > 0
|
||||
? metadata.error_name
|
||||
: fallbackErrorName;
|
||||
const errorMessage =
|
||||
typeof metadata?.error_message === "string" && metadata.error_message.length > 0
|
||||
? metadata.error_message
|
||||
: fallbackErrorMessage;
|
||||
const taskId = typeof metadata?.task_id === "string" ? metadata.task_id : (event.entityId || "-");
|
||||
const responseSummary =
|
||||
typeof metadata?.response_summary === "string" ? metadata.response_summary : "unknown";
|
||||
@@ -513,6 +687,7 @@ export async function GET(request: NextRequest) {
|
||||
externalActorActivities.set(actorId, {
|
||||
actor_id: actorId,
|
||||
events: 1,
|
||||
actor_class: actorClass,
|
||||
latest_action: event.action,
|
||||
latest_surface: normalizedSurface,
|
||||
latest_source_ip: normalizedIp,
|
||||
@@ -522,7 +697,7 @@ export async function GET(request: NextRequest) {
|
||||
latest_response_summary: responseSummary,
|
||||
latest_reason: event.reason || "unknown",
|
||||
latest_payload_summary:
|
||||
typeof metadata?.payload_summary === "string" ? metadata.payload_summary : "unknown",
|
||||
normalizePayloadSummary(metadata?.payload_summary),
|
||||
latest_request_id:
|
||||
typeof metadata?.request_id === "string" ? metadata.request_id : "unknown",
|
||||
latest_created_at_ms: eventAt,
|
||||
@@ -540,7 +715,7 @@ export async function GET(request: NextRequest) {
|
||||
existingActorActivity.latest_response_summary = responseSummary;
|
||||
existingActorActivity.latest_reason = event.reason || "unknown";
|
||||
existingActorActivity.latest_payload_summary =
|
||||
typeof metadata?.payload_summary === "string" ? metadata.payload_summary : "unknown";
|
||||
normalizePayloadSummary(metadata?.payload_summary);
|
||||
existingActorActivity.latest_request_id =
|
||||
typeof metadata?.request_id === "string" ? metadata.request_id : "unknown";
|
||||
existingActorActivity.latest_created_at_ms = eventAt;
|
||||
@@ -568,17 +743,30 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
});
|
||||
|
||||
const recentExternalEvents = recentEvents.filter((event) =>
|
||||
event.action.startsWith("EXTERNAL_") &&
|
||||
!isInternalActor({
|
||||
actorType: event.actorType,
|
||||
actorId: event.actorId,
|
||||
})
|
||||
);
|
||||
const recentExternalEvents = recentEvents
|
||||
.filter(
|
||||
(event) =>
|
||||
event.action.startsWith("EXTERNAL_") &&
|
||||
!isInternalActor({
|
||||
actorType: event.actorType,
|
||||
actorId: event.actorId,
|
||||
})
|
||||
)
|
||||
.map((event) => ({
|
||||
...event,
|
||||
source_ip: resolveDisplayIp(event),
|
||||
user_agent: resolveDisplayUserAgent(event),
|
||||
response_status: resolveResponseStatus(event),
|
||||
}));
|
||||
|
||||
const recentInternalEvents = recentEvents.filter(
|
||||
(event) => !event.action.startsWith("EXTERNAL_")
|
||||
);
|
||||
const recentInternalEvents = recentEvents
|
||||
.filter((event) => !event.action.startsWith("EXTERNAL_"))
|
||||
.map((event) => ({
|
||||
...event,
|
||||
source_ip: resolveDisplayIp(event),
|
||||
user_agent: resolveDisplayUserAgent(event),
|
||||
response_status: resolveResponseStatus(event),
|
||||
}));
|
||||
|
||||
const externalSurfaceSummary = Array.from(externalSourceSurfaceMap.entries())
|
||||
.map(([surface, bucket]) => ({
|
||||
@@ -629,6 +817,14 @@ export async function GET(request: NextRequest) {
|
||||
})
|
||||
.slice(0, 40);
|
||||
|
||||
const externalActorClassSummaryRows = Array.from(externalActorClassSummary.entries())
|
||||
.map(([actorClass, bucket]) => ({
|
||||
actor_class: actorClass,
|
||||
events: bucket.events,
|
||||
actors: bucket.actors.size,
|
||||
}))
|
||||
.sort((a, b) => b.events - a.events);
|
||||
|
||||
return NextResponse.json({
|
||||
period_minutes: minutes,
|
||||
total_events: totalRows,
|
||||
@@ -645,6 +841,7 @@ export async function GET(request: NextRequest) {
|
||||
external_source_ip_summary: externalSourceIpSummary,
|
||||
external_user_agent_summary: externalUserAgentSummary,
|
||||
external_response_status_summary: externalResponseStatusSummary,
|
||||
external_actor_class_summary: externalActorClassSummaryRows,
|
||||
external_actor_activities: externalActorActivityRows,
|
||||
external_error_rows: externalErrorRowsSorted,
|
||||
recent_external_events: recentExternalEvents,
|
||||
|
||||
82
apps/web/src/app/login/page.tsx
Normal file
82
apps/web/src/app/login/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, use } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type SearchParams = {
|
||||
next?: string | string[];
|
||||
role?: string | string[];
|
||||
};
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<SearchParams>;
|
||||
};
|
||||
|
||||
function sanitizePath(pathname: string | undefined | string[]) {
|
||||
if (!pathname || Array.isArray(pathname)) {
|
||||
return "/admin";
|
||||
}
|
||||
|
||||
const trimmed = pathname.trim();
|
||||
if (!trimmed.startsWith("/")) {
|
||||
return "/admin";
|
||||
}
|
||||
|
||||
if (trimmed.includes("://")) {
|
||||
return "/admin";
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export default function LoginPage(props: PageProps) {
|
||||
const searchParams = use(props.searchParams);
|
||||
const router = useRouter();
|
||||
const role = searchParams?.role;
|
||||
const candidate = sanitizePath(searchParams?.next);
|
||||
const targetPath = role !== "ADMIN" ? "/admin" : (candidate || "/admin");
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
router.replace(targetPath);
|
||||
}, 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [router, targetPath]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 flex flex-col items-center justify-center font-sans text-gray-100 selection:bg-indigo-500/30">
|
||||
<div className="w-full max-w-md p-8 relative overflow-hidden backdrop-blur-xl bg-white/5 border border-white/10 rounded-3xl shadow-2xl">
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-32 h-1 bg-gradient-to-r from-transparent via-indigo-500 to-transparent opacity-50"></div>
|
||||
|
||||
<div className="flex flex-col items-center space-y-6 text-center">
|
||||
<div className="relative flex items-center justify-center w-20 h-20 rounded-full bg-indigo-500/10 border border-indigo-500/20">
|
||||
<svg className="w-8 h-8 text-indigo-400 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
<div className="absolute inset-0 rounded-full border border-indigo-500/30 animate-[ping_2s_ease-in-out_infinite]"></div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Security Check</h1>
|
||||
<p className="text-sm text-gray-400">
|
||||
{role === "ADMIN"
|
||||
? "驗證通過,正在安全導向至管理後台..."
|
||||
: "正在將您導向至登入頁面..."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-1 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-indigo-500 rounded-full animate-[progress_1.5s_ease-in-out_forwards]" style={{ width: '0%' }}>
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
@keyframes progress {
|
||||
0% { width: 0%; }
|
||||
100% { width: 100%; }
|
||||
}
|
||||
`}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -119,6 +119,9 @@ export default async function Home() {
|
||||
<Link href="/traffic" className="inline-flex items-center gap-2 text-emerald-400 hover:text-emerald-300">
|
||||
查看外部 AI 導流監控 →
|
||||
</Link>
|
||||
<Link href="/admin/traffic" className="inline-flex items-center gap-2 text-amber-400 hover:text-amber-300 mt-2 block">
|
||||
管理後台:流量監控總覽 →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { isIP } from "node:net";
|
||||
|
||||
@@ -77,7 +78,15 @@ function isInternalActor(input: { actorType: string | null | undefined; actorId:
|
||||
return isInternalActorId(input.actorId);
|
||||
}
|
||||
|
||||
function isAuthorizedToken(token: string | undefined, tokenHeader: string | undefined) {
|
||||
function isAuthorizedToken(
|
||||
token: string | undefined,
|
||||
tokenHeader: string | undefined,
|
||||
isAdmin = false,
|
||||
) {
|
||||
if (isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!token) return true;
|
||||
return tokenHeader === token;
|
||||
}
|
||||
@@ -91,9 +100,95 @@ function explainAction(action: string) {
|
||||
return EVENT_LABELS[action] || action;
|
||||
}
|
||||
|
||||
type TrafficActorClass = "a2a" | "external_ai_agent" | "likely_ai_agent" | "other_external";
|
||||
|
||||
const AI_USER_AGENT_HINTS = [
|
||||
"gpt",
|
||||
"chatgpt",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"claude",
|
||||
"perplexity",
|
||||
"llm",
|
||||
"mcp",
|
||||
"autogpt",
|
||||
"agent",
|
||||
"assistant",
|
||||
"gemini",
|
||||
"cursor",
|
||||
"copilot",
|
||||
];
|
||||
|
||||
function isLikelyAIAgentActor(
|
||||
actorType: string | null | undefined,
|
||||
actorId: string | null | undefined,
|
||||
metadata: Record<string, unknown> | undefined
|
||||
) {
|
||||
if (actorType === "AGENT") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedActor = (actorId || "").toLowerCase();
|
||||
if (normalizedActor.startsWith("agent:")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const userAgent = String(metadata?.user_agent || "").toLowerCase();
|
||||
if (AI_USER_AGENT_HINTS.some((token) => userAgent.includes(token))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const requestHeaders = asRecordJson(metadata?.request_actor_headers);
|
||||
if (!requestHeaders) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const headerText = Object.values(requestHeaders)
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
return AI_USER_AGENT_HINTS.some((token) => headerText.includes(token));
|
||||
}
|
||||
|
||||
function resolveActorClass(
|
||||
action: string,
|
||||
actorType: string | null | undefined,
|
||||
actorId: string | null | undefined,
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
surface: string | undefined
|
||||
) {
|
||||
const normalizedSurface = (surface || "").toLowerCase();
|
||||
if (normalizedSurface.startsWith("mcp/")) {
|
||||
return "a2a";
|
||||
}
|
||||
|
||||
const normalizedActorId = (actorId || "").toLowerCase();
|
||||
if (actorType === "AGENT" || normalizedActorId.startsWith("agent:")) {
|
||||
if (action.startsWith("EXTERNAL_") && normalizedSurface.startsWith("mcp/")) {
|
||||
return "a2a";
|
||||
}
|
||||
return "external_ai_agent";
|
||||
}
|
||||
|
||||
if (isLikelyAIAgentActor(actorType, actorId, metadata)) {
|
||||
return "likely_ai_agent";
|
||||
}
|
||||
|
||||
return action.startsWith("EXTERNAL_") ? "other_external" : "other_external";
|
||||
}
|
||||
|
||||
function actorClassLabel(actorClass: TrafficActorClass) {
|
||||
if (actorClass === "a2a") return "A2A (MCP)";
|
||||
if (actorClass === "external_ai_agent") return "外部 AI Agent";
|
||||
if (actorClass === "likely_ai_agent") return "疑似 AI 流量";
|
||||
return "其他外部流量";
|
||||
}
|
||||
|
||||
type ExternalActorActivity = {
|
||||
actorId: string;
|
||||
events: number;
|
||||
actorClass: TrafficActorClass;
|
||||
latestAction: string;
|
||||
latestSurface: string;
|
||||
latestSourceIp: string;
|
||||
@@ -268,6 +363,13 @@ async function getTrafficSummary(minutes: number) {
|
||||
|
||||
const recentEvents = latestEvents.map((event) => {
|
||||
const metadata = asRecordJson(event.metadata);
|
||||
const actorClass = resolveActorClass(
|
||||
event.action,
|
||||
event.actorType,
|
||||
event.actorId,
|
||||
metadata,
|
||||
typeof metadata?.surface === "string" ? metadata.surface : undefined
|
||||
);
|
||||
return {
|
||||
...event,
|
||||
surface: metadata?.surface,
|
||||
@@ -276,6 +378,7 @@ async function getTrafficSummary(minutes: number) {
|
||||
response_summary: typeof metadata?.response_summary === "string" ? metadata.response_summary : "unknown",
|
||||
payload_summary: typeof metadata?.payload_summary === "string" ? metadata.payload_summary : "unknown",
|
||||
request_id: typeof metadata?.request_id === "string" ? metadata.request_id : "n/a",
|
||||
actor_class: actorClass,
|
||||
metadata,
|
||||
};
|
||||
});
|
||||
@@ -315,6 +418,15 @@ async function getTrafficSummary(minutes: number) {
|
||||
.filter((event) => event.action.includes("ERROR"))
|
||||
.map((event) => event.action);
|
||||
|
||||
const externalActorClassMap = new Map<TrafficActorClass, number>();
|
||||
for (const event of recentEvents.filter((event) => event.action.startsWith("EXTERNAL_"))) {
|
||||
const actorClass = (event as { actor_class?: TrafficActorClass }).actor_class || "other_external";
|
||||
externalActorClassMap.set(actorClass, (externalActorClassMap.get(actorClass) || 0) + 1);
|
||||
}
|
||||
const externalActorClassSummary = Array.from(externalActorClassMap.entries())
|
||||
.map(([actor_class, events]) => ({ actor_class, events }))
|
||||
.sort((a, b) => b.events - a.events);
|
||||
|
||||
const demandHealthLabel = demandSupply.openTaskCount > 0 ? "有可接需求" : "無可接需求";
|
||||
const demandHealthTone = demandSupply.openTaskCount > 0 ? "text-emerald-300" : "text-amber-300";
|
||||
|
||||
@@ -325,11 +437,16 @@ async function getTrafficSummary(minutes: number) {
|
||||
}
|
||||
|
||||
const actorId = event.actorId || "agent:unknown";
|
||||
const actorClass = (event as { actor_class?: TrafficActorClass }).actor_class || "other_external";
|
||||
const isInternal = isInternalActor({
|
||||
actorType: event.actorType,
|
||||
actorId: event.actorId,
|
||||
});
|
||||
if (isInternal || event.actorType !== "AGENT") {
|
||||
const isTrackedExternal =
|
||||
!isInternal &&
|
||||
(actorClass === "a2a" || actorClass === "external_ai_agent" || actorClass === "likely_ai_agent");
|
||||
|
||||
if (!isTrackedExternal) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -357,6 +474,7 @@ async function getTrafficSummary(minutes: number) {
|
||||
externalActorActivityMap.set(actorId, {
|
||||
actorId,
|
||||
events: 1,
|
||||
actorClass,
|
||||
latestAction: event.action,
|
||||
latestSurface: String(event.surface || "unknown"),
|
||||
latestSourceIp: normalizedIp,
|
||||
@@ -438,6 +556,7 @@ async function getTrafficSummary(minutes: number) {
|
||||
recentInternalEvents: recentEvents.filter((event) => !event.action.startsWith("EXTERNAL_")),
|
||||
conversionSummary,
|
||||
conversionRates,
|
||||
externalActorClassSummary,
|
||||
externalActorActivities,
|
||||
externalErrors,
|
||||
demandSupply,
|
||||
@@ -503,8 +622,11 @@ export default async function TrafficDashboard({
|
||||
const resolved = await searchParams;
|
||||
const token = resolved?.token;
|
||||
const minutes = Math.max(parseInt(resolved?.minutes || "1440", 10) || 5, 5);
|
||||
const requestHeaders = await headers();
|
||||
const requestTokenHeader = requestHeaders.get("x-traffic-token");
|
||||
const isAdmin = requestHeaders.get("x-admin-authenticated") === "1";
|
||||
|
||||
if (!isAuthorizedToken(MONITOR_TOKEN, token)) {
|
||||
if (!isAuthorizedToken(MONITOR_TOKEN, (token ?? requestTokenHeader) || undefined, isAdmin)) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-gray-100 p-8 font-sans">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
@@ -666,6 +788,22 @@ export default async function TrafficDashboard({
|
||||
</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">外部流量來源分類(近 120 筆事件)</h2>
|
||||
<div className="space-y-2">
|
||||
{summary.externalActorClassSummary.length === 0 ? (
|
||||
<p className="text-gray-500">目前區間內尚無外部分類資料。</p>
|
||||
) : (
|
||||
summary.externalActorClassSummary.map((item) => (
|
||||
<div key={item.actor_class} className="flex justify-between text-sm">
|
||||
<span className="text-gray-300">{actorClassLabel(item.actor_class)}</span>
|
||||
<span className="text-emerald-300">{item.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 前 20</h2>
|
||||
<div className="space-y-2 max-h-80 overflow-auto">
|
||||
@@ -704,6 +842,7 @@ export default async function TrafficDashboard({
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-gray-400 border-b border-gray-700">
|
||||
<th className="text-left py-2">來源類型</th>
|
||||
<th className="text-left py-2">Actor</th>
|
||||
<th className="text-left py-2">事件</th>
|
||||
<th className="text-left py-2">最新行為</th>
|
||||
@@ -717,13 +856,14 @@ export default async function TrafficDashboard({
|
||||
<tbody>
|
||||
{summary.externalActorActivities.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="text-gray-500 py-3">
|
||||
<td colSpan={9} className="text-gray-500 py-3">
|
||||
尚無可追蹤的外部 AGENT 行為,可能目前仍未有 AGENT 類型入口流量。
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
summary.externalActorActivities.map((actor) => (
|
||||
<tr key={actor.actorId} className="border-b border-gray-800">
|
||||
<td className="py-2 text-gray-300">{actorClassLabel(actor.actorClass)}</td>
|
||||
<td className="py-2 text-gray-300">{actor.actorId}</td>
|
||||
<td className="py-2 text-emerald-300">{actor.events}</td>
|
||||
<td className="py-2">
|
||||
@@ -800,7 +940,7 @@ export default async function TrafficDashboard({
|
||||
<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} | surface={String(event.surface || "-")} | {ts}
|
||||
actor={event.actorType}:{event.actorId || "unknown"} | 類別={actorClassLabel((event as { actor_class?: TrafficActorClass }).actor_class || "other_external")} | entity={event.entityType}/{event.entityId} | surface={String(event.surface || "-")} | {ts}
|
||||
</div>
|
||||
<div className="text-gray-500 text-xs mt-1">
|
||||
response={event.response_status ?? "n/a"} / summary={event.response_summary}
|
||||
|
||||
Reference in New Issue
Block a user