feat: SSE real-time trace streaming + advanced search/filter with URL sync

This commit is contained in:
Vectry
2026-02-10 00:12:32 +00:00
parent 5bb75433aa
commit 47ef3dcbe6
3 changed files with 545 additions and 48 deletions

View File

@@ -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,
});

View 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 });
}