feat(evals-ui): Add filtering, bulk delete, tool consolidation, and run notes (#9837)

This commit is contained in:
Hannes Rudolph 2025-12-04 14:28:37 -07:00 committed by GitHub
parent ae655c5d29
commit 8433eafb05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 971 additions and 96 deletions

View file

@ -13,6 +13,9 @@ import {
exerciseLanguages,
createRun as _createRun,
deleteRun as _deleteRun,
updateRun as _updateRun,
getIncompleteRuns as _getIncompleteRuns,
deleteRunsByIds as _deleteRunsByIds,
createTask,
getExercisesForLanguage,
} from "@roo-code/evals"
@ -20,6 +23,9 @@ import {
import { CreateRun } from "@/lib/schemas"
import { redisClient } from "@/lib/server/redis"
// Storage base path for eval logs
const EVALS_STORAGE_PATH = "/tmp/evals/runs"
const EVALS_REPO_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../../evals")
export async function createRun({ suite, exercises = [], timeout, iterations = 1, ...values }: CreateRun) {
@ -214,3 +220,150 @@ export async function killRun(runId: number): Promise<KillRunResult> {
errors,
}
}
export type DeleteIncompleteRunsResult = {
success: boolean
deletedCount: number
deletedRunIds: number[]
storageErrors: string[]
}
/**
* Delete all incomplete runs (runs without a taskMetricsId/final score).
* Removes both database records and storage folders.
*/
export async function deleteIncompleteRuns(): Promise<DeleteIncompleteRunsResult> {
const storageErrors: string[] = []
// Get all incomplete runs
const incompleteRuns = await _getIncompleteRuns()
const runIds = incompleteRuns.map((run) => run.id)
if (runIds.length === 0) {
return {
success: true,
deletedCount: 0,
deletedRunIds: [],
storageErrors: [],
}
}
// Delete storage folders for each run
for (const runId of runIds) {
const storagePath = path.join(EVALS_STORAGE_PATH, String(runId))
try {
if (fs.existsSync(storagePath)) {
fs.rmSync(storagePath, { recursive: true, force: true })
console.log(`Deleted storage folder: ${storagePath}`)
}
} catch (error) {
console.error(`Failed to delete storage folder ${storagePath}:`, error)
storageErrors.push(`Failed to delete storage for run ${runId}`)
}
// Also try to clear Redis state for any potentially running incomplete runs
try {
const redis = await redisClient()
await redis.del(`heartbeat:${runId}`)
await redis.del(`runners:${runId}`)
} catch (error) {
// Non-critical error, just log it
console.error(`Failed to clear Redis state for run ${runId}:`, error)
}
}
// Delete from database
await _deleteRunsByIds(runIds)
revalidatePath("/runs")
return {
success: true,
deletedCount: runIds.length,
deletedRunIds: runIds,
storageErrors,
}
}
/**
* Get count of incomplete runs (for UI display)
*/
export async function getIncompleteRunsCount(): Promise<number> {
const incompleteRuns = await _getIncompleteRuns()
return incompleteRuns.length
}
/**
* Delete all runs older than 30 days.
* Removes both database records and storage folders.
*/
export async function deleteOldRuns(): Promise<DeleteIncompleteRunsResult> {
const storageErrors: string[] = []
// Get all runs older than 30 days
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
const { getRuns } = await import("@roo-code/evals")
const allRuns = await getRuns()
const oldRuns = allRuns.filter((run) => run.createdAt < thirtyDaysAgo)
const runIds = oldRuns.map((run) => run.id)
if (runIds.length === 0) {
return {
success: true,
deletedCount: 0,
deletedRunIds: [],
storageErrors: [],
}
}
// Delete storage folders for each run
for (const runId of runIds) {
const storagePath = path.join(EVALS_STORAGE_PATH, String(runId))
try {
if (fs.existsSync(storagePath)) {
fs.rmSync(storagePath, { recursive: true, force: true })
console.log(`Deleted storage folder: ${storagePath}`)
}
} catch (error) {
console.error(`Failed to delete storage folder ${storagePath}:`, error)
storageErrors.push(`Failed to delete storage for run ${runId}`)
}
// Also try to clear Redis state
try {
const redis = await redisClient()
await redis.del(`heartbeat:${runId}`)
await redis.del(`runners:${runId}`)
} catch (error) {
// Non-critical error, just log it
console.error(`Failed to clear Redis state for run ${runId}:`, error)
}
}
// Delete from database
await _deleteRunsByIds(runIds)
revalidatePath("/runs")
return {
success: true,
deletedCount: runIds.length,
deletedRunIds: runIds,
storageErrors,
}
}
/**
* Update the description of a run.
*/
export async function updateRunDescription(runId: number, description: string | null): Promise<{ success: boolean }> {
try {
await _updateRun(runId, { description })
revalidatePath("/runs")
revalidatePath(`/runs/${runId}`)
return { success: true }
} catch (error) {
console.error("Failed to update run description:", error)
return { success: false }
}
}

View file

@ -2,12 +2,12 @@ import { useCallback, useState, useRef } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Ellipsis, ClipboardList, Copy, Check, LoaderCircle, Trash, Settings, FileDown } from "lucide-react"
import { Ellipsis, ClipboardList, Copy, Check, LoaderCircle, Trash, Settings, FileDown, StickyNote } from "lucide-react"
import type { Run as EvalsRun, TaskMetrics as EvalsTaskMetrics } from "@roo-code/evals"
import type { ToolName } from "@roo-code/types"
import { deleteRun } from "@/actions/runs"
import { deleteRun, updateRunDescription } from "@/actions/runs"
import {
formatCurrency,
formatDateTime,
@ -20,6 +20,10 @@ import {
Button,
TableCell,
TableRow,
Textarea,
Tooltip,
TooltipContent,
TooltipTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@ -34,6 +38,7 @@ import {
AlertDialogTitle,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
ScrollArea,
@ -43,16 +48,41 @@ type RunProps = {
run: EvalsRun
taskMetrics: EvalsTaskMetrics | null
toolColumns: ToolName[]
consolidatedToolColumns: string[]
}
export function Run({ run, taskMetrics, toolColumns }: RunProps) {
export function Run({ run, taskMetrics, toolColumns, consolidatedToolColumns }: RunProps) {
const router = useRouter()
const [deleteRunId, setDeleteRunId] = useState<number>()
const [showSettings, setShowSettings] = useState(false)
const [isExportingLogs, setIsExportingLogs] = useState(false)
const [showNotesDialog, setShowNotesDialog] = useState(false)
const [editingDescription, setEditingDescription] = useState(run.description ?? "")
const [isSavingNotes, setIsSavingNotes] = useState(false)
const continueRef = useRef<HTMLButtonElement>(null)
const { isPending, copyRun, copied } = useCopyRun(run.id)
const hasDescription = Boolean(run.description && run.description.trim().length > 0)
const handleSaveDescription = useCallback(async () => {
setIsSavingNotes(true)
try {
const result = await updateRunDescription(run.id, editingDescription.trim() || null)
if (result.success) {
toast.success("Description saved")
setShowNotesDialog(false)
router.refresh()
} else {
toast.error("Failed to save description")
}
} catch (error) {
console.error("Error saving description:", error)
toast.error("Failed to save description")
} finally {
setIsSavingNotes(false)
}
}, [run.id, editingDescription, router])
const onExportFailedLogs = useCallback(async () => {
if (run.failed === 0) {
toast.error("No failed tasks to export")
@ -140,6 +170,68 @@ export function Run({ run, taskMetrics, toolColumns }: RunProps) {
</div>
)}
</TableCell>
{consolidatedToolColumns.length > 0 && (
<TableCell className="text-xs text-center">
{taskMetrics?.toolUsage ? (
(() => {
// Calculate aggregated stats for consolidated tools
let totalAttempts = 0
let totalFailures = 0
const breakdown: Array<{ tool: string; attempts: number; rate: string }> = []
for (const toolName of consolidatedToolColumns) {
const usage = taskMetrics.toolUsage[toolName as ToolName]
if (usage) {
totalAttempts += usage.attempts
totalFailures += usage.failures
const rate =
usage.attempts > 0
? `${Math.round(((usage.attempts - usage.failures) / usage.attempts) * 100)}%`
: "0%"
breakdown.push({ tool: toolName, attempts: usage.attempts, rate })
}
}
const consolidatedRate =
totalAttempts > 0 ? ((totalAttempts - totalFailures) / totalAttempts) * 100 : 100
const rateColor =
consolidatedRate === 100
? "text-muted-foreground"
: consolidatedRate >= 80
? "text-yellow-500"
: "text-red-500"
return totalAttempts > 0 ? (
<Tooltip>
<TooltipTrigger>
<div className="flex flex-col items-center">
<span className="font-medium">{totalAttempts}</span>
<span className={rateColor}>{Math.round(consolidatedRate)}%</span>
</div>
</TooltipTrigger>
<TooltipContent>
<div className="text-xs">
<div className="font-semibold mb-1">Consolidated Tools:</div>
{breakdown.map(({ tool, attempts, rate }) => (
<div key={tool} className="flex justify-between gap-4">
<span>{tool}:</span>
<span>
{attempts} ({rate})
</span>
</div>
))}
</div>
</TooltipContent>
</Tooltip>
) : (
<span className="text-muted-foreground">-</span>
)
})()
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
)}
{toolColumns.map((toolName) => {
const usage = taskMetrics?.toolUsage?.[toolName]
const successRate =
@ -166,80 +258,107 @@ export function Run({ run, taskMetrics, toolColumns }: RunProps) {
<TableCell>{taskMetrics && formatCurrency(taskMetrics.cost)}</TableCell>
<TableCell>{taskMetrics && formatDuration(taskMetrics.duration)}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<Button variant="ghost" size="icon" asChild>
<DropdownMenuTrigger data-dropdown-trigger>
<Ellipsis />
</DropdownMenuTrigger>
</Button>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/runs/${run.id}`}>
<div className="flex items-center gap-1">
{/* Note Icon */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={hasDescription ? "" : "opacity-30 hover:opacity-60"}
onClick={(e) => {
e.stopPropagation()
setEditingDescription(run.description ?? "")
setShowNotesDialog(true)
}}>
<StickyNote className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
{hasDescription ? (
<div className="whitespace-pre-wrap">{run.description}</div>
) : (
<div className="text-muted-foreground">No description. Click to add one.</div>
)}
</TooltipContent>
</Tooltip>
{/* More Actions Menu */}
<DropdownMenu>
<Button variant="ghost" size="icon" asChild>
<DropdownMenuTrigger data-dropdown-trigger>
<Ellipsis />
</DropdownMenuTrigger>
</Button>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/runs/${run.id}`}>
<div className="flex items-center gap-1">
<ClipboardList />
<div>View Tasks</div>
</div>
</Link>
</DropdownMenuItem>
{run.settings && (
<DropdownMenuItem onClick={() => setShowSettings(true)}>
<div className="flex items-center gap-1">
<Settings />
<div>View Settings</div>
</div>
</DropdownMenuItem>
)}
{run.taskMetricsId && (
<DropdownMenuItem onClick={() => copyRun()} disabled={isPending || copied}>
<div className="flex items-center gap-1">
{isPending ? (
<>
<LoaderCircle className="animate-spin" />
Copying...
</>
) : copied ? (
<>
<Check />
Copied!
</>
) : (
<>
<Copy />
Copy to Production
</>
)}
</div>
</DropdownMenuItem>
)}
{run.failed > 0 && (
<DropdownMenuItem onClick={onExportFailedLogs} disabled={isExportingLogs}>
<div className="flex items-center gap-1">
{isExportingLogs ? (
<>
<LoaderCircle className="animate-spin" />
Exporting...
</>
) : (
<>
<FileDown />
Export Failed Logs
</>
)}
</div>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
setDeleteRunId(run.id)
setTimeout(() => continueRef.current?.focus(), 0)
}}>
<div className="flex items-center gap-1">
<ClipboardList />
<div>View Tasks</div>
</div>
</Link>
</DropdownMenuItem>
{run.settings && (
<DropdownMenuItem onClick={() => setShowSettings(true)}>
<div className="flex items-center gap-1">
<Settings />
<div>View Settings</div>
<Trash />
<div>Delete</div>
</div>
</DropdownMenuItem>
)}
{run.taskMetricsId && (
<DropdownMenuItem onClick={() => copyRun()} disabled={isPending || copied}>
<div className="flex items-center gap-1">
{isPending ? (
<>
<LoaderCircle className="animate-spin" />
Copying...
</>
) : copied ? (
<>
<Check />
Copied!
</>
) : (
<>
<Copy />
Copy to Production
</>
)}
</div>
</DropdownMenuItem>
)}
{run.failed > 0 && (
<DropdownMenuItem onClick={onExportFailedLogs} disabled={isExportingLogs}>
<div className="flex items-center gap-1">
{isExportingLogs ? (
<>
<LoaderCircle className="animate-spin" />
Exporting...
</>
) : (
<>
<FileDown />
Export Failed Logs
</>
)}
</div>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
setDeleteRunId(run.id)
setTimeout(() => continueRef.current?.focus(), 0)
}}>
<div className="flex items-center gap-1">
<Trash />
<div>Delete</div>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
<AlertDialog open={!!deleteRunId} onOpenChange={() => setDeleteRunId(undefined)}>
@ -268,6 +387,39 @@ export function Run({ run, taskMetrics, toolColumns }: RunProps) {
</ScrollArea>
</DialogContent>
</Dialog>
{/* Notes/Description Dialog */}
<Dialog open={showNotesDialog} onOpenChange={setShowNotesDialog}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Run Description</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Textarea
placeholder="Add a description or notes for this run..."
value={editingDescription}
onChange={(e) => setEditingDescription(e.target.value)}
rows={4}
className="resize-none"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowNotesDialog(false)}>
Cancel
</Button>
<Button onClick={handleSaveDescription} disabled={isSavingNotes}>
{isSavingNotes ? (
<>
<LoaderCircle className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
"Save"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View file

@ -1,14 +1,45 @@
"use client"
import { useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { ArrowDown, ArrowUp, ArrowUpDown, Rocket } from "lucide-react"
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
Combine,
Ellipsis,
LoaderCircle,
Rocket,
RotateCcw,
Trash2,
X,
} from "lucide-react"
import { toast } from "sonner"
import type { Run, TaskMetrics } from "@roo-code/evals"
import type { ToolName } from "@roo-code/types"
import { deleteIncompleteRuns, deleteOldRuns } from "@/actions/runs"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
MultiSelect,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableBody,
TableCell,
@ -26,6 +57,41 @@ type RunWithTaskMetrics = Run & { taskMetrics: TaskMetrics | null }
type SortColumn = "model" | "provider" | "passed" | "failed" | "percent" | "cost" | "duration" | "createdAt"
type SortDirection = "asc" | "desc"
type TimeframeOption = "all" | "24h" | "7d" | "30d" | "90d"
const TIMEFRAME_OPTIONS: { value: TimeframeOption; label: string }[] = [
{ value: "all", label: "All time" },
{ value: "24h", label: "Last 24 hours" },
{ value: "7d", label: "Last 7 days" },
{ value: "30d", label: "Last 30 days" },
{ value: "90d", label: "Last 90 days" },
]
// LocalStorage keys
const STORAGE_KEYS = {
TIMEFRAME: "evals-runs-timeframe",
MODEL_FILTER: "evals-runs-model-filter",
PROVIDER_FILTER: "evals-runs-provider-filter",
CONSOLIDATED_TOOLS: "evals-runs-consolidated-tools",
}
function getTimeframeStartDate(timeframe: TimeframeOption): Date | null {
if (timeframe === "all") return null
const now = new Date()
switch (timeframe) {
case "24h":
return new Date(now.getTime() - 24 * 60 * 60 * 1000)
case "7d":
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
case "30d":
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
case "90d":
return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000)
default:
return null
}
}
// Generate abbreviation from tool name (e.g., "read_file" -> "RF", "list_code_definition_names" -> "LCDN")
function getToolAbbreviation(toolName: string): string {
return toolName
@ -54,6 +120,109 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) {
const [sortColumn, setSortColumn] = useState<SortColumn | null>("createdAt")
const [sortDirection, setSortDirection] = useState<SortDirection>("desc")
// Filter state - initialize from localStorage
const [timeframeFilter, setTimeframeFilter] = useState<TimeframeOption>(() => {
if (typeof window === "undefined") return "all"
const stored = localStorage.getItem(STORAGE_KEYS.TIMEFRAME)
return (stored as TimeframeOption) || "all"
})
const [modelFilter, setModelFilter] = useState<string[]>(() => {
if (typeof window === "undefined") return []
const stored = localStorage.getItem(STORAGE_KEYS.MODEL_FILTER)
return stored ? JSON.parse(stored) : []
})
const [providerFilter, setProviderFilter] = useState<string[]>(() => {
if (typeof window === "undefined") return []
const stored = localStorage.getItem(STORAGE_KEYS.PROVIDER_FILTER)
return stored ? JSON.parse(stored) : []
})
// Tool column consolidation state - initialize from localStorage
const [consolidatedToolColumns, setConsolidatedToolColumns] = useState<string[]>(() => {
if (typeof window === "undefined") return []
const stored = localStorage.getItem(STORAGE_KEYS.CONSOLIDATED_TOOLS)
return stored ? JSON.parse(stored) : []
})
// Delete runs state
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [showDeleteOldConfirm, setShowDeleteOldConfirm] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
// Persist filters to localStorage
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.TIMEFRAME, timeframeFilter)
}, [timeframeFilter])
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.MODEL_FILTER, JSON.stringify(modelFilter))
}, [modelFilter])
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.PROVIDER_FILTER, JSON.stringify(providerFilter))
}, [providerFilter])
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.CONSOLIDATED_TOOLS, JSON.stringify(consolidatedToolColumns))
}, [consolidatedToolColumns])
// Count incomplete runs (runs without taskMetricsId)
const incompleteRunsCount = useMemo(() => {
return runs.filter((run) => run.taskMetrics === null).length
}, [runs])
// Count runs older than 30 days
const oldRunsCount = useMemo(() => {
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
return runs.filter((run) => run.createdAt < thirtyDaysAgo).length
}, [runs])
const handleDeleteIncompleteRuns = useCallback(async () => {
setIsDeleting(true)
try {
const result = await deleteIncompleteRuns()
if (result.success) {
toast.success(`Deleted ${result.deletedCount} incomplete run${result.deletedCount !== 1 ? "s" : ""}`)
if (result.storageErrors.length > 0) {
toast.warning(`Some storage folders could not be deleted: ${result.storageErrors.length} errors`)
}
router.refresh()
} else {
toast.error("Failed to delete incomplete runs")
}
} catch (error) {
console.error("Error deleting incomplete runs:", error)
toast.error("Failed to delete incomplete runs")
} finally {
setIsDeleting(false)
setShowDeleteConfirm(false)
}
}, [router])
const handleDeleteOldRuns = useCallback(async () => {
setIsDeleting(true)
try {
const result = await deleteOldRuns()
if (result.success) {
toast.success(
`Deleted ${result.deletedCount} run${result.deletedCount !== 1 ? "s" : ""} older than 30 days`,
)
if (result.storageErrors.length > 0) {
toast.warning(`Some storage folders could not be deleted: ${result.storageErrors.length} errors`)
}
router.refresh()
} else {
toast.error("Failed to delete old runs")
}
} catch (error) {
console.error("Error deleting old runs:", error)
toast.error("Failed to delete old runs")
} finally {
setIsDeleting(false)
setShowDeleteOldConfirm(false)
}
}, [router])
const handleSort = (column: SortColumn) => {
if (sortColumn === column) {
setSortDirection(sortDirection === "asc" ? "desc" : "asc")
@ -63,11 +232,59 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) {
}
}
// Collect all unique tool names from all runs and sort by total attempts
const toolColumns = useMemo<ToolName[]>(() => {
// Derive unique models and providers from runs
const modelOptions = useMemo(() => {
const models = new Set<string>()
for (const run of runs) {
if (run.model) models.add(run.model)
}
return Array.from(models)
.sort()
.map((model) => ({ label: model, value: model }))
}, [runs])
const providerOptions = useMemo(() => {
const providers = new Set<string>()
for (const run of runs) {
const provider = run.settings?.apiProvider
if (provider) providers.add(provider)
}
return Array.from(providers)
.sort()
.map((provider) => ({ label: provider, value: provider }))
}, [runs])
// Filter runs based on filter state
const filteredRuns = useMemo(() => {
return runs.filter((run) => {
// Timeframe filter
const timeframeStart = getTimeframeStartDate(timeframeFilter)
if (timeframeStart && run.createdAt < timeframeStart) {
return false
}
// Model filter
if (modelFilter.length > 0 && !modelFilter.includes(run.model)) {
return false
}
// Provider filter
if (providerFilter.length > 0) {
const provider = run.settings?.apiProvider
if (!provider || !providerFilter.includes(provider)) {
return false
}
}
return true
})
}, [runs, timeframeFilter, modelFilter, providerFilter])
// Collect all unique tool names from filtered runs and sort by total attempts
const allToolColumns = useMemo<ToolName[]>(() => {
const toolTotals = new Map<ToolName, number>()
for (const run of runs) {
for (const run of filteredRuns) {
if (run.taskMetrics?.toolUsage) {
for (const [toolName, usage] of Object.entries(run.taskMetrics.toolUsage)) {
const tool = toolName as ToolName
@ -81,13 +298,32 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) {
return Array.from(toolTotals.entries())
.sort((a, b) => b[1] - a[1])
.map(([name]): ToolName => name)
}, [runs])
}, [filteredRuns])
// Sort runs based on current sort column and direction
// Tool column options for the consolidation dropdown
const toolColumnOptions = useMemo(() => {
return allToolColumns.map((tool) => ({
label: tool,
value: tool,
}))
}, [allToolColumns])
// Separate consolidated and individual tool columns
const individualToolColumns = useMemo(() => {
return allToolColumns.filter((tool) => !consolidatedToolColumns.includes(tool))
}, [allToolColumns, consolidatedToolColumns])
// Create a "consolidated" column if any tools are selected for consolidation
const hasConsolidatedColumn = consolidatedToolColumns.length > 0
// Use individualToolColumns for rendering
const toolColumns = individualToolColumns
// Sort filtered runs based on current sort column and direction
const sortedRuns = useMemo(() => {
if (!sortColumn) return runs
if (!sortColumn) return filteredRuns
return [...runs].sort((a, b) => {
return [...filteredRuns].sort((a, b) => {
let aVal: string | number | Date | null = null
let bVal: string | number | Date | null = null
@ -139,14 +375,170 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) {
return sortDirection === "asc" ? comparison : -comparison
})
}, [runs, sortColumn, sortDirection])
}, [filteredRuns, sortColumn, sortDirection])
// Calculate colSpan for empty state (7 base columns + dynamic tools + 3 end columns)
const totalColumns = 7 + toolColumns.length + 3
// Calculate colSpan for empty state (7 base columns + dynamic tools + consolidated column + 3 end columns)
const totalColumns = 7 + toolColumns.length + (hasConsolidatedColumn ? 1 : 0) + 3
// Check if any filters or settings are active
const hasActiveFilters = timeframeFilter !== "all" || modelFilter.length > 0 || providerFilter.length > 0
const hasConsolidatedTools = consolidatedToolColumns.length > 0
const hasAnyCustomization = hasActiveFilters || hasConsolidatedTools
const clearAllFilters = () => {
setTimeframeFilter("all")
setModelFilter([])
setProviderFilter([])
}
const resetAll = () => {
setTimeframeFilter("all")
setModelFilter([])
setProviderFilter([])
setConsolidatedToolColumns([])
localStorage.removeItem(STORAGE_KEYS.TIMEFRAME)
localStorage.removeItem(STORAGE_KEYS.MODEL_FILTER)
localStorage.removeItem(STORAGE_KEYS.PROVIDER_FILTER)
localStorage.removeItem(STORAGE_KEYS.CONSOLIDATED_TOOLS)
}
return (
<>
<Table className="border border-t-0">
{/* Filter Controls */}
<div className="flex items-center gap-4 p-4 border border-b-0 rounded-t-md bg-muted/30">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">Timeframe:</span>
<Select
value={timeframeFilter}
onValueChange={(value) => setTimeframeFilter(value as TimeframeOption)}>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIMEFRAME_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">Model:</span>
<MultiSelect
options={modelOptions}
value={modelFilter}
onValueChange={setModelFilter}
placeholder="All models"
className="w-[200px]"
maxCount={1}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">Provider:</span>
<MultiSelect
options={providerOptions}
value={providerFilter}
onValueChange={setProviderFilter}
placeholder="All providers"
className="w-[180px]"
maxCount={1}
/>
</div>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2">
<Combine className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">Consolidate:</span>
</div>
</TooltipTrigger>
<TooltipContent>Select tool columns to consolidate into a combined column</TooltipContent>
</Tooltip>
<div className="relative min-w-[100px] w-fit max-w-[140px]">
<div className={consolidatedToolColumns.length > 0 ? "[&>div>div]:invisible" : ""}>
<MultiSelect
options={toolColumnOptions}
value={consolidatedToolColumns}
onValueChange={setConsolidatedToolColumns}
placeholder="None"
className="w-full min-w-[100px]"
maxCount={0}
popoverAutoWidth
footer={
hasAnyCustomization && (
<Button
variant="ghost"
size="sm"
className="w-full justify-start text-muted-foreground hover:text-foreground"
onClick={resetAll}>
<RotateCcw className="h-4 w-4 mr-2" />
Reset all filters & consolidation
</Button>
)
}
/>
</div>
{consolidatedToolColumns.length > 0 && (
<div className="absolute inset-0 flex items-center px-3 pointer-events-none">
<span className="text-sm font-medium whitespace-nowrap">
{consolidatedToolColumns.length} tool
{consolidatedToolColumns.length !== 1 ? "s" : ""}
</span>
</div>
)}
</div>
</div>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={clearAllFilters}>
<X className="h-4 w-4 mr-1" />
Clear filters
</Button>
)}
<div className="flex items-center gap-2 ml-auto">
{/* Bulk Actions Menu */}
{(incompleteRunsCount > 0 || oldRunsCount > 0) && (
<DropdownMenu>
<Button variant="ghost" size="sm" asChild>
<DropdownMenuTrigger disabled={isDeleting}>
<Ellipsis className="h-4 w-4" />
</DropdownMenuTrigger>
</Button>
<DropdownMenuContent align="end">
{incompleteRunsCount > 0 && (
<DropdownMenuItem
onClick={() => setShowDeleteConfirm(true)}
disabled={isDeleting}
className="text-destructive focus:text-destructive">
<Trash2 className="h-4 w-4 mr-2" />
Delete {incompleteRunsCount} incomplete run
{incompleteRunsCount !== 1 ? "s" : ""}
</DropdownMenuItem>
)}
{oldRunsCount > 0 && (
<DropdownMenuItem
onClick={() => setShowDeleteOldConfirm(true)}
disabled={isDeleting}
className="text-destructive focus:text-destructive">
<Trash2 className="h-4 w-4 mr-2" />
Delete {oldRunsCount} run{oldRunsCount !== 1 ? "s" : ""} over 30d
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
<div className="text-sm text-muted-foreground">
{filteredRuns.length} of {runs.length} runs
</div>
</div>
</div>
<Table className="border border-t-0 rounded-t-none">
<TableHeader>
<TableRow>
<TableHead
@ -188,6 +580,23 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) {
</div>
</TableHead>
<TableHead>Tokens</TableHead>
{hasConsolidatedColumn && (
<TableHead className="text-xs text-center">
<Tooltip>
<TooltipTrigger>
<Combine className="h-3 w-3 inline" />
</TooltipTrigger>
<TooltipContent>
<div className="text-xs">
<div className="font-semibold mb-1">Consolidated Tools:</div>
{consolidatedToolColumns.map((tool) => (
<div key={tool}>{tool}</div>
))}
</div>
</TooltipContent>
</Tooltip>
</TableHead>
)}
{toolColumns.map((toolName) => (
<TableHead key={toolName} className="text-xs text-center">
<Tooltip>
@ -214,16 +623,34 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) {
<TableBody>
{sortedRuns.length ? (
sortedRuns.map(({ taskMetrics, ...run }) => (
<Row key={run.id} run={run} taskMetrics={taskMetrics} toolColumns={toolColumns} />
<Row
key={run.id}
run={run}
taskMetrics={taskMetrics}
toolColumns={toolColumns}
consolidatedToolColumns={consolidatedToolColumns}
/>
))
) : (
<TableRow>
<TableCell colSpan={totalColumns} className="text-center">
No eval runs yet.
<Button variant="link" onClick={() => router.push("/runs/new")}>
Launch
</Button>
one now.
<TableCell colSpan={totalColumns} className="text-center py-8">
{runs.length === 0 ? (
<>
No eval runs yet.
<Button variant="link" onClick={() => router.push("/runs/new")}>
Launch
</Button>
one now.
</>
) : (
<>
No runs match the current filters.
<Button variant="link" onClick={clearAllFilters}>
Clear filters
</Button>
to see all runs.
</>
)}
</TableCell>
</TableRow>
)}
@ -235,6 +662,70 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) {
onClick={() => router.push("/runs/new")}>
<Rocket className="size-6" />
</Button>
{/* Delete Incomplete Runs Confirmation Dialog */}
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Incomplete Runs</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete {incompleteRunsCount} incomplete run
{incompleteRunsCount !== 1 ? "s" : ""}? This will permanently remove all database records
and storage folders for these runs. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteIncompleteRuns}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{isDeleting ? (
<>
<LoaderCircle className="h-4 w-4 mr-2 animate-spin" />
Deleting...
</>
) : (
<>
Delete {incompleteRunsCount} run{incompleteRunsCount !== 1 ? "s" : ""}
</>
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Delete Old Runs Confirmation Dialog */}
<AlertDialog open={showDeleteOldConfirm} onOpenChange={setShowDeleteOldConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Old Runs</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete {oldRunsCount} run{oldRunsCount !== 1 ? "s" : ""} older than
30 days? This will permanently remove all database records and storage folders for these
runs. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteOldRuns}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{isDeleting ? (
<>
<LoaderCircle className="h-4 w-4 mr-2 animate-spin" />
Deleting...
</>
) : (
<>
Delete {oldRunsCount} run{oldRunsCount !== 1 ? "s" : ""}
</>
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View file

@ -84,6 +84,18 @@ interface MultiSelectProps extends React.HTMLAttributes<HTMLDivElement>, Variant
* Optional, can be used to add custom styles.
*/
className?: string
/**
* If true, popover width will auto-size to content instead of matching trigger width.
* Optional, defaults to false.
*/
popoverAutoWidth?: boolean
/**
* Optional footer content to render at the bottom of the popover.
* Useful for adding reset buttons or other actions.
*/
footer?: React.ReactNode
}
export const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
@ -97,6 +109,8 @@ export const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
placeholder = "Select options",
maxCount = 3,
modalPopover = false,
popoverAutoWidth = false,
footer,
className,
...props
},
@ -243,7 +257,7 @@ export const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
</div>
</PopoverTrigger>
<PopoverContent
className="p-0 w-[var(--radix-popover-trigger-width)]"
className={cn("p-0", popoverAutoWidth ? "w-auto" : "w-[var(--radix-popover-trigger-width)]")}
align="start"
onEscapeKeyDown={() => setIsPopoverOpen(false)}>
<Command filter={onFilter}>
@ -276,6 +290,7 @@ export const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
</CommandGroup>
</CommandList>
</Command>
{footer && <div className="border-t p-2">{footer}</div>}
</PopoverContent>
</Popover>
)

View file

@ -45,7 +45,7 @@ export const formatTokens = (tokens: number) => {
}
export const formatToolUsageSuccessRate = (usage: { attempts: number; failures: number }) =>
usage.attempts === 0 ? "0%" : `${(((usage.attempts - usage.failures) / usage.attempts) * 100).toFixed(1)}%`
usage.attempts === 0 ? "0%" : `${Math.round(((usage.attempts - usage.failures) / usage.attempts) * 100)}%`
export const formatDateTime = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {

View file

@ -134,3 +134,67 @@ export const deleteRun = async (runId: number) => {
await db.delete(schema.taskMetrics).where(inArray(schema.taskMetrics.id, taskMetricsIds))
}
/**
* Get all runs without a taskMetricsId (incomplete runs)
*/
export const getIncompleteRuns = async () => {
return db.query.runs.findMany({
where: sql`${schema.runs.taskMetricsId} IS NULL`,
columns: { id: true },
})
}
/**
* Delete multiple runs by their IDs
*/
export const deleteRunsByIds = async (runIds: number[]) => {
if (runIds.length === 0) return
// Get all tasks for these runs
const tasks = await db.query.tasks.findMany({
where: inArray(schema.tasks.runId, runIds),
columns: { id: true, taskMetricsId: true },
})
const taskIds = tasks.map(({ id }) => id)
// Get run taskMetricsIds
const runs = await db.query.runs.findMany({
where: inArray(schema.runs.id, runIds),
columns: { taskMetricsId: true },
})
// Delete tool errors for tasks
if (taskIds.length > 0) {
await db.delete(schema.toolErrors).where(inArray(schema.toolErrors.taskId, taskIds))
}
// Delete tasks
await db.delete(schema.tasks).where(inArray(schema.tasks.runId, runIds))
// Delete tool errors for runs
await db.delete(schema.toolErrors).where(inArray(schema.toolErrors.runId, runIds))
// Delete from tables that exist in DB but not in drizzle schema
// Using individual deletes since drizzle's sql template doesn't support custom table schemas
for (const runId of runIds) {
await db.execute(sql`DELETE FROM "cpuMetrics" WHERE run_id = ${runId}`)
await db.execute(sql`DELETE FROM "notes" WHERE run_id = ${runId}`)
}
// Delete runs
await db.delete(schema.runs).where(inArray(schema.runs.id, runIds))
// Delete task metrics
const taskMetricsIds = [
...tasks
.map(({ taskMetricsId }) => taskMetricsId)
.filter((id): id is number => id !== null && id !== undefined),
...runs.map(({ taskMetricsId }) => taskMetricsId).filter((id): id is number => id !== null && id !== undefined),
]
if (taskMetricsIds.length > 0) {
await db.delete(schema.taskMetrics).where(inArray(schema.taskMetrics.id, taskMetricsIds))
}
}