diff --git a/apps/web/src/app/api/traces/route.ts b/apps/web/src/app/api/traces/route.ts index d3393fa..eb9092c 100644 --- a/apps/web/src/app/api/traces/route.ts +++ b/apps/web/src/app/api/traces/route.ts @@ -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 = {}; 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, }); diff --git a/apps/web/src/app/api/traces/stream/route.ts b/apps/web/src/app/api/traces/stream/route.ts new file mode 100644 index 0000000..cd12ec8 --- /dev/null +++ b/apps/web/src/app/api/traces/stream/route.ts @@ -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 | 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 }); +} diff --git a/apps/web/src/components/trace-list.tsx b/apps/web/src/components/trace-list.tsx index a6feda1..7b22699 100644 --- a/apps/web/src/components/trace-list.tsx +++ b/apps/web/src/components/trace-list.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect, useCallback } from "react"; import Link from "next/link"; +import { useSearchParams } from "next/navigation"; import { Search, Filter, @@ -14,6 +15,12 @@ import { ChevronLeft, ChevronRight, ArrowRight, + ChevronDown, + ChevronUp, + RefreshCw, + ToggleLeft, + ToggleRight, + WifiOff, } from "lucide-react"; import { cn, formatDuration, formatRelativeTime } from "@/lib/utils"; @@ -43,6 +50,7 @@ interface TraceListProps { } type FilterStatus = "ALL" | TraceStatus; +type SortOption = "newest" | "oldest" | "longest" | "shortest" | "costliest"; const statusConfig: Record; color: string; bgColor: string }> = { RUNNING: { @@ -65,17 +73,206 @@ const statusConfig: Record(initialTraces); + const [total, setTotal] = useState(initialTotal); + const [totalPages, setTotalPages] = useState(initialTotalPages); + const [currentPage, setCurrentPage] = useState(initialPage); + const [isLoading, setIsLoading] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); const [statusFilter, setStatusFilter] = useState("ALL"); - const [currentPage, setCurrentPage] = useState(initialPage); + const [sortFilter, setSortFilter] = useState("newest"); + const [tagsFilter, setTagsFilter] = useState(""); + const [dateFrom, setDateFrom] = useState(""); + const [dateTo, setDateTo] = useState(""); - const filteredTraces = initialTraces.filter((trace) => { + const [showAdvancedFilters, setShowAdvancedFilters] = useState(false); + const [autoRefresh, setAutoRefresh] = useState(false); + const [sseConnected, setSseConnected] = useState(false); + const [newTracesCount, setNewTracesCount] = useState(0); + + const updateUrlParams = useCallback(() => { + const params = new URLSearchParams(searchParams.toString()); + + if (statusFilter !== "ALL") { + params.set("status", statusFilter); + } else { + params.delete("status"); + } + + if (searchQuery) { + params.set("search", searchQuery); + } else { + params.delete("search"); + } + + if (sortFilter !== "newest") { + params.set("sort", sortFilter); + } else { + params.delete("sort"); + } + + if (tagsFilter) { + params.set("tags", tagsFilter); + } else { + params.delete("tags"); + } + + if (dateFrom) { + params.set("dateFrom", dateFrom); + } else { + params.delete("dateFrom"); + } + + if (dateTo) { + params.set("dateTo", dateTo); + } else { + params.delete("dateTo"); + } + + params.set("page", currentPage.toString()); + + const newUrl = `${window.location.pathname}?${params.toString()}`; + window.history.replaceState({}, "", newUrl); + }, [searchParams, statusFilter, searchQuery, sortFilter, tagsFilter, dateFrom, dateTo, currentPage]); + + const fetchTraces = useCallback(async () => { + setIsLoading(true); + try { + const params = new URLSearchParams(); + params.set("page", currentPage.toString()); + params.set("limit", "50"); + + if (statusFilter !== "ALL") { + params.set("status", statusFilter); + } + if (searchQuery) { + params.set("search", searchQuery); + } + if (sortFilter !== "newest") { + params.set("sort", sortFilter); + } + if (tagsFilter) { + params.set("tags", tagsFilter); + } + if (dateFrom) { + params.set("dateFrom", dateFrom); + } + if (dateTo) { + params.set("dateTo", dateTo); + } + + const res = await fetch(`/api/traces?${params.toString()}`, { cache: "no-store" }); + + if (!res.ok) { + throw new Error(`Failed to fetch traces: ${res.status}`); + } + + const data = await res.json(); + setTraces(data.traces); + setTotal(data.total); + setTotalPages(data.totalPages); + setNewTracesCount(0); + } catch (error) { + console.error("Error fetching traces:", error); + } finally { + setIsLoading(false); + } + }, [currentPage, statusFilter, searchQuery, sortFilter, tagsFilter, dateFrom, dateTo]); + + useEffect(() => { + const status = searchParams.get("status") as FilterStatus | null; + const search = searchParams.get("search") ?? ""; + const sort = (searchParams.get("sort") as SortOption | null) ?? "newest"; + const tags = searchParams.get("tags") ?? ""; + const from = searchParams.get("dateFrom") ?? ""; + const to = searchParams.get("dateTo") ?? ""; + const page = parseInt(searchParams.get("page") ?? "1", 10); + + setStatusFilter(status ?? "ALL"); + setSearchQuery(search); + setSortFilter(sort); + setTagsFilter(tags); + setDateFrom(from); + setDateTo(to); + setCurrentPage(page); + + const savedAutoRefresh = localStorage.getItem("agentlens-auto-refresh"); + setAutoRefresh(savedAutoRefresh === "true"); + }, [searchParams]); + + useEffect(() => { + updateUrlParams(); + }, [updateUrlParams]); + + useEffect(() => { + fetchTraces(); + }, [currentPage, fetchTraces]); + + useEffect(() => { + if (!autoRefresh) return; + + const interval = setInterval(() => { + fetchTraces(); + }, 5000); + + return () => clearInterval(interval); + }, [autoRefresh, fetchTraces]); + + useEffect(() => { + localStorage.setItem("agentlens-auto-refresh", autoRefresh.toString()); + }, [autoRefresh]); + + useEffect(() => { + const eventSource = new EventSource("/api/traces/stream"); + + eventSource.onopen = () => { + setSseConnected(true); + }; + + eventSource.onerror = () => { + setSseConnected(false); + }; + + eventSource.addEventListener("trace-update", (event) => { + try { + const data = JSON.parse(event.data); + if (data.type === "new") { + setNewTracesCount((prev) => prev + 1); + } else if (data.type === "updated") { + setNewTracesCount((prev) => prev + 1); + } + } catch (error) { + console.error("Error parsing SSE message:", error); + } + }); + + eventSource.addEventListener("heartbeat", () => { + setSseConnected(true); + }); + + return () => { + eventSource.close(); + setSseConnected(false); + }; + }, []); + + const filteredTraces = traces.filter((trace) => { const matchesSearch = trace.name.toLowerCase().includes(searchQuery.toLowerCase()) || trace.tags.some((tag) => @@ -93,7 +290,12 @@ export function TraceList({ { value: "ERROR", label: "Error" }, ]; - if (initialTraces.length === 0) { + const handlePageChange = (newPage: number) => { + setCurrentPage(newPage); + window.scrollTo({ top: 0, behavior: "smooth" }); + }; + + if (traces.length === 0) { return ; } @@ -101,44 +303,171 @@ export function TraceList({
{/* Header */}
-
-

Traces

-

- {initialTotal} trace{initialTotal !== 1 ? "s" : ""} captured -

+
+
+

Traces

+

+ {total} trace{total !== 1 ? "s" : ""} captured +

+
+
+ {sseConnected ? ( + <> +
+
+
+
+ Live + + ) : ( + <> + + Offline + + )} +
+
- {/* Search and Filter */} -
-
- - setSearchQuery(e.target.value)} - className="w-full pl-10 pr-4 py-3 bg-neutral-900 border border-neutral-800 rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition-all" - /> + {/* New Traces Banner */} + {newTracesCount > 0 && ( +
+ + {newTracesCount} new trace{newTracesCount !== 1 ? "s" : ""} available + +
-
- -
- {filterChips.map((chip) => ( - - ))} + )} + + {/* Search and Filter */} +
+
+
+ + setSearchQuery(e.target.value)} + className="w-full pl-10 pr-4 py-3 bg-neutral-900 border border-neutral-800 rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition-all" + />
+
+ +
+ {filterChips.map((chip) => ( + + ))} +
+
+
+ + {/* Advanced Filters */} +
+ + + {showAdvancedFilters && ( +
+
+ + +
+ +
+ + setDateFrom(e.target.value)} + className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500 transition-all" + /> +
+ +
+ + setDateTo(e.target.value)} + className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500 transition-all" + /> +
+ +
+ + setTagsFilter(e.target.value)} + className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500 transition-all" + /> +
+
+ )}
@@ -150,7 +479,7 @@ export function TraceList({
{/* Empty Filtered State */} - {filteredTraces.length === 0 && initialTraces.length > 0 && ( + {filteredTraces.length === 0 && traces.length > 0 && (

No traces match your search criteria @@ -159,24 +488,22 @@ export function TraceList({ )} {/* Pagination */} - {initialTotalPages > 1 && ( + {totalPages > 1 && (

- Page {currentPage} of {initialTotalPages} + Page {currentPage} of {totalPages}