feat: SSE real-time trace streaming + advanced search/filter with URL sync
This commit is contained in:
@@ -254,6 +254,10 @@ export async function GET(request: NextRequest) {
|
||||
const status = searchParams.get("status");
|
||||
const search = searchParams.get("search");
|
||||
const sessionId = searchParams.get("sessionId");
|
||||
const tags = searchParams.get("tags");
|
||||
const sort = searchParams.get("sort") ?? "newest";
|
||||
const dateFrom = searchParams.get("dateFrom");
|
||||
const dateTo = searchParams.get("dateTo");
|
||||
|
||||
// Validate pagination parameters
|
||||
if (isNaN(page) || page < 1) {
|
||||
@@ -269,6 +273,20 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: `Invalid status. Must be one of: ${validStatuses.join(", ")}` }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate sort parameter
|
||||
const validSorts = ["newest", "oldest", "longest", "shortest", "costliest"];
|
||||
if (sort && !validSorts.includes(sort)) {
|
||||
return NextResponse.json({ error: `Invalid sort. Must be one of: ${validSorts.join(", ")}` }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate date parameters
|
||||
if (dateFrom && isNaN(Date.parse(dateFrom))) {
|
||||
return NextResponse.json({ error: "Invalid dateFrom parameter. Must be a valid ISO date string." }, { status: 400 });
|
||||
}
|
||||
if (dateTo && isNaN(Date.parse(dateTo))) {
|
||||
return NextResponse.json({ error: "Invalid dateTo parameter. Must be a valid ISO date string." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Build where clause
|
||||
const where: Record<string, unknown> = {};
|
||||
if (status) {
|
||||
@@ -283,6 +301,50 @@ export async function GET(request: NextRequest) {
|
||||
if (sessionId) {
|
||||
where.sessionId = sessionId;
|
||||
}
|
||||
if (tags) {
|
||||
const tagList = tags.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
if (tagList.length > 0) {
|
||||
where.tags = {
|
||||
hasSome: tagList,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (dateFrom) {
|
||||
where.createdAt = {
|
||||
...((where.createdAt as Prisma.TraceWhereInput) ?? {}),
|
||||
gte: new Date(dateFrom),
|
||||
};
|
||||
}
|
||||
if (dateTo) {
|
||||
where.createdAt = {
|
||||
...((where.createdAt as Prisma.TraceWhereInput) ?? {}),
|
||||
lte: new Date(dateTo),
|
||||
};
|
||||
}
|
||||
|
||||
// Build order by clause based on sort parameter
|
||||
let orderBy: Prisma.TraceOrderByWithRelationInput = {
|
||||
startedAt: "desc",
|
||||
};
|
||||
|
||||
switch (sort) {
|
||||
case "oldest":
|
||||
orderBy = { startedAt: "asc" };
|
||||
break;
|
||||
case "longest":
|
||||
orderBy = { totalDuration: "desc" };
|
||||
break;
|
||||
case "shortest":
|
||||
orderBy = { totalDuration: "asc" };
|
||||
break;
|
||||
case "costliest":
|
||||
orderBy = { totalCost: "desc" };
|
||||
break;
|
||||
case "newest":
|
||||
default:
|
||||
orderBy = { startedAt: "desc" };
|
||||
break;
|
||||
}
|
||||
|
||||
// Count total traces
|
||||
const total = await prisma.trace.count({ where });
|
||||
@@ -303,9 +365,7 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
startedAt: "desc",
|
||||
},
|
||||
orderBy,
|
||||
skip,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
110
apps/web/src/app/api/traces/stream/route.ts
Normal file
110
apps/web/src/app/api/traces/stream/route.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface TraceUpdateData {
|
||||
type: "new" | "updated";
|
||||
trace: {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "RUNNING" | "COMPLETED" | "ERROR";
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
totalDuration: number | null;
|
||||
tags: string[];
|
||||
metadata: Record<string, unknown> | null;
|
||||
totalCost: number | null;
|
||||
totalTokens: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const headers = new Headers();
|
||||
headers.set("Content-Type", "text/event-stream");
|
||||
headers.set("Cache-Control", "no-cache");
|
||||
headers.set("Connection", "keep-alive");
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
let lastCheck = new Date();
|
||||
let heartbeatInterval: NodeJS.Timeout | null = null;
|
||||
let pollInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const sendSSE = (event: string, data: unknown) => {
|
||||
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
controller.enqueue(encoder.encode(message));
|
||||
};
|
||||
|
||||
const pollForUpdates = async () => {
|
||||
try {
|
||||
const newTraces = await prisma.trace.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ createdAt: { gt: lastCheck } },
|
||||
{ updatedAt: { gt: lastCheck } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
startedAt: true,
|
||||
endedAt: true,
|
||||
totalDuration: true,
|
||||
tags: true,
|
||||
metadata: true,
|
||||
totalCost: true,
|
||||
totalTokens: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const trace of newTraces) {
|
||||
const type = trace.createdAt > lastCheck ? "new" : "updated";
|
||||
sendSSE("trace-update", {
|
||||
type,
|
||||
trace: {
|
||||
...trace,
|
||||
startedAt: trace.startedAt.toISOString(),
|
||||
endedAt: trace.endedAt?.toISOString() ?? null,
|
||||
createdAt: trace.createdAt.toISOString(),
|
||||
updatedAt: trace.updatedAt.toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
lastCheck = new Date();
|
||||
} catch (error) {
|
||||
console.error("Error polling for trace updates:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const sendHeartbeat = () => {
|
||||
sendSSE("heartbeat", { timestamp: new Date().toISOString() });
|
||||
};
|
||||
|
||||
// Send initial heartbeat
|
||||
sendHeartbeat();
|
||||
|
||||
// Poll every 2 seconds for updates
|
||||
pollInterval = setInterval(pollForUpdates, 2000);
|
||||
|
||||
// Send heartbeat every 15 seconds
|
||||
heartbeatInterval = setInterval(sendHeartbeat, 15000);
|
||||
|
||||
// Cleanup function
|
||||
request.signal.addEventListener("abort", () => {
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
||||
controller.close();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, { headers });
|
||||
}
|
||||
Reference in New Issue
Block a user