Spaces:
Running
Running
| import React, { useEffect, useCallback, useState, useMemo } from "react"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Skeleton } from "@/components/ui/skeleton"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { | |
| Select, | |
| SelectContent, | |
| SelectItem, | |
| SelectTrigger, | |
| SelectValue, | |
| } from "@/components/ui/select"; | |
| import { | |
| Tooltip, | |
| TooltipContent, | |
| TooltipProvider, | |
| TooltipTrigger, | |
| } from "@/components/ui/tooltip"; | |
| import { | |
| FileText, | |
| GitBranch, | |
| Search, | |
| Filter, | |
| X, | |
| ArrowUpDown, | |
| Eye, | |
| Trash2, | |
| Info, | |
| HelpCircle, | |
| } from "lucide-react"; | |
| import { useAgentGraph } from "@/context/AgentGraphContext"; | |
| import { useModal } from "@/context/ModalContext"; | |
| import { EmptyState } from "@/components/shared/EmptyState"; | |
| import { api } from "@/lib/api"; | |
| // Types for filtering and sorting | |
| type SortField = "name" | "date" | "content" | "type" | "status"; | |
| type SortDirection = "asc" | "desc"; | |
| type StatusFilter = "all" | "processed" | "ready"; | |
| type TypeFilter = "all" | string; | |
| export function TracesView() { | |
| const { state, actions } = useAgentGraph(); | |
| const { openModal } = useModal(); | |
| const { traces, isLoading } = state; | |
| // Search and filter states | |
| const [searchQuery, setSearchQuery] = useState(""); | |
| const [typeFilter, setTypeFilter] = useState<TypeFilter>("all"); | |
| const [statusFilter, setStatusFilter] = useState<StatusFilter>("all"); | |
| const [sortField, setSortField] = useState<SortField>("date"); | |
| const [sortDirection, setSortDirection] = useState<SortDirection>("desc"); | |
| // Simple bulk selection state | |
| const [selectedTraces, setSelectedTraces] = useState<string[]>([]); | |
| // Format trace type function | |
| const formatTraceType = (type?: string) => { | |
| if (!type) return "Unknown"; | |
| return type.replace(/[-_]/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); | |
| }; | |
| // Format date function | |
| const formatDate = (dateString?: string) => { | |
| if (!dateString) return "N/A"; | |
| try { | |
| const date = new Date(dateString); | |
| return date.toLocaleDateString(); | |
| } catch { | |
| return "N/A"; | |
| } | |
| }; | |
| // Get unique trace types for filter dropdown | |
| const uniqueTypes = useMemo(() => { | |
| const types = new Set<string>(); | |
| traces.forEach((trace) => { | |
| if (trace.trace_type) { | |
| types.add(trace.trace_type); | |
| } | |
| }); | |
| return Array.from(types).sort(); | |
| }, [traces]); | |
| // Filter and sort traces | |
| const filteredAndSortedTraces = useMemo(() => { | |
| let filtered = traces; | |
| // Apply search filter | |
| if (searchQuery.trim()) { | |
| const query = searchQuery.toLowerCase(); | |
| filtered = filtered.filter( | |
| (trace) => | |
| trace.filename.toLowerCase().includes(query) || | |
| trace.description?.toLowerCase().includes(query) || | |
| trace.title?.toLowerCase().includes(query) | |
| ); | |
| } | |
| // Apply type filter | |
| if (typeFilter !== "all") { | |
| filtered = filtered.filter((trace) => trace.trace_type === typeFilter); | |
| } | |
| // Apply status filter | |
| if (statusFilter !== "all") { | |
| filtered = filtered.filter((trace) => { | |
| const hasGraphs = | |
| trace.knowledge_graphs && trace.knowledge_graphs.length > 0; | |
| if (statusFilter === "processed") return hasGraphs; | |
| if (statusFilter === "ready") return !hasGraphs; | |
| return true; | |
| }); | |
| } | |
| // Apply sorting | |
| filtered.sort((a, b) => { | |
| let valueA: any, valueB: any; | |
| switch (sortField) { | |
| case "name": | |
| valueA = a.filename.toLowerCase(); | |
| valueB = b.filename.toLowerCase(); | |
| break; | |
| case "date": | |
| // Sort by upload timestamp (added date) | |
| valueA = new Date(a.upload_timestamp || a.timestamp || 0).getTime(); | |
| valueB = new Date(b.upload_timestamp || b.timestamp || 0).getTime(); | |
| break; | |
| case "content": | |
| valueA = a.character_count || 0; | |
| valueB = b.character_count || 0; | |
| break; | |
| case "type": | |
| valueA = a.trace_type || ""; | |
| valueB = b.trace_type || ""; | |
| break; | |
| case "status": | |
| valueA = (a.knowledge_graphs?.length || 0) > 0 ? 1 : 0; | |
| valueB = (b.knowledge_graphs?.length || 0) > 0 ? 1 : 0; | |
| break; | |
| default: | |
| valueA = a.filename; | |
| valueB = b.filename; | |
| } | |
| if (valueA < valueB) return sortDirection === "asc" ? -1 : 1; | |
| if (valueA > valueB) return sortDirection === "asc" ? 1 : -1; | |
| return 0; | |
| }); | |
| return filtered; | |
| }, [traces, searchQuery, typeFilter, statusFilter, sortField, sortDirection]); | |
| // Clear all filters | |
| const clearFilters = () => { | |
| setSearchQuery(""); | |
| setTypeFilter("all"); | |
| setStatusFilter("all"); | |
| setSortField("date"); | |
| setSortDirection("desc"); | |
| }; | |
| // Check if any filters are active | |
| const hasActiveFilters = | |
| searchQuery.trim() || typeFilter !== "all" || statusFilter !== "all"; | |
| // Simplified bulk operation handlers | |
| const handleSelectAll = (checked: boolean) => { | |
| if (checked) { | |
| setSelectedTraces( | |
| filteredAndSortedTraces.map((trace) => trace.id.toString()) | |
| ); | |
| } else { | |
| setSelectedTraces([]); | |
| } | |
| }; | |
| const handleSelectTrace = (traceId: string, checked: boolean) => { | |
| if (checked) { | |
| setSelectedTraces((prev) => [...prev, traceId]); | |
| } else { | |
| setSelectedTraces((prev) => prev.filter((id) => id !== traceId)); | |
| } | |
| }; | |
| const handleBulkDelete = () => { | |
| if (selectedTraces.length === 0) return; | |
| const confirmed = window.confirm( | |
| `Are you sure you want to delete ${selectedTraces.length} trace${ | |
| selectedTraces.length !== 1 ? "s" : "" | |
| }? This action cannot be undone.` | |
| ); | |
| if (confirmed) { | |
| // Simple deletion without complex loading states | |
| selectedTraces.forEach(async (traceId) => { | |
| try { | |
| await api.traces.delete(traceId); | |
| } catch (error) { | |
| console.error("Error deleting trace:", error); | |
| } | |
| }); | |
| // Refresh traces and clear selection | |
| setTimeout(() => { | |
| loadTraces(); | |
| setSelectedTraces([]); | |
| }, 500); | |
| } | |
| }; | |
| const handleViewTrace = (trace: any, e: React.MouseEvent) => { | |
| e.stopPropagation(); | |
| actions.setSelectedTrace(trace); | |
| actions.setActiveView("trace-kg"); | |
| }; | |
| const handleViewTraceDetails = async (trace: any, e: React.MouseEvent) => { | |
| e.stopPropagation(); | |
| openModal( | |
| "trace-details", | |
| `Trace Details - ${trace.filename}`, | |
| { | |
| trace: trace, | |
| knowledgeGraphs: trace.knowledge_graphs || [], | |
| }, | |
| { | |
| size: "xl", | |
| closable: true, | |
| } | |
| ); | |
| }; | |
| const handleDeleteSingleTrace = async ( | |
| traceId: string, | |
| e: React.MouseEvent | |
| ) => { | |
| e.stopPropagation(); | |
| if (window.confirm("Are you sure you want to delete this trace?")) { | |
| try { | |
| await api.traces.delete(traceId); | |
| await loadTraces(); | |
| } catch (error) { | |
| console.error("Error deleting trace:", error); | |
| alert("Error deleting trace. Please try again."); | |
| } | |
| } | |
| }; | |
| const isAllSelected = | |
| filteredAndSortedTraces.length > 0 && | |
| filteredAndSortedTraces.every((trace) => | |
| selectedTraces.includes(trace.id.toString()) | |
| ); | |
| const isPartiallySelected = selectedTraces.length > 0 && !isAllSelected; | |
| const loadTraces = useCallback(async () => { | |
| actions.setLoading(true); | |
| try { | |
| const tracesData = await api.traces.list(); | |
| actions.setTraces(Array.isArray(tracesData) ? tracesData : []); | |
| } catch (error) { | |
| actions.setError( | |
| error instanceof Error ? error.message : "Failed to load traces" | |
| ); | |
| actions.setTraces([]); | |
| } finally { | |
| actions.setLoading(false); | |
| } | |
| }, [actions]); | |
| useEffect(() => { | |
| loadTraces(); | |
| }, [loadTraces]); | |
| // Auto-refresh traces every 60 seconds (increased for HF Spaces compatibility) | |
| useEffect(() => { | |
| const interval = setInterval(() => { | |
| if (!isLoading) { | |
| console.log("🔄 Auto-refreshing traces..."); | |
| loadTraces(); | |
| } | |
| }, 60000); // 60 seconds (increased from 12s to avoid 429 errors) | |
| return () => clearInterval(interval); | |
| }, [loadTraces, isLoading]); | |
| const handleUploadTrace = () => { | |
| actions.setActiveView("upload"); | |
| }; | |
| return ( | |
| <TooltipProvider> | |
| <div className="p-6 space-y-6"> | |
| {/* Main Content */} | |
| <div className="space-y-6"> | |
| {/* Search and Filter Toolbar */} | |
| <div className="space-y-4"> | |
| {/* Search Bar */} | |
| <div className="relative"> | |
| <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> | |
| <Input | |
| placeholder="Search traces by name or description..." | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| className="pl-10" | |
| /> | |
| </div> | |
| {/* Filters Row */} | |
| <div className="flex flex-wrap items-center gap-4"> | |
| {/* Type Filter */} | |
| <div className="flex items-center gap-2"> | |
| <Filter className="h-4 w-4 text-muted-foreground" /> | |
| <Select value={typeFilter} onValueChange={setTypeFilter}> | |
| <SelectTrigger className="w-40"> | |
| <SelectValue placeholder="All Types" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="all">All Types</SelectItem> | |
| {uniqueTypes.map((type) => ( | |
| <SelectItem key={type} value={type}> | |
| {formatTraceType(type)} | |
| </SelectItem> | |
| ))} | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| {/* Status Filter */} | |
| <Select | |
| value={statusFilter} | |
| onValueChange={(value: string) => | |
| setStatusFilter(value as StatusFilter) | |
| } | |
| > | |
| <SelectTrigger className="w-40"> | |
| <SelectValue placeholder="All Status" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="all">All Status</SelectItem> | |
| <SelectItem value="processed">Processed</SelectItem> | |
| <SelectItem value="ready">Ready</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| {/* Sort Options */} | |
| <div className="flex items-center gap-2"> | |
| <ArrowUpDown className="h-4 w-4 text-muted-foreground" /> | |
| <Select | |
| value={sortField} | |
| onValueChange={(value: SortField) => setSortField(value)} | |
| > | |
| <SelectTrigger className="w-32"> | |
| <SelectValue placeholder="Sort by" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="date">Date</SelectItem> | |
| <SelectItem value="name">Name</SelectItem> | |
| <SelectItem value="type">Type</SelectItem> | |
| <SelectItem value="content">Content</SelectItem> | |
| <SelectItem value="status">Status</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => | |
| setSortDirection((prev) => | |
| prev === "asc" ? "desc" : "asc" | |
| ) | |
| } | |
| className="px-3" | |
| > | |
| {sortDirection === "asc" ? "↑" : "↓"} | |
| </Button> | |
| </div> | |
| {/* Clear Filters */} | |
| {hasActiveFilters && ( | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={clearFilters} | |
| className="gap-2" | |
| > | |
| <X className="h-4 w-4" /> | |
| Clear | |
| </Button> | |
| )} | |
| {/* Results Count */} | |
| <div className="ml-auto"> | |
| <Badge variant="outline" className="text-xs"> | |
| {filteredAndSortedTraces.length} of {traces.length} traces | |
| </Badge> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Simple Bulk Operations */} | |
| {selectedTraces.length > 0 && ( | |
| <div className="bg-muted/50 border rounded-lg p-3 flex items-center justify-between"> | |
| <span className="text-sm text-muted-foreground"> | |
| {selectedTraces.length} trace | |
| {selectedTraces.length !== 1 ? "s" : ""} selected | |
| </span> | |
| <div className="flex items-center gap-2"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => setSelectedTraces([])} | |
| > | |
| Clear | |
| </Button> | |
| <Button | |
| variant="destructive" | |
| size="sm" | |
| onClick={handleBulkDelete} | |
| > | |
| Delete Selected | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Content Area */} | |
| {isLoading ? ( | |
| <div className="space-y-4"> | |
| {[...Array(5)].map((_, i) => ( | |
| <div key={i} className="space-y-2 p-4 border rounded-lg"> | |
| <Skeleton className="h-5 w-3/4" /> | |
| <Skeleton className="h-4 w-1/2" /> | |
| <Skeleton className="h-3 w-1/4" /> | |
| </div> | |
| ))} | |
| </div> | |
| ) : traces.length === 0 ? ( | |
| <div className="py-12"> | |
| <EmptyState | |
| icon={FileText} | |
| title="No traces yet" | |
| description="Upload your first trace to start analyzing AI agent behavior patterns and generate insights." | |
| action={{ | |
| label: "Upload Trace", | |
| onClick: handleUploadTrace, | |
| }} | |
| > | |
| <div className="mt-6 text-center"> | |
| <p className="text-sm text-muted-foreground"> | |
| Supported formats: JSON, TXT, LOG | |
| </p> | |
| </div> | |
| </EmptyState> | |
| </div> | |
| ) : ( | |
| <div className="border rounded-lg overflow-hidden"> | |
| <div className="overflow-x-auto"> | |
| <table className="w-full"> | |
| <thead> | |
| <tr className="border-b bg-muted/50"> | |
| <th className="text-left p-4 font-medium w-12"> | |
| <input | |
| type="checkbox" | |
| checked={isAllSelected} | |
| ref={(checkbox) => { | |
| if (checkbox) | |
| checkbox.indeterminate = isPartiallySelected; | |
| }} | |
| onChange={(e) => handleSelectAll(e.target.checked)} | |
| className="rounded" | |
| /> | |
| </th> | |
| <th className="text-left p-4 font-medium"> | |
| <div className="flex items-center gap-1"> | |
| Name | |
| <Tooltip> | |
| <TooltipTrigger> | |
| <HelpCircle className="h-3 w-3 text-muted-foreground" /> | |
| </TooltipTrigger> | |
| <TooltipContent> | |
| <p> | |
| Name and description of the uploaded trace file | |
| </p> | |
| </TooltipContent> | |
| </Tooltip> | |
| </div> | |
| </th> | |
| <th className="text-left p-4 font-medium"> | |
| <div className="flex items-center gap-1"> | |
| Type | |
| <Tooltip> | |
| <TooltipTrigger> | |
| <HelpCircle className="h-3 w-3 text-muted-foreground" /> | |
| </TooltipTrigger> | |
| <TooltipContent> | |
| <p>Source or format of the trace data</p> | |
| </TooltipContent> | |
| </Tooltip> | |
| </div> | |
| </th> | |
| <th className="text-left p-4 font-medium"> | |
| <div className="flex items-center gap-1"> | |
| Date | |
| <Tooltip> | |
| <TooltipTrigger> | |
| <HelpCircle className="h-3 w-3 text-muted-foreground" /> | |
| </TooltipTrigger> | |
| <TooltipContent> | |
| <p>When the trace was uploaded to the system</p> | |
| </TooltipContent> | |
| </Tooltip> | |
| </div> | |
| </th> | |
| <th className="text-left p-4 font-medium"> | |
| <div className="flex items-center gap-1"> | |
| Content | |
| <Tooltip> | |
| <TooltipTrigger> | |
| <HelpCircle className="h-3 w-3 text-muted-foreground" /> | |
| </TooltipTrigger> | |
| <TooltipContent> | |
| <p> | |
| Character count and content size information | |
| </p> | |
| </TooltipContent> | |
| </Tooltip> | |
| </div> | |
| </th> | |
| <th className="text-left p-4 font-medium"> | |
| <div className="flex items-center gap-1"> | |
| Graphs | |
| <Tooltip> | |
| <TooltipTrigger> | |
| <HelpCircle className="h-3 w-3 text-muted-foreground" /> | |
| </TooltipTrigger> | |
| <TooltipContent> | |
| <p> | |
| Number of generated agent graphs for this trace | |
| </p> | |
| </TooltipContent> | |
| </Tooltip> | |
| </div> | |
| </th> | |
| <th className="text-right p-4 font-medium">Actions</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {filteredAndSortedTraces.map((trace) => ( | |
| <tr | |
| key={trace.id} | |
| className="border-b hover:bg-primary/5 active:bg-primary/10 cursor-pointer transition-all duration-200 hover:shadow-sm group" | |
| onClick={() => { | |
| actions.setSelectedTrace(trace); | |
| actions.setActiveView("trace-kg"); | |
| }} | |
| onMouseDown={(e) => { | |
| // Add pressed effect | |
| e.currentTarget.style.transform = "scale(0.995)"; | |
| e.currentTarget.style.boxShadow = | |
| "inset 0 2px 4px rgba(0,0,0,0.1)"; | |
| }} | |
| onMouseUp={(e) => { | |
| // Remove pressed effect | |
| e.currentTarget.style.transform = "scale(1)"; | |
| e.currentTarget.style.boxShadow = "none"; | |
| }} | |
| onMouseLeave={(e) => { | |
| // Reset on mouse leave | |
| e.currentTarget.style.transform = "scale(1)"; | |
| e.currentTarget.style.boxShadow = "none"; | |
| }} | |
| > | |
| <td | |
| className="p-4 w-12" | |
| onClick={(e) => e.stopPropagation()} | |
| > | |
| <input | |
| type="checkbox" | |
| checked={selectedTraces.includes( | |
| trace.id.toString() | |
| )} | |
| onChange={(e) => | |
| handleSelectTrace( | |
| trace.id.toString(), | |
| e.target.checked | |
| ) | |
| } | |
| className="rounded" | |
| /> | |
| </td> | |
| <td className="p-4"> | |
| <div className="flex items-center gap-3"> | |
| <div className="min-w-0 flex-1"> | |
| <div | |
| className="font-medium text-sm truncate group-hover:text-primary transition-colors" | |
| title={trace.filename} | |
| > | |
| {trace.filename} | |
| </div> | |
| {trace.description && ( | |
| <div className="text-xs text-muted-foreground truncate mt-1 max-w-md"> | |
| {trace.description} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </td> | |
| <td className="p-4"> | |
| <div className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 group-hover:bg-blue-200 transition-colors"> | |
| {formatTraceType(trace.trace_type)} | |
| </div> | |
| </td> | |
| <td className="p-4"> | |
| <div className="text-sm font-medium group-hover:text-primary transition-colors"> | |
| {formatDate( | |
| trace.upload_timestamp || trace.timestamp | |
| )} | |
| </div> | |
| <div className="text-xs text-muted-foreground"> | |
| {trace.upload_timestamp || trace.timestamp | |
| ? new Date( | |
| trace.upload_timestamp || trace.timestamp! | |
| ).toLocaleTimeString() | |
| : "No time available"} | |
| </div> | |
| </td> | |
| <td className="p-4"> | |
| <div className="text-sm font-medium group-hover:text-primary transition-colors"> | |
| {trace.character_count | |
| ? `${(trace.character_count / 1000).toFixed(0)}K` | |
| : "N/A"} | |
| </div> | |
| <div className="text-xs text-muted-foreground"> | |
| {trace.character_count | |
| ? `${trace.character_count.toLocaleString()} chars` | |
| : "No content"} | |
| </div> | |
| </td> | |
| <td className="p-4"> | |
| <div className="flex items-center gap-1"> | |
| {trace.knowledge_graphs && | |
| trace.knowledge_graphs.length > 0 ? ( | |
| <> | |
| <GitBranch className="h-3 w-3 text-muted-foreground group-hover:text-primary transition-colors" /> | |
| <span className="text-sm font-medium group-hover:text-primary transition-colors"> | |
| { | |
| trace.knowledge_graphs.filter( | |
| (kg) => | |
| kg.is_final === true || | |
| (kg.window_index === null && | |
| kg.window_total !== null) | |
| ).length | |
| } | |
| </span> | |
| </> | |
| ) : ( | |
| <span className="text-sm text-muted-foreground"> | |
| None | |
| </span> | |
| )} | |
| </div> | |
| </td> | |
| <td className="p-4"> | |
| <div className="flex items-center justify-end gap-2"> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={(e) => handleViewTrace(trace, e)} | |
| className="text-primary hover:text-primary/90" | |
| > | |
| <Eye className="h-4 w-4" /> | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={(e) => handleViewTraceDetails(trace, e)} | |
| className="text-info hover:text-info/90" | |
| > | |
| <Info className="h-4 w-4" /> | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={(e) => | |
| handleDeleteSingleTrace(trace.id.toString(), e) | |
| } | |
| className="text-red-500 hover:text-red-600" | |
| > | |
| <Trash2 className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </TooltipProvider> | |
| ); | |
| } | |