|
|
@@ -2,12 +2,14 @@
|
|
|
|
|
|
import { useMemo, useState, useCallback, useEffect } from "react"
|
|
|
import { toast } from "sonner"
|
|
|
-import { LoaderCircle, FileText, Copy, Check } from "lucide-react"
|
|
|
+import { LoaderCircle, FileText, Copy, Check, StopCircle } from "lucide-react"
|
|
|
|
|
|
import type { Run, TaskMetrics as _TaskMetrics, Task } from "@roo-code/evals"
|
|
|
+import type { ToolName } from "@roo-code/types"
|
|
|
|
|
|
import { formatCurrency, formatDuration, formatTokens, formatToolUsageSuccessRate } from "@/lib/formatters"
|
|
|
import { useRunStatus } from "@/hooks/use-run-status"
|
|
|
+import { killRun } from "@/actions/runs"
|
|
|
import {
|
|
|
Table,
|
|
|
TableBody,
|
|
|
@@ -24,6 +26,14 @@ import {
|
|
|
DialogTitle,
|
|
|
ScrollArea,
|
|
|
Button,
|
|
|
+ AlertDialog,
|
|
|
+ AlertDialogAction,
|
|
|
+ AlertDialogCancel,
|
|
|
+ AlertDialogContent,
|
|
|
+ AlertDialogDescription,
|
|
|
+ AlertDialogFooter,
|
|
|
+ AlertDialogHeader,
|
|
|
+ AlertDialogTitle,
|
|
|
} from "@/components/ui"
|
|
|
|
|
|
import { TaskStatus } from "./task-status"
|
|
|
@@ -51,19 +61,80 @@ type HighlightPattern = {
|
|
|
}
|
|
|
|
|
|
const HIGHLIGHT_PATTERNS: HighlightPattern[] = [
|
|
|
- // Timestamps [YYYY-MM-DDTHH:MM:SS.sssZ]
|
|
|
- { pattern: /\[(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)\]/g, className: "text-blue-400" },
|
|
|
- // Log levels
|
|
|
+ // Log levels - styled as badges
|
|
|
{ pattern: /\|\s*(INFO)\s*\|/g, className: "text-green-400", wrapGroup: 1 },
|
|
|
{ pattern: /\|\s*(WARN|WARNING)\s*\|/g, className: "text-yellow-400", wrapGroup: 1 },
|
|
|
- { pattern: /\|\s*(ERROR)\s*\|/g, className: "text-red-400", wrapGroup: 1 },
|
|
|
+ { pattern: /\|\s*(ERROR)\s*\|/g, className: "text-red-400 font-semibold", wrapGroup: 1 },
|
|
|
{ pattern: /\|\s*(DEBUG)\s*\|/g, className: "text-gray-400", wrapGroup: 1 },
|
|
|
- // Task identifiers
|
|
|
- { pattern: /(taskCreated|taskFocused|taskStarted|taskCompleted|EvalPass|EvalFail)/g, className: "text-purple-400" },
|
|
|
+ // Task identifiers - important events
|
|
|
+ {
|
|
|
+ pattern: /(taskCreated|taskFocused|taskStarted|taskCompleted|taskAborted|taskResumable)/g,
|
|
|
+ className: "text-purple-400 font-medium",
|
|
|
+ },
|
|
|
+ // Tool failures - highlight in red
|
|
|
+ { pattern: /(taskToolFailed)/g, className: "text-red-400 font-bold" },
|
|
|
+ { pattern: /(Tool execution failed|tool.*failed|failed.*tool)/gi, className: "text-red-400" },
|
|
|
+ { pattern: /(EvalPass)/g, className: "text-green-400 font-bold" },
|
|
|
+ { pattern: /(EvalFail)/g, className: "text-red-400 font-bold" },
|
|
|
// Message arrows
|
|
|
{ pattern: /→/g, className: "text-cyan-400" },
|
|
|
+ // Tool names in quotes
|
|
|
+ { pattern: /"(tool)":\s*"([^"]+)"/g, className: "text-orange-400" },
|
|
|
+ // JSON keys
|
|
|
+ { pattern: /"([^"]+)":/g, className: "text-sky-300" },
|
|
|
+ // Boolean values
|
|
|
+ { pattern: /:\s*(true|false)/g, className: "text-amber-400", wrapGroup: 1 },
|
|
|
+ // Numbers
|
|
|
+ { pattern: /:\s*(-?\d+\.?\d*)/g, className: "text-emerald-400", wrapGroup: 1 },
|
|
|
]
|
|
|
|
|
|
+// Extract timestamp from a log line and return elapsed time from baseline
|
|
|
+function formatElapsedTime(timestamp: string, baselineMs: number): string {
|
|
|
+ const currentMs = new Date(timestamp).getTime()
|
|
|
+ const elapsedMs = currentMs - baselineMs
|
|
|
+ const totalSeconds = Math.floor(elapsedMs / 1000)
|
|
|
+ const minutes = Math.floor(totalSeconds / 60)
|
|
|
+ const seconds = totalSeconds % 60
|
|
|
+ return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`
|
|
|
+}
|
|
|
+
|
|
|
+// Extract the first timestamp from the log to use as baseline
|
|
|
+function extractFirstTimestamp(log: string): number | null {
|
|
|
+ // Match timestamp at start of line: [2025-11-28T09:35:23.187Z | ... or [2025-11-28T09:35:23.187Z]
|
|
|
+ const match = log.match(/\[(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)[\s|\]]/)
|
|
|
+ const isoString = match?.[1]
|
|
|
+ if (!isoString) return null
|
|
|
+ return new Date(isoString).getTime()
|
|
|
+}
|
|
|
+
|
|
|
+// Simplify log line by removing redundant metadata
|
|
|
+function simplifyLogLine(line: string, baselineMs: number | null): { timestamp: string; simplified: string } {
|
|
|
+ // Extract timestamp - matches [2025-11-28T09:35:23.187Z | ... format
|
|
|
+ const timestampMatch = line.match(/\[(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)[\s|\]]/)
|
|
|
+ const isoTimestamp = timestampMatch?.[1]
|
|
|
+ if (!isoTimestamp) {
|
|
|
+ return { timestamp: "", simplified: line }
|
|
|
+ }
|
|
|
+
|
|
|
+ const timestamp = baselineMs !== null ? formatElapsedTime(isoTimestamp, baselineMs) : isoTimestamp.slice(11, 19)
|
|
|
+
|
|
|
+ // Remove the timestamp from the line (handles both [timestamp] and [timestamp | formats)
|
|
|
+ let simplified = line.replace(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\s*\|?\s*/, "")
|
|
|
+
|
|
|
+ // Remove redundant metadata: pid, run, task IDs (they're same for entire log)
|
|
|
+ simplified = simplified.replace(/\|\s*pid:\d+\s*/g, "")
|
|
|
+ simplified = simplified.replace(/\|\s*run:\d+\s*/g, "")
|
|
|
+ simplified = simplified.replace(/\|\s*task:\d+\s*/g, "")
|
|
|
+ simplified = simplified.replace(/runTask\s*\|\s*/g, "")
|
|
|
+
|
|
|
+ // Clean up extra pipes, spaces, and trailing brackets
|
|
|
+ simplified = simplified.replace(/\|\s*\|/g, "|")
|
|
|
+ simplified = simplified.replace(/^\s*\|\s*/, "")
|
|
|
+ simplified = simplified.replace(/\]\s*$/, "") // Remove trailing bracket if present
|
|
|
+
|
|
|
+ return { timestamp, simplified }
|
|
|
+}
|
|
|
+
|
|
|
// Format a single line with syntax highlighting using React elements (XSS-safe)
|
|
|
function formatLine(line: string): React.ReactNode[] {
|
|
|
// Find all matches with their positions
|
|
|
@@ -125,24 +196,83 @@ function formatLine(line: string): React.ReactNode[] {
|
|
|
return result.length > 0 ? result : [line]
|
|
|
}
|
|
|
|
|
|
+// Determine the visual style for a log line based on its content
|
|
|
+function getLineStyle(line: string): string {
|
|
|
+ if (line.includes("ERROR")) return "bg-red-950/30 border-l-2 border-red-500"
|
|
|
+ if (line.includes("WARN") || line.includes("WARNING")) return "bg-yellow-950/20 border-l-2 border-yellow-500"
|
|
|
+ if (line.includes("taskToolFailed")) return "bg-red-950/30 border-l-2 border-red-500"
|
|
|
+ if (line.includes("taskStarted") || line.includes("taskCreated")) return "bg-purple-950/20"
|
|
|
+ if (line.includes("EvalPass")) return "bg-green-950/30 border-l-2 border-green-500"
|
|
|
+ if (line.includes("EvalFail")) return "bg-red-950/30 border-l-2 border-red-500"
|
|
|
+ if (line.includes("taskCompleted") || line.includes("taskAborted")) return "bg-blue-950/20"
|
|
|
+ return ""
|
|
|
+}
|
|
|
+
|
|
|
// Format log content with basic highlighting (XSS-safe - no dangerouslySetInnerHTML)
|
|
|
function formatLogContent(log: string): React.ReactNode[] {
|
|
|
const lines = log.split("\n")
|
|
|
- return lines.map((line, index) => (
|
|
|
- <div key={index} className="hover:bg-white/5">
|
|
|
- {line ? formatLine(line) : " "}
|
|
|
- </div>
|
|
|
- ))
|
|
|
+ const baselineMs = extractFirstTimestamp(log)
|
|
|
+
|
|
|
+ return lines.map((line, index) => {
|
|
|
+ if (!line.trim()) {
|
|
|
+ return (
|
|
|
+ <div key={index} className="h-2">
|
|
|
+ {" "}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ const parsed = simplifyLogLine(line, baselineMs)
|
|
|
+ const lineStyle = getLineStyle(line)
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div key={index} className={`flex hover:bg-white/10 py-0.5 rounded-sm transition-colors ${lineStyle}`}>
|
|
|
+ {/* Elapsed time */}
|
|
|
+ <span className="text-blue-400 font-mono w-12 flex-shrink-0 tabular-nums text-right pr-2">
|
|
|
+ {parsed.timestamp}
|
|
|
+ </span>
|
|
|
+ {/* Log content - pl-12 ensures wrapped lines are indented under the timestamp */}
|
|
|
+ <span className="flex-1 break-words" style={{ textIndent: "-0.5rem", paddingLeft: "0.5rem" }}>
|
|
|
+ {formatLine(parsed.simplified)}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
export function Run({ run }: { run: Run }) {
|
|
|
const runStatus = useRunStatus(run)
|
|
|
- const { tasks, tokenUsage, usageUpdatedAt } = runStatus
|
|
|
+ const { tasks, tokenUsage, usageUpdatedAt, heartbeat, runners } = runStatus
|
|
|
|
|
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null)
|
|
|
const [taskLog, setTaskLog] = useState<string | null>(null)
|
|
|
const [isLoadingLog, setIsLoadingLog] = useState(false)
|
|
|
const [copied, setCopied] = useState(false)
|
|
|
+ const [showKillDialog, setShowKillDialog] = useState(false)
|
|
|
+ const [isKilling, setIsKilling] = useState(false)
|
|
|
+
|
|
|
+ // Determine if run is still active (has heartbeat or runners)
|
|
|
+ const isRunActive = !run.taskMetricsId && (!!heartbeat || (runners && runners.length > 0))
|
|
|
+
|
|
|
+ const onKillRun = useCallback(async () => {
|
|
|
+ setIsKilling(true)
|
|
|
+ try {
|
|
|
+ const result = await killRun(run.id)
|
|
|
+ if (result.killedContainers.length > 0) {
|
|
|
+ toast.success(`Killed ${result.killedContainers.length} container(s)`)
|
|
|
+ } else if (result.errors.length === 0) {
|
|
|
+ toast.info("No running containers found")
|
|
|
+ } else {
|
|
|
+ toast.error(result.errors.join(", "))
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error("Failed to kill run:", error)
|
|
|
+ toast.error("Failed to kill run")
|
|
|
+ } finally {
|
|
|
+ setIsKilling(false)
|
|
|
+ setShowKillDialog(false)
|
|
|
+ }
|
|
|
+ }, [run.id])
|
|
|
|
|
|
const onCopyLog = useCallback(async () => {
|
|
|
if (!taskLog) return
|
|
|
@@ -172,9 +302,9 @@ export function Run({ run }: { run: Run }) {
|
|
|
|
|
|
const onViewTaskLog = useCallback(
|
|
|
async (task: Task) => {
|
|
|
- // Only allow viewing logs for completed tasks
|
|
|
- if (task.passed === null || task.passed === undefined) {
|
|
|
- toast.error("Task is still running")
|
|
|
+ // Only allow viewing logs for tasks that have started
|
|
|
+ if (!task.startedAt && !tokenUsage.get(task.id)) {
|
|
|
+ toast.error("Task has not started yet")
|
|
|
return
|
|
|
}
|
|
|
|
|
|
@@ -202,7 +332,7 @@ export function Run({ run }: { run: Run }) {
|
|
|
setIsLoadingLog(false)
|
|
|
}
|
|
|
},
|
|
|
- [run.id],
|
|
|
+ [run.id, tokenUsage],
|
|
|
)
|
|
|
|
|
|
const taskMetrics: Record<number, TaskMetrics> = useMemo(() => {
|
|
|
@@ -228,22 +358,34 @@ export function Run({ run }: { run: Run }) {
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
}, [tasks, tokenUsage, usageUpdatedAt])
|
|
|
|
|
|
+ // Collect all unique tool names from all tasks and sort by total attempts
|
|
|
+ const toolColumns = useMemo<ToolName[]>(() => {
|
|
|
+ if (!tasks) return []
|
|
|
+
|
|
|
+ const toolTotals = new Map<ToolName, number>()
|
|
|
+
|
|
|
+ for (const task of tasks) {
|
|
|
+ if (task.taskMetrics?.toolUsage) {
|
|
|
+ for (const [toolName, usage] of Object.entries(task.taskMetrics.toolUsage)) {
|
|
|
+ const tool = toolName as ToolName
|
|
|
+ const current = toolTotals.get(tool) ?? 0
|
|
|
+ toolTotals.set(tool, current + usage.attempts)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Sort by total attempts descending
|
|
|
+ return Array.from(toolTotals.entries())
|
|
|
+ .sort((a, b) => b[1] - a[1])
|
|
|
+ .map(([name]): ToolName => name)
|
|
|
+ }, [tasks])
|
|
|
+
|
|
|
// Compute aggregate stats
|
|
|
const stats = useMemo(() => {
|
|
|
if (!tasks) return null
|
|
|
|
|
|
const passed = tasks.filter((t) => t.passed === true).length
|
|
|
const failed = tasks.filter((t) => t.passed === false).length
|
|
|
- // Count running tasks exactly like TaskStatus shows spinner:
|
|
|
- // - passed is not true and not false (null/undefined)
|
|
|
- // - AND has activity (startedAt or tokenUsage)
|
|
|
- const running = tasks.filter(
|
|
|
- (t) => t.passed !== true && t.passed !== false && (t.startedAt || tokenUsage.get(t.id)),
|
|
|
- ).length
|
|
|
- const pending = tasks.filter(
|
|
|
- (t) => t.passed !== true && t.passed !== false && !t.startedAt && !tokenUsage.get(t.id),
|
|
|
- ).length
|
|
|
- const total = tasks.length
|
|
|
const completed = passed + failed
|
|
|
|
|
|
let totalTokensIn = 0
|
|
|
@@ -279,9 +421,6 @@ export function Run({ run }: { run: Run }) {
|
|
|
return {
|
|
|
passed,
|
|
|
failed,
|
|
|
- running,
|
|
|
- pending,
|
|
|
- total,
|
|
|
completed,
|
|
|
passRate: completed > 0 ? ((passed / completed) * 100).toFixed(1) : null,
|
|
|
totalTokensIn,
|
|
|
@@ -293,42 +432,96 @@ export function Run({ run }: { run: Run }) {
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
}, [tasks, taskMetrics, tokenUsage, usageUpdatedAt])
|
|
|
|
|
|
+ // Calculate elapsed time (wall-clock time from run creation to completion or now)
|
|
|
+ const elapsedTime = useMemo(() => {
|
|
|
+ if (!tasks || tasks.length === 0) return null
|
|
|
+
|
|
|
+ const startTime = new Date(run.createdAt).getTime()
|
|
|
+
|
|
|
+ // If run is complete, find the latest finishedAt from tasks
|
|
|
+ if (run.taskMetricsId) {
|
|
|
+ const latestFinish = tasks.reduce((latest, task) => {
|
|
|
+ if (task.finishedAt) {
|
|
|
+ const finishTime = new Date(task.finishedAt).getTime()
|
|
|
+ return finishTime > latest ? finishTime : latest
|
|
|
+ }
|
|
|
+ return latest
|
|
|
+ }, startTime)
|
|
|
+ return latestFinish - startTime
|
|
|
+ }
|
|
|
+
|
|
|
+ // If still running, use current time
|
|
|
+ return Date.now() - startTime
|
|
|
+ // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
+ }, [tasks, run.createdAt, run.taskMetricsId, usageUpdatedAt])
|
|
|
+
|
|
|
return (
|
|
|
<>
|
|
|
<div>
|
|
|
- <div className="mb-4">
|
|
|
- <div>
|
|
|
- <div className="font-mono">{run.model}</div>
|
|
|
- {run.description && <div className="text-sm text-muted-foreground">{run.description}</div>}
|
|
|
- </div>
|
|
|
- {!run.taskMetricsId && <RunStatus runStatus={runStatus} />}
|
|
|
- </div>
|
|
|
-
|
|
|
{stats && (
|
|
|
- <div className="mb-4 p-4 border rounded-lg bg-muted/50">
|
|
|
+ <div className="mb-4 p-4 border rounded-lg bg-muted sticky top-0 z-10">
|
|
|
+ {/* Provider, Model title and status */}
|
|
|
+ <div className="flex items-center justify-center gap-3 mb-3 relative">
|
|
|
+ {run.settings?.apiProvider && (
|
|
|
+ <span className="text-sm text-muted-foreground">{run.settings.apiProvider}</span>
|
|
|
+ )}
|
|
|
+ <div className="font-mono">{run.model}</div>
|
|
|
+ <RunStatus runStatus={runStatus} isComplete={!!run.taskMetricsId} />
|
|
|
+ {run.description && (
|
|
|
+ <span className="text-sm text-muted-foreground">- {run.description}</span>
|
|
|
+ )}
|
|
|
+ {isRunActive && (
|
|
|
+ <Tooltip>
|
|
|
+ <TooltipTrigger asChild>
|
|
|
+ <Button
|
|
|
+ variant="ghost"
|
|
|
+ size="sm"
|
|
|
+ onClick={() => setShowKillDialog(true)}
|
|
|
+ disabled={isKilling}
|
|
|
+ className="absolute right-0 flex items-center gap-1 text-muted-foreground hover:text-destructive">
|
|
|
+ {isKilling ? (
|
|
|
+ <LoaderCircle className="size-4 animate-spin" />
|
|
|
+ ) : (
|
|
|
+ <StopCircle className="size-4" />
|
|
|
+ )}
|
|
|
+ Kill
|
|
|
+ </Button>
|
|
|
+ </TooltipTrigger>
|
|
|
+ <TooltipContent>Stop all containers for this run</TooltipContent>
|
|
|
+ </Tooltip>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
{/* Main Stats Row */}
|
|
|
- <div className="flex flex-wrap items-start justify-between gap-x-6 gap-y-3">
|
|
|
+ <div className="flex items-start justify-center gap-x-8 gap-y-3">
|
|
|
{/* Passed/Failed */}
|
|
|
- <div className="text-center">
|
|
|
+ <div className="text-center min-w-[80px]">
|
|
|
<div className="text-2xl font-bold whitespace-nowrap">
|
|
|
<span className="text-green-600">{stats.passed}</span>
|
|
|
<span className="text-muted-foreground mx-1">/</span>
|
|
|
<span className="text-red-600">{stats.failed}</span>
|
|
|
- {stats.running > 0 && (
|
|
|
- <span className="text-yellow-600 text-sm ml-2">({stats.running})</span>
|
|
|
- )}
|
|
|
</div>
|
|
|
<div className="text-xs text-muted-foreground">Passed / Failed</div>
|
|
|
</div>
|
|
|
|
|
|
{/* Pass Rate */}
|
|
|
- <div className="text-center">
|
|
|
- <div className="text-2xl font-bold">{stats.passRate ? `${stats.passRate}%` : "-"}</div>
|
|
|
+ <div className="text-center min-w-[80px]">
|
|
|
+ <div
|
|
|
+ className={`text-2xl font-bold ${
|
|
|
+ stats.passRate === null
|
|
|
+ ? ""
|
|
|
+ : parseFloat(stats.passRate) === 100
|
|
|
+ ? ""
|
|
|
+ : parseFloat(stats.passRate) >= 80
|
|
|
+ ? "text-yellow-500"
|
|
|
+ : "text-red-500"
|
|
|
+ }`}>
|
|
|
+ {stats.passRate ? `${stats.passRate}%` : "-"}
|
|
|
+ </div>
|
|
|
<div className="text-xs text-muted-foreground">Pass Rate</div>
|
|
|
</div>
|
|
|
|
|
|
{/* Tokens */}
|
|
|
- <div className="text-center">
|
|
|
+ <div className="text-center min-w-[140px]">
|
|
|
<div className="text-xl font-bold font-mono whitespace-nowrap">
|
|
|
{formatTokens(stats.totalTokensIn)}
|
|
|
<span className="text-muted-foreground mx-1">/</span>
|
|
|
@@ -338,58 +531,64 @@ export function Run({ run }: { run: Run }) {
|
|
|
</div>
|
|
|
|
|
|
{/* Cost */}
|
|
|
- <div className="text-center">
|
|
|
+ <div className="text-center min-w-[70px]">
|
|
|
<div className="text-2xl font-bold font-mono">{formatCurrency(stats.totalCost)}</div>
|
|
|
<div className="text-xs text-muted-foreground">Cost</div>
|
|
|
</div>
|
|
|
|
|
|
{/* Duration */}
|
|
|
- <div className="text-center">
|
|
|
+ <div className="text-center min-w-[90px]">
|
|
|
<div className="text-2xl font-bold font-mono whitespace-nowrap">
|
|
|
{stats.totalDuration > 0 ? formatDuration(stats.totalDuration) : "-"}
|
|
|
</div>
|
|
|
<div className="text-xs text-muted-foreground">Duration</div>
|
|
|
</div>
|
|
|
|
|
|
- {/* Tool Usage - Inline */}
|
|
|
- {Object.keys(stats.toolUsage).length > 0 && (
|
|
|
- <div className="flex items-center gap-2 flex-wrap">
|
|
|
- {Object.entries(stats.toolUsage)
|
|
|
- .sort(([, a], [, b]) => b.attempts - a.attempts)
|
|
|
- .map(([toolName, usage]) => {
|
|
|
- const abbr = getToolAbbreviation(toolName)
|
|
|
- const successRate =
|
|
|
- usage.attempts > 0
|
|
|
- ? ((usage.attempts - usage.failures) / usage.attempts) * 100
|
|
|
- : 100
|
|
|
- const rateColor =
|
|
|
- successRate === 100
|
|
|
- ? "text-green-500"
|
|
|
- : successRate >= 80
|
|
|
- ? "text-yellow-500"
|
|
|
- : "text-red-500"
|
|
|
- return (
|
|
|
- <Tooltip key={toolName}>
|
|
|
- <TooltipTrigger asChild>
|
|
|
- <div className="flex items-center gap-1 px-2 py-1 rounded bg-background/50 border border-border/50 hover:border-border transition-colors cursor-default text-xs">
|
|
|
- <span className="font-medium text-muted-foreground">
|
|
|
- {abbr}
|
|
|
- </span>
|
|
|
- <span className="font-bold tabular-nums">
|
|
|
- {usage.attempts}
|
|
|
- </span>
|
|
|
- <span className={`${rateColor}`}>
|
|
|
- {formatToolUsageSuccessRate(usage)}
|
|
|
- </span>
|
|
|
- </div>
|
|
|
- </TooltipTrigger>
|
|
|
- <TooltipContent side="bottom">{toolName}</TooltipContent>
|
|
|
- </Tooltip>
|
|
|
- )
|
|
|
- })}
|
|
|
+ {/* Elapsed Time */}
|
|
|
+ <div className="text-center min-w-[90px]">
|
|
|
+ <div className="text-2xl font-bold font-mono whitespace-nowrap">
|
|
|
+ {elapsedTime !== null ? formatDuration(elapsedTime) : "-"}
|
|
|
</div>
|
|
|
- )}
|
|
|
+ <div className="text-xs text-muted-foreground">Elapsed</div>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
+
|
|
|
+ {/* Tool Usage Row */}
|
|
|
+ {Object.keys(stats.toolUsage).length > 0 && (
|
|
|
+ <div className="flex items-center justify-center gap-2 flex-wrap mt-3">
|
|
|
+ {Object.entries(stats.toolUsage)
|
|
|
+ .sort(([, a], [, b]) => b.attempts - a.attempts)
|
|
|
+ .map(([toolName, usage]) => {
|
|
|
+ const abbr = getToolAbbreviation(toolName)
|
|
|
+ const successRate =
|
|
|
+ usage.attempts > 0
|
|
|
+ ? ((usage.attempts - usage.failures) / usage.attempts) * 100
|
|
|
+ : 100
|
|
|
+ const rateColor =
|
|
|
+ successRate === 100
|
|
|
+ ? "text-green-500"
|
|
|
+ : successRate >= 80
|
|
|
+ ? "text-yellow-500"
|
|
|
+ : "text-red-500"
|
|
|
+ return (
|
|
|
+ <Tooltip key={toolName}>
|
|
|
+ <TooltipTrigger asChild>
|
|
|
+ <div className="flex items-center gap-1 px-2 py-1 rounded bg-background/50 border border-border/50 hover:border-border transition-colors cursor-default text-xs">
|
|
|
+ <span className="font-medium text-muted-foreground">
|
|
|
+ {abbr}
|
|
|
+ </span>
|
|
|
+ <span className="font-bold tabular-nums">{usage.attempts}</span>
|
|
|
+ <span className={`${rateColor}`}>
|
|
|
+ {formatToolUsageSuccessRate(usage)}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </TooltipTrigger>
|
|
|
+ <TooltipContent side="bottom">{toolName}</TooltipContent>
|
|
|
+ </Tooltip>
|
|
|
+ )
|
|
|
+ })}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
)}
|
|
|
{!tasks ? (
|
|
|
@@ -401,67 +600,104 @@ export function Run({ run }: { run: Run }) {
|
|
|
<TableHead>Exercise</TableHead>
|
|
|
<TableHead className="text-center">Tokens In / Out</TableHead>
|
|
|
<TableHead>Context</TableHead>
|
|
|
+ {toolColumns.map((toolName) => (
|
|
|
+ <TableHead key={toolName} className="text-xs text-center">
|
|
|
+ <Tooltip>
|
|
|
+ <TooltipTrigger>{getToolAbbreviation(toolName)}</TooltipTrigger>
|
|
|
+ <TooltipContent>{toolName}</TooltipContent>
|
|
|
+ </Tooltip>
|
|
|
+ </TableHead>
|
|
|
+ ))}
|
|
|
<TableHead>Duration</TableHead>
|
|
|
<TableHead>Cost</TableHead>
|
|
|
</TableRow>
|
|
|
</TableHeader>
|
|
|
<TableBody>
|
|
|
- {tasks.map((task) => (
|
|
|
- <TableRow
|
|
|
- key={task.id}
|
|
|
- className={task.finishedAt ? "cursor-pointer hover:bg-muted/50" : ""}
|
|
|
- onClick={() => task.finishedAt && onViewTaskLog(task)}>
|
|
|
- <TableCell>
|
|
|
- <div className="flex items-center gap-2">
|
|
|
- <TaskStatus
|
|
|
- task={task}
|
|
|
- running={!!task.startedAt || !!tokenUsage.get(task.id)}
|
|
|
- />
|
|
|
+ {tasks.map((task) => {
|
|
|
+ const hasStarted = !!task.startedAt || !!tokenUsage.get(task.id)
|
|
|
+ return (
|
|
|
+ <TableRow
|
|
|
+ key={task.id}
|
|
|
+ className={`${hasStarted ? "cursor-pointer hover:bg-muted/50" : ""} ${task.passed === false ? "bg-red-950/30 border-l-2 border-l-red-500" : ""}`}
|
|
|
+ onClick={() => hasStarted && onViewTaskLog(task)}>
|
|
|
+ <TableCell>
|
|
|
<div className="flex items-center gap-2">
|
|
|
- <span>
|
|
|
- {task.language}/{task.exercise}
|
|
|
- {task.iteration > 1 && (
|
|
|
- <span className="text-muted-foreground ml-1">
|
|
|
- (#{task.iteration})
|
|
|
- </span>
|
|
|
+ <TaskStatus task={task} running={hasStarted} />
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <span>
|
|
|
+ {task.language}/{task.exercise}
|
|
|
+ {task.iteration > 1 && (
|
|
|
+ <span className="text-muted-foreground ml-1">
|
|
|
+ (#{task.iteration})
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </span>
|
|
|
+ {hasStarted && (
|
|
|
+ <Tooltip>
|
|
|
+ <TooltipTrigger asChild>
|
|
|
+ <FileText className="size-3 text-muted-foreground" />
|
|
|
+ </TooltipTrigger>
|
|
|
+ <TooltipContent>Click to view log</TooltipContent>
|
|
|
+ </Tooltip>
|
|
|
)}
|
|
|
- </span>
|
|
|
- {task.finishedAt && (
|
|
|
- <Tooltip>
|
|
|
- <TooltipTrigger asChild>
|
|
|
- <FileText className="size-3 text-muted-foreground" />
|
|
|
- </TooltipTrigger>
|
|
|
- <TooltipContent>Click to view log</TooltipContent>
|
|
|
- </Tooltip>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </TableCell>
|
|
|
- {taskMetrics[task.id] ? (
|
|
|
- <>
|
|
|
- <TableCell className="font-mono text-xs">
|
|
|
- <div className="flex items-center justify-evenly">
|
|
|
- <div>{formatTokens(taskMetrics[task.id]!.tokensIn)}</div>/
|
|
|
- <div>{formatTokens(taskMetrics[task.id]!.tokensOut)}</div>
|
|
|
</div>
|
|
|
- </TableCell>
|
|
|
- <TableCell className="font-mono text-xs">
|
|
|
- {formatTokens(taskMetrics[task.id]!.tokensContext)}
|
|
|
- </TableCell>
|
|
|
- <TableCell className="font-mono text-xs">
|
|
|
- {taskMetrics[task.id]!.duration
|
|
|
- ? formatDuration(taskMetrics[task.id]!.duration)
|
|
|
- : "-"}
|
|
|
- </TableCell>
|
|
|
- <TableCell className="font-mono text-xs">
|
|
|
- {formatCurrency(taskMetrics[task.id]!.cost)}
|
|
|
- </TableCell>
|
|
|
- </>
|
|
|
- ) : (
|
|
|
- <TableCell colSpan={4} />
|
|
|
- )}
|
|
|
- </TableRow>
|
|
|
- ))}
|
|
|
+ </div>
|
|
|
+ </TableCell>
|
|
|
+ {taskMetrics[task.id] ? (
|
|
|
+ <>
|
|
|
+ <TableCell className="font-mono text-xs">
|
|
|
+ <div className="flex items-center justify-evenly">
|
|
|
+ <div>{formatTokens(taskMetrics[task.id]!.tokensIn)}</div>/
|
|
|
+ <div>{formatTokens(taskMetrics[task.id]!.tokensOut)}</div>
|
|
|
+ </div>
|
|
|
+ </TableCell>
|
|
|
+ <TableCell className="font-mono text-xs">
|
|
|
+ {formatTokens(taskMetrics[task.id]!.tokensContext)}
|
|
|
+ </TableCell>
|
|
|
+ {toolColumns.map((toolName) => {
|
|
|
+ const usage = task.taskMetrics?.toolUsage?.[toolName]
|
|
|
+ const successRate =
|
|
|
+ usage && usage.attempts > 0
|
|
|
+ ? ((usage.attempts - usage.failures) / usage.attempts) * 100
|
|
|
+ : 100
|
|
|
+ const rateColor =
|
|
|
+ successRate === 100
|
|
|
+ ? "text-muted-foreground"
|
|
|
+ : successRate >= 80
|
|
|
+ ? "text-yellow-500"
|
|
|
+ : "text-red-500"
|
|
|
+ return (
|
|
|
+ <TableCell key={toolName} className="text-xs text-center">
|
|
|
+ {usage ? (
|
|
|
+ <div className="flex flex-col items-center">
|
|
|
+ <span className="font-medium">
|
|
|
+ {usage.attempts}
|
|
|
+ </span>
|
|
|
+ <span className={rateColor}>
|
|
|
+ {formatToolUsageSuccessRate(usage)}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ <span className="text-muted-foreground">-</span>
|
|
|
+ )}
|
|
|
+ </TableCell>
|
|
|
+ )
|
|
|
+ })}
|
|
|
+ <TableCell className="font-mono text-xs">
|
|
|
+ {taskMetrics[task.id]!.duration
|
|
|
+ ? formatDuration(taskMetrics[task.id]!.duration)
|
|
|
+ : "-"}
|
|
|
+ </TableCell>
|
|
|
+ <TableCell className="font-mono text-xs">
|
|
|
+ {formatCurrency(taskMetrics[task.id]!.cost)}
|
|
|
+ </TableCell>
|
|
|
+ </>
|
|
|
+ ) : (
|
|
|
+ <TableCell colSpan={4 + toolColumns.length} />
|
|
|
+ )}
|
|
|
+ </TableRow>
|
|
|
+ )
|
|
|
+ })}
|
|
|
</TableBody>
|
|
|
</Table>
|
|
|
)}
|
|
|
@@ -479,8 +715,20 @@ export function Run({ run }: { run: Run }) {
|
|
|
<span className="text-muted-foreground">(#{selectedTask.iteration})</span>
|
|
|
)}
|
|
|
<span
|
|
|
- className={`ml-2 text-sm ${selectedTask?.passed ? "text-green-600" : "text-red-600"}`}>
|
|
|
- ({selectedTask?.passed ? "Passed" : "Failed"})
|
|
|
+ className={`ml-2 text-sm ${
|
|
|
+ selectedTask?.passed === true
|
|
|
+ ? "text-green-600"
|
|
|
+ : selectedTask?.passed === false
|
|
|
+ ? "text-red-600"
|
|
|
+ : "text-yellow-500"
|
|
|
+ }`}>
|
|
|
+ (
|
|
|
+ {selectedTask?.passed === true
|
|
|
+ ? "Passed"
|
|
|
+ : selectedTask?.passed === false
|
|
|
+ ? "Failed"
|
|
|
+ : "Running"}
|
|
|
+ )
|
|
|
</span>
|
|
|
</DialogTitle>
|
|
|
{taskLog && (
|
|
|
@@ -523,6 +771,35 @@ export function Run({ run }: { run: Run }) {
|
|
|
</div>
|
|
|
</DialogContent>
|
|
|
</Dialog>
|
|
|
+
|
|
|
+ {/* Kill Run Confirmation Dialog */}
|
|
|
+ <AlertDialog open={showKillDialog} onOpenChange={setShowKillDialog}>
|
|
|
+ <AlertDialogContent>
|
|
|
+ <AlertDialogHeader>
|
|
|
+ <AlertDialogTitle>Kill Run?</AlertDialogTitle>
|
|
|
+ <AlertDialogDescription>
|
|
|
+ This will stop the controller and all task runner containers for this run. Any running tasks
|
|
|
+ will be terminated immediately. This action cannot be undone.
|
|
|
+ </AlertDialogDescription>
|
|
|
+ </AlertDialogHeader>
|
|
|
+ <AlertDialogFooter>
|
|
|
+ <AlertDialogCancel disabled={isKilling}>Cancel</AlertDialogCancel>
|
|
|
+ <AlertDialogAction
|
|
|
+ onClick={onKillRun}
|
|
|
+ disabled={isKilling}
|
|
|
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
|
|
+ {isKilling ? (
|
|
|
+ <>
|
|
|
+ <LoaderCircle className="size-4 animate-spin mr-2" />
|
|
|
+ Killing...
|
|
|
+ </>
|
|
|
+ ) : (
|
|
|
+ "Kill Run"
|
|
|
+ )}
|
|
|
+ </AlertDialogAction>
|
|
|
+ </AlertDialogFooter>
|
|
|
+ </AlertDialogContent>
|
|
|
+ </AlertDialog>
|
|
|
</>
|
|
|
)
|
|
|
}
|