diff --git a/examples/dice_agent/agent.py b/examples/dice_agent/agent.py index c2551e1..38866b4 100644 --- a/examples/dice_agent/agent.py +++ b/examples/dice_agent/agent.py @@ -56,7 +56,7 @@ def is_prime(n: int) -> bool: dice_agent = Agent( name="dice_agent", # model="gemini-2.5-flash", - model="gemini-2.5-flash-lite", + model="gemini-3-flash-preview", instruction="""You are a helpful assistant that can roll dice and check if numbers are prime. When a user asks you to roll a die, use the roll_die tool with the appropriate number of sides. diff --git a/examples/zero-code-examples/adk/run.py b/examples/zero-code-examples/adk/run.py index e0d10a5..f5e8419 100644 --- a/examples/zero-code-examples/adk/run.py +++ b/examples/zero-code-examples/adk/run.py @@ -44,6 +44,7 @@ async def main(): endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318") print(f"OTLP endpoint: {endpoint}") + os.environ.setdefault("OTEL_SERVICE_NAME", "adk-agent") os.environ.setdefault( "OTEL_RESOURCE_ATTRIBUTES", "agentevals.eval_set_id=dice_agent_eval,agentevals.session_name=adk-zero-code", diff --git a/examples/zero-code-examples/langchain/run.py b/examples/zero-code-examples/langchain/run.py index 51174cd..7d283ee 100644 --- a/examples/zero-code-examples/langchain/run.py +++ b/examples/zero-code-examples/langchain/run.py @@ -48,6 +48,7 @@ def main(): os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "true" + os.environ.setdefault("OTEL_SERVICE_NAME", "langchain-agent") os.environ.setdefault( "OTEL_RESOURCE_ATTRIBUTES", "agentevals.eval_set_id=langchain_agent_eval,agentevals.session_name=langchain-zero-code", diff --git a/examples/zero-code-examples/ollama/run.py b/examples/zero-code-examples/ollama/run.py index d6f407d..9686d37 100644 --- a/examples/zero-code-examples/ollama/run.py +++ b/examples/zero-code-examples/ollama/run.py @@ -112,6 +112,7 @@ def main(): print(f"Local model: {model}") os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "true" + os.environ.setdefault("OTEL_SERVICE_NAME", "ollama-agent") os.environ.setdefault( "OTEL_RESOURCE_ATTRIBUTES", "agentevals.eval_set_id=langchain_local_ollama_openai_eval,agentevals.session_name=langchain-ollama-openai-zero-code", diff --git a/examples/zero-code-examples/openai-agents/run.py b/examples/zero-code-examples/openai-agents/run.py index ca4b0d7..c1ab13d 100644 --- a/examples/zero-code-examples/openai-agents/run.py +++ b/examples/zero-code-examples/openai-agents/run.py @@ -58,6 +58,7 @@ def main(): os.environ.setdefault("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "span_and_event") os.environ.setdefault("OTEL_SEMCONV_STABILITY_OPT_IN", "gen_ai_latest_experimental") + os.environ.setdefault("OTEL_SERVICE_NAME", "openai-agents-agent") os.environ.setdefault( "OTEL_RESOURCE_ATTRIBUTES", "agentevals.eval_set_id=openai_agents_eval,agentevals.session_name=openai-agents-zero-code", diff --git a/examples/zero-code-examples/pydantic-ai/run.py b/examples/zero-code-examples/pydantic-ai/run.py index 19f34d5..dfc1e3a 100644 --- a/examples/zero-code-examples/pydantic-ai/run.py +++ b/examples/zero-code-examples/pydantic-ai/run.py @@ -54,6 +54,7 @@ def main(): endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318") print(f"OTLP endpoint: {endpoint}") + os.environ.setdefault("OTEL_SERVICE_NAME", "pydantic-ai-agent") os.environ.setdefault( "OTEL_RESOURCE_ATTRIBUTES", "agentevals.eval_set_id=pydantic_ai_eval,agentevals.session_name=pydantic-ai-zero-code", @@ -72,6 +73,7 @@ def main(): agent = Agent( "openai:gpt-4o-mini", + # "openai:gpt-5.4-mini-2026-03-17", instructions="You are a helpful assistant. You can roll dice and check if numbers are prime.", ) agent.tool_plain(roll_die) diff --git a/examples/zero-code-examples/strands/run.py b/examples/zero-code-examples/strands/run.py index 3de52ef..39d34f6 100644 --- a/examples/zero-code-examples/strands/run.py +++ b/examples/zero-code-examples/strands/run.py @@ -40,6 +40,7 @@ def main(): endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318") print(f"OTLP endpoint: {endpoint}") + os.environ.setdefault("OTEL_SERVICE_NAME", "strands-agent") os.environ.setdefault( "OTEL_RESOURCE_ATTRIBUTES", "agentevals.eval_set_id=strands_agent_eval,agentevals.session_name=strands-zero-code", diff --git a/src/agentevals/api/streaming_routes.py b/src/agentevals/api/streaming_routes.py index b6a3872..22746c2 100644 --- a/src/agentevals/api/streaming_routes.py +++ b/src/agentevals/api/streaming_routes.py @@ -7,16 +7,15 @@ import logging from typing import TYPE_CHECKING -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import FileResponse from pydantic import BaseModel, ConfigDict, Field -from ..config import BuiltinMetricDef, EvalRunConfig, EvaluatorDef +from ..config import BuiltinMetricDef, EvalParams, EvalRunConfig, EvaluatorDef from ..converter import convert_traces from ..loader.otlp import OtlpJsonLoader -from ..runner import run_evaluation +from ..runner import RunResult, run_evaluation from ..trace_attrs import OTEL_GENAI_INPUT_MESSAGES, OTEL_GENAI_REQUEST_MODEL -from ..utils.log_enrichment import enrich_spans_with_logs from .dependencies import require_trace_manager from .models import ( CreateEvalSetData, @@ -167,9 +166,47 @@ async def create_eval_set_from_session( return await _do_create_eval_set(request, manager) +async def _persist_sessions_run( + http_request: Request, + *, + evaluators: list[EvaluatorDef], + eval_set_dict: dict, + session_ids: list[str], + run_results: list[RunResult], + session_errors: list[str], +) -> None: + """Best-effort: persist a UI-driven session evaluation as a single Run row + (plus Result rows) when ``app.state.run_service`` is configured (postgres + backend), mirroring the offline ``/api/evaluate`` persistence. + + Every evaluated session's traces are aggregated into one run so the run + history counts one evaluation per "Evaluate" click, the same way one + offline upload of N traces produces one run. No-op (and never raises) on + the memory backend or on persistence failure, so the live eval results are + always returned to the caller regardless.""" + service = getattr(http_request.app.state, "run_service", None) + if service is None: + return + trace_results = [tr for rr in run_results for tr in rr.trace_results] + if not trace_results: + return + combined = RunResult(trace_results=trace_results, errors=session_errors) + try: + await service.record_eval_run( + params=EvalParams(evaluators=evaluators), + eval_set_dict=eval_set_dict, + trace_format="otlp-json", + upload_filenames=session_ids, + run_result=combined, + ) + except Exception: + logger.exception("failed to persist evaluate-sessions run; results still returned to caller") + + @streaming_router.post("/evaluate-sessions", response_model=StandardResponse[EvaluateSessionsData]) async def evaluate_sessions( request: EvaluateSessionsRequest, + http_request: Request, manager: StreamingTraceManager = Depends(require_trace_manager), ): """Evaluate all sessions against a golden session converted to EvalSet.""" @@ -200,7 +237,7 @@ async def evaluate_sessions( sem = asyncio.Semaphore(5) - async def eval_one_session(session_id: str, session) -> SessionEvalResult: + async def eval_one_session(session_id: str, session) -> tuple[SessionEvalResult, RunResult | None]: async with sem: try: trace_file = await manager._save_spans_to_temp_file(session) @@ -216,34 +253,47 @@ async def eval_one_session(session_id: str, session) -> SessionEvalResult: if eval_result.trace_results: trace_result = eval_result.trace_results[0] - return SessionEvalResult( - session_id=session_id, - trace_id=trace_result.trace_id, - num_invocations=trace_result.num_invocations, - metric_results=[ - { - "metricName": mr.metric_name, - "score": mr.score, - "evalStatus": mr.eval_status, - "error": mr.error, - "perInvocationScores": mr.per_invocation_scores, - "details": mr.details, - } - for mr in trace_result.metric_results - ], + return ( + SessionEvalResult( + session_id=session_id, + trace_id=trace_result.trace_id, + num_invocations=trace_result.num_invocations, + metric_results=[ + { + "metricName": mr.metric_name, + "score": mr.score, + "evalStatus": mr.eval_status, + "error": mr.error, + "perInvocationScores": mr.per_invocation_scores, + "details": mr.details, + } + for mr in trace_result.metric_results + ], + ), + eval_result, ) else: logger.warning("No trace results for session %s", session_id) - return SessionEvalResult(session_id=session_id, error="No trace results") + return SessionEvalResult(session_id=session_id, error="No trace results"), None except Exception as exc: logger.error(f"Failed to evaluate session {session_id}: {exc}", exc_info=True) - return SessionEvalResult(session_id=session_id, error=str(exc)) + return SessionEvalResult(session_id=session_id, error=str(exc)), None - results = await asyncio.gather(*[eval_one_session(sid, sess) for sid, sess in sessions_to_evaluate]) + evaluated = await asyncio.gather(*[eval_one_session(sid, sess) for sid, sess in sessions_to_evaluate]) + results = [session_result for session_result, _ in evaluated] logger.info("Evaluation complete. Total results: %d", len(results)) + await _persist_sessions_run( + http_request, + evaluators=request.evaluators, + eval_set_dict=eval_set_response.data.eval_set, + session_ids=[sid for sid, _ in sessions_to_evaluate], + run_results=[run_result for _, run_result in evaluated if run_result is not None], + session_errors=[f"{r.session_id}: {r.error}" for r in results if r.error], + ) + return StandardResponse( data=EvaluateSessionsData( golden_session_id=request.golden_session_id, @@ -344,12 +394,6 @@ async def get_trace( raise HTTPException(status_code=404, detail="Session not found") try: - import tempfile - - temp_file = tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) - - unified_trace_id = session.trace_id - has_genai_spans = any( span.get("attributes", []) and any( @@ -366,23 +410,20 @@ async def get_trace( request.session_id, ) - enriched_spans = enrich_spans_with_logs(session.spans, session.logs) - - for span in enriched_spans: - span_copy = span.copy() - span_copy["traceId"] = unified_trace_id - temp_file.write(json.dumps(span_copy) + "\n") - - temp_file.close() - - with open(temp_file.name) as f: # noqa: ASYNC230 + # Reuse the canonical serializer so the trace handed to the UI (and + # re-uploaded to /api/evaluate) carries service.name, exactly like the + # evaluate-sessions path. Serializing spans here independently would + # drop the resource attribute and lose agent identity on the run. + trace_file = await manager._save_spans_to_temp_file(session) + with open(trace_file) as f: # noqa: ASYNC230 trace_content = f.read() + num_spans = sum(1 for line in trace_content.splitlines() if line.strip()) return StandardResponse( data=GetTraceData( session_id=request.session_id, trace_content=trace_content, - num_spans=len(enriched_spans), + num_spans=num_spans, ) ) diff --git a/src/agentevals/run/result_builder.py b/src/agentevals/run/result_builder.py index 8c18a03..0b18a28 100644 --- a/src/agentevals/run/result_builder.py +++ b/src/agentevals/run/result_builder.py @@ -99,27 +99,63 @@ def build_results(run_id: UUID, params: EvalParams, run_result: RunResult) -> li return out +def _status_key(mr: MetricResult) -> str: + """Map a :class:`MetricResult` onto a :class:`ResultStatus` string, + matching :func:`result_from_metric_result` so the summary counts and the + persisted ``result`` rows never disagree.""" + if mr.error: + return "errored" + status = (mr.eval_status or "").upper() + if status == "PASSED": + return "passed" + if status == "FAILED": + return "failed" + return "skipped" + + def summarize_run_result(run_result: RunResult) -> dict[str, Any]: """Summary blob persisted alongside the run row. Counts mirror :class:`agentevals.storage.models.ResultStatus` values so a caller polling ``GET /api/runs/{id}`` can compute pass/fail rates without fetching the full result list. + + ``per_metric`` aggregates the same statuses by ``evaluator_name`` plus a + mean ``score``, so the run-history dashboard can chart per-metric trends + across runs from the list response alone, without an N+1 over + ``/api/runs/{id}/results``. ``avg_score`` is ``None`` when no invocation of + that evaluator produced a numeric score (e.g. it only errored). """ counts = {"passed": 0, "failed": 0, "errored": 0, "skipped": 0} + per_metric: dict[str, dict[str, Any]] = {} + agents: set[str] = set() for tr in run_result.trace_results: + identity = tr.service_name or tr.agent_name + if identity: + agents.add(identity) for mr in tr.metric_results: - if mr.error: - counts["errored"] += 1 - elif (mr.eval_status or "").upper() == "PASSED": - counts["passed"] += 1 - elif (mr.eval_status or "").upper() == "FAILED": - counts["failed"] += 1 - else: - counts["skipped"] += 1 + key = _status_key(mr) + counts[key] += 1 + + metric = per_metric.setdefault( + mr.metric_name, + {"passed": 0, "failed": 0, "errored": 0, "skipped": 0, "_score_sum": 0.0, "_score_count": 0}, + ) + metric[key] += 1 + if mr.score is not None: + metric["_score_sum"] += mr.score + metric["_score_count"] += 1 + + for metric in per_metric.values(): + score_count = metric.pop("_score_count") + score_sum = metric.pop("_score_sum") + metric["avg_score"] = (score_sum / score_count) if score_count else None + return { "trace_count": len(run_result.trace_results), "result_counts": counts, + "per_metric": per_metric, + "agents": sorted(agents), "errors": list(run_result.errors), "performance_metrics": run_result.performance_metrics, } diff --git a/src/agentevals/runner.py b/src/agentevals/runner.py index 0ebf1fb..ebca577 100644 --- a/src/agentevals/runner.py +++ b/src/agentevals/runner.py @@ -22,7 +22,7 @@ from .converter import ConversionResult, convert_traces from .loader import load_traces from .loader.base import Trace -from .trace_metrics import _calc_percentiles, extract_performance_metrics +from .trace_metrics import _calc_percentiles, extract_agent_identity, extract_performance_metrics logger = logging.getLogger(__name__) @@ -50,6 +50,8 @@ class TraceResult(BaseModel): metric_results: list[MetricResult] = Field(default_factory=list) conversion_warnings: list[str] = Field(default_factory=list) performance_metrics: dict[str, Any] | None = None + service_name: str | None = None + agent_name: str | None = None class RunResult(BaseModel): @@ -111,7 +113,7 @@ async def _evaluate_trace_bounded(idx: int, conv_result: ConversionResult) -> Tr trace = trace_map.get(conv_result.trace_id) - return await _evaluate_trace( + trace_result = await _evaluate_trace( conv_result=conv_result, evaluators=config.evaluators, eval_set=eval_set, @@ -121,6 +123,11 @@ async def _evaluate_trace_bounded(idx: int, conv_result: ConversionResult) -> Tr trace=trace, performance_metrics=perf_metrics_map.get(conv_result.trace_id), ) + if trace is not None: + identity = extract_agent_identity(trace) + trace_result.service_name = identity["service_name"] + trace_result.agent_name = identity["agent_name"] + return trace_result trace_results = await asyncio.gather( *[_evaluate_trace_bounded(idx, conv_result) for idx, conv_result in enumerate(conversion_results)], diff --git a/src/agentevals/streaming/ws_server.py b/src/agentevals/streaming/ws_server.py index 0163245..f7448fc 100644 --- a/src/agentevals/streaming/ws_server.py +++ b/src/agentevals/streaming/ws_server.py @@ -28,7 +28,7 @@ ) from ..loader.base import Trace from ..loader.otlp import OtlpJsonLoader -from ..trace_attrs import OTEL_GENAI_INPUT_MESSAGES, OTEL_GENAI_REQUEST_MODEL +from ..trace_attrs import OTEL_GENAI_INPUT_MESSAGES, OTEL_GENAI_REQUEST_MODEL, OTEL_SERVICE_NAME from ..utils.log_enrichment import enrich_spans_with_logs from .incremental_processor import IncrementalInvocationExtractor from .session import TraceSession @@ -658,10 +658,21 @@ async def _save_spans_to_temp_file(self, session: TraceSession) -> Path: enriched_spans = enrich_spans_with_logs(session.spans, session.logs, session.session_id) + # service.name is an OTel resource attribute, so it lives on the session + # (lifted from resource attrs at ingest) rather than on individual spans. + # The JSONL trace format has no resource envelope, so re-attach it to each + # span here; otherwise it's lost on reload and runs can't group by agent. + service_name = (session.metadata or {}).get(OTEL_SERVICE_NAME) + with open(temp_file, "w") as f: # noqa: ASYNC230 for span in enriched_spans: span_copy = span.copy() span_copy["traceId"] = session.trace_id + if service_name: + attrs = list(span_copy.get("attributes", [])) + if not any(a.get("key") == OTEL_SERVICE_NAME for a in attrs): + attrs.append({"key": OTEL_SERVICE_NAME, "value": {"stringValue": service_name}}) + span_copy["attributes"] = attrs f.write(json.dumps(span_copy) + "\n") return temp_file diff --git a/src/agentevals/trace_attrs.py b/src/agentevals/trace_attrs.py index 5aedc88..6bfc0df 100644 --- a/src/agentevals/trace_attrs.py +++ b/src/agentevals/trace_attrs.py @@ -6,6 +6,9 @@ Covers OTel GenAI semantic conventions up to v1.40.0. """ +# OTel resource +OTEL_SERVICE_NAME = "service.name" + # OTel scope OTEL_SCOPE = "otel.scope.name" OTEL_SCOPE_VERSION = "otel.scope.version" diff --git a/src/agentevals/trace_metrics.py b/src/agentevals/trace_metrics.py index a349817..8009957 100644 --- a/src/agentevals/trace_metrics.py +++ b/src/agentevals/trace_metrics.py @@ -16,9 +16,41 @@ OTEL_GENAI_AGENT_NAME, OTEL_GENAI_REQUEST_MODEL, OTEL_GENAI_TOOL_NAME, + OTEL_SERVICE_NAME, ) +def _first_service_name(trace) -> str | None: + """The OTel ``service.name`` resource attribute, merged onto every span at + ingest. Cross-framework, so it's the stable identifier for grouping runs by + agent regardless of instrumentation.""" + for span in trace.all_spans: + value = span.get_tag(OTEL_SERVICE_NAME) + if value: + return value + return None + + +def extract_agent_identity(trace, extractor=None) -> dict[str, str | None]: + """Best-effort agent identity for grouping runs. Prefers ``service.name``; + falls back only to a real ``gen_ai.agent.name``. + + Deliberately does NOT fall back to the root span's operation name: for + OTel GenAI traces that's the LLM call name (e.g. "chat gpt-4o-mini"), which + is a model, not an agent, and would mislabel agent groups in the dashboard. + """ + agent_name = None + try: + if extractor is None: + extractor = get_extractor(trace) + invocation_spans = extractor.find_invocation_spans(trace) + if invocation_spans: + agent_name = invocation_spans[0].get_tag(OTEL_GENAI_AGENT_NAME) + except Exception: + agent_name = None + return {"service_name": _first_service_name(trace), "agent_name": agent_name} + + def _truncate(text: str, max_length: int = 200) -> str: if len(text) <= max_length: return text @@ -149,6 +181,7 @@ def extract_trace_metadata(trace, extractor=None) -> dict[str, Any]: metadata: dict[str, Any] = { "agent_name": None, "agent_id": None, + "service_name": _first_service_name(trace), "model": None, "response_model": None, "provider": None, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index dfffdbe..d7d66bd 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -7,6 +7,7 @@ import { InspectorView } from './components/inspector/InspectorView'; import { BuilderView } from './components/builder/BuilderView'; import { LiveStreamingView } from './components/streaming/LiveStreamingView'; import { AnnotationQueueView } from './components/annotation-queue/AnnotationQueueView'; +import { RunsView } from './components/runs/RunsView'; import { Sidebar } from './components/sidebar/Sidebar'; function AppContent() { @@ -24,6 +25,7 @@ function AppContent() { {state.currentView === 'builder' && } {state.currentView === 'streaming' && } {state.currentView === 'annotation-queue' && } + {state.currentView === 'runs' && } {state.currentView === 'comparison' && (
Comparison view coming soon... diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index feb20f6..ac81f1a 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -5,9 +5,21 @@ import type { MetricMetadata, StandardResponse, ConvertTracesResponse, + Run, + RunStatus, + RunResultRow, } from '../lib/types'; import { config } from '../config'; +export class StorageUnavailableError extends Error { + constructor() { + super( + 'Run history requires the durable storage backend. Start the server with AGENTEVALS_STORAGE_BACKEND=postgres and AGENTEVALS_DATABASE_URL set.', + ); + this.name = 'StorageUnavailableError'; + } +} + const API_BASE_URL = `${config.api.baseUrl}/api`; async function unwrap(response: Response): Promise { @@ -186,6 +198,42 @@ export async function evaluateTracesStreaming( } } +export async function listRuns(options?: { + status?: RunStatus[]; + limit?: number; + before?: string; +}): Promise { + const params = new URLSearchParams(); + (options?.status ?? []).forEach(s => params.append('status', s)); + if (options?.limit != null) params.set('limit', String(options.limit)); + if (options?.before) params.set('before', options.before); + const query = params.toString(); + + const response = await fetch(`${config.api.endpoints.runs}${query ? `?${query}` : ''}`); + + if (response.status === 503) { + throw new StorageUnavailableError(); + } + if (!response.ok) { + throw new Error(`Failed to list runs: ${response.statusText}`); + } + return unwrap(response); +} + +export async function getRun(runId: string): Promise { + const response = await fetch(`${config.api.endpoints.runs}/${runId}`); + if (response.status === 503) throw new StorageUnavailableError(); + if (!response.ok) throw new Error(`Failed to fetch run: ${response.statusText}`); + return unwrap(response); +} + +export async function getRunResults(runId: string): Promise { + const response = await fetch(`${config.api.endpoints.runs}/${runId}/results`); + if (response.status === 503) throw new StorageUnavailableError(); + if (!response.ok) throw new Error(`Failed to fetch run results: ${response.statusText}`); + return unwrap(response); +} + export async function listMetrics(): Promise { try { const response = await fetch(`${API_BASE_URL}/metrics`); diff --git a/ui/src/components/dashboard/DashboardView.tsx b/ui/src/components/dashboard/DashboardView.tsx index 9057431..b0e05c2 100644 --- a/ui/src/components/dashboard/DashboardView.tsx +++ b/ui/src/components/dashboard/DashboardView.tsx @@ -129,6 +129,17 @@ export const DashboardView: React.FC = () => { setHoveredTraceId(traceId); }; + // The golden/reference trace is plotted in the performance charts (as a + // baseline) but excluded from pass/fail scoring - summary stats and the + // results table. Charts get the full set plus the golden id for labeling. + const goldenTraceId = state.goldenTraceId; + const scoredResults = goldenTraceId + ? state.results.filter((r) => r.traceId !== goldenTraceId) + : state.results; + const scoredRows = Array.from(state.tableRows.values()).filter( + (r) => !goldenTraceId || r.traceId !== goldenTraceId, + ); + return (
@@ -178,9 +189,13 @@ export const DashboardView: React.FC = () => { {state.tableRows.size > 0 && ( <> - {state.results.length > 0 && } + {scoredResults.length > 0 && } - + {state.isEvaluating && (
@@ -192,7 +207,7 @@ export const DashboardView: React.FC = () => { )} = ({ traceResults, hoveredTraceId }) => { +export const PerformanceCharts: React.FC = ({ traceResults, hoveredTraceId, goldenTraceId }) => { const tracesWithPerf = traceResults.filter(tr => tr.performanceMetrics); if (tracesWithPerf.length === 0) { return null; } + const isGolden = (tr: TraceResult) => goldenTraceId != null && tr.traceId === goldenTraceId; + const sortedTraces = [...tracesWithPerf].sort((a, b) => { + // Golden reference first, so it reads as the baseline the runs compare to. + if (isGolden(a) !== isGolden(b)) return isGolden(a) ? -1 : 1; const aSession = a.sessionId || a.traceId; const bSession = b.sessionId || b.traceId; return aSession.localeCompare(bSession); }); - const labels = sortedTraces.map(tr => tr.sessionId || tr.traceId.substring(0, 12)); + const labels = sortedTraces.map(tr => { + const base = tr.sessionId || tr.traceId.substring(0, 12); + return isGolden(tr) ? `${base} (reference)` : base; + }); const hoveredIndex = hoveredTraceId ? sortedTraces.findIndex(tr => tr.traceId === hoveredTraceId) diff --git a/ui/src/components/dashboard/TraceTable.tsx b/ui/src/components/dashboard/TraceTable.tsx index 75f9c66..890cf4e 100644 --- a/ui/src/components/dashboard/TraceTable.tsx +++ b/ui/src/components/dashboard/TraceTable.tsx @@ -3,8 +3,19 @@ import { Table } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { css } from '@emotion/react'; import { Clock, User, Cpu, MessageSquare, CheckCircle2, XCircle, Loader2, ChevronRight, ChevronDown } from 'lucide-react'; -import type { TraceTableRow, Annotation } from '../../lib/types'; +import type { TraceTableRow, Annotation, TrajectoryComparison } from '../../lib/types'; import { formatTimestamp } from '../../lib/utils'; +import { TrajectoryComparisonDetails } from '../inspector/TrajectoryComparisonDetails'; + +const TRAJECTORY_METRIC = 'tool_trajectory_avg_score'; + +function getTrajectoryComparisons(record: TraceTableRow): TrajectoryComparison[] { + return record.metricResults.get(TRAJECTORY_METRIC)?.details?.comparisons ?? []; +} + +function hasTrajectoryMismatch(record: TraceTableRow): boolean { + return getTrajectoryComparisons(record).some(c => !c.matched); +} interface TraceTableProps { rows: TraceTableRow[]; @@ -121,6 +132,19 @@ const tableStyle = css` white-space: nowrap; } + .diff-badge { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--status-failure); + background: rgba(255, 87, 87, 0.12); + border: 1px solid rgba(255, 87, 87, 0.3); + border-radius: 4px; + padding: 1px 6px; + cursor: pointer; + } + @keyframes spin { from { transform: rotate(0deg); @@ -238,27 +262,35 @@ export const TraceTable: React.FC = ({ const numTurns = record.invocations?.length || record.numInvocations || 1; const isExpanded = expandedRowKeys.includes(record.traceId); + const mismatch = hasTrajectoryMismatch(record); + const expandable = numTurns > 1 || mismatch; + + const toggle = (e: React.MouseEvent) => { + e.stopPropagation(); + setExpandedRowKeys( + isExpanded + ? expandedRowKeys.filter(k => k !== record.traceId) + : [...expandedRowKeys, record.traceId], + ); + }; - if (numTurns > 1) { + if (expandable) { return (
- { - e.stopPropagation(); - if (isExpanded) { - setExpandedRowKeys(expandedRowKeys.filter(k => k !== record.traceId)); - } else { - setExpandedRowKeys([...expandedRowKeys, record.traceId]); - } - }} - style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }} - > + {isExpanded ? : } - - {numTurns} turns - + {numTurns > 1 ? ( + {numTurns} turns + ) : ( + {record.userInputPreview} + )} + {mismatch && ( + + diff + + )}
); } @@ -385,7 +417,9 @@ export const TraceTable: React.FC = ({ const expandedRowRender = (record: TraceTableRow) => { const invocations = record.invocations || []; - if (invocations.length === 0) return null; + const comparisons = getTrajectoryComparisons(record); + const showDiff = comparisons.some(c => !c.matched); + if (invocations.length === 0 && !showDiff) return null; return (
= ({
); })} + {showDiff && ( +
0 ? '16px' : '0' }}> + +
+ )}
); }; @@ -524,7 +563,7 @@ export const TraceTable: React.FC = ({ } }, expandedRowRender, - rowExpandable: (record) => (record.invocations?.length || 0) > 1, + rowExpandable: (record) => (record.invocations?.length || 0) > 1 || hasTrajectoryMismatch(record), expandIcon: () => null, // Hide default expand icon since we show it in the Conversation column }} onRow={(record) => ({ diff --git a/ui/src/components/runs/PassRateTrendChart.tsx b/ui/src/components/runs/PassRateTrendChart.tsx new file mode 100644 index 0000000..5d6d16d --- /dev/null +++ b/ui/src/components/runs/PassRateTrendChart.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { css } from '@emotion/react'; +import { Line } from 'react-chartjs-2'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + Filler, + Tooltip, + Legend, +} from 'chart.js'; +import type { Run } from '../../lib/types'; +import { + CHART_COLORS, + METRIC_PALETTE, + agentNamesAcross, + formatTimestamp, + passRate, + runAgents, +} from './runHistory'; + +ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Filler, Tooltip, Legend); + +interface PassRateTrendChartProps { + runs: Run[]; +} + +const toPercent = (rate: number) => Math.round(rate * 1000) / 10; + +export const PassRateTrendChart: React.FC = ({ runs }) => { + const hasDecided = runs.some(run => passRate(run) !== null); + if (!hasDecided) { + return ( +
+

Pass rate over time

+

No decided (pass/fail) results yet.

+
+ ); + } + + const agentNames = agentNamesAcross(runs); + const labels = runs.map(run => formatTimestamp(run.createdAt)); + + // One line per agent (service.name) on a shared run-ordered time axis; a run + // contributes a point only to the agent(s) it ran, null elsewhere so lines + // break across gaps. Runs with no agent identity fall back to one aggregate + // line so older runs still chart. + const datasets = agentNames.length + ? agentNames.map((name, index) => { + const color = METRIC_PALETTE[index % METRIC_PALETTE.length]; + return { + label: name, + data: runs.map(run => { + const rate = passRate(run); + return runAgents(run).includes(name) && rate !== null ? toPercent(rate) : null; + }), + borderColor: color, + backgroundColor: color, + spanGaps: false, + tension: 0.25, + pointRadius: 3, + pointHoverRadius: 5, + borderWidth: 2, + }; + }) + : [ + { + label: 'Pass rate', + data: runs.map(run => { + const rate = passRate(run); + return rate === null ? null : toPercent(rate); + }), + borderColor: CHART_COLORS.passRate, + backgroundColor: CHART_COLORS.passRateFill, + fill: true, + spanGaps: false, + tension: 0.25, + pointRadius: 3, + pointHoverRadius: 5, + borderWidth: 2, + }, + ]; + + const multiAgent = agentNames.length > 0; + + const options = { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index' as const, intersect: false }, + plugins: { + legend: { + display: multiAgent, + position: 'bottom' as const, + labels: { color: CHART_COLORS.text, font: { size: 12 }, padding: 14, usePointStyle: true }, + }, + tooltip: { + backgroundColor: 'rgba(0, 0, 0, 0.9)', + titleColor: '#fff', + bodyColor: '#fff', + padding: 12, + cornerRadius: 6, + callbacks: { + label: (ctx: { dataset: { label?: string }; parsed: { y: number } }) => + `${ctx.dataset.label}: ${ctx.parsed.y}%`, + }, + }, + }, + scales: { + y: { + min: 0, + max: 100, + ticks: { + color: CHART_COLORS.text, + font: { size: 12 }, + callback: (value: string | number) => `${value}%`, + }, + grid: { color: CHART_COLORS.grid }, + }, + x: { + ticks: { color: CHART_COLORS.text, font: { size: 11 }, maxRotation: 0, autoSkip: true }, + grid: { color: CHART_COLORS.grid }, + }, + }, + }; + + return ( +
+

Pass rate over time

+
+ +
+
+ ); +}; + +const cardStyle = css` + background: var(--bg-surface); + border: 1px solid var(--border-default); + border-radius: 8px; + padding: 20px; + + h3 { + margin: 0 0 16px 0; + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + } +`; + +const chartWrapperStyle = css` + height: 300px; + position: relative; +`; + +const emptyStyle = css` + color: var(--text-tertiary); + font-size: 0.875rem; + margin: 0; +`; diff --git a/ui/src/components/runs/PerMetricTrendChart.tsx b/ui/src/components/runs/PerMetricTrendChart.tsx new file mode 100644 index 0000000..28e32c0 --- /dev/null +++ b/ui/src/components/runs/PerMetricTrendChart.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { css } from '@emotion/react'; +import { Line } from 'react-chartjs-2'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + Tooltip, + Legend, +} from 'chart.js'; +import type { Run } from '../../lib/types'; +import { CHART_COLORS, METRIC_PALETTE, formatTimestamp, metricNamesAcross } from './runHistory'; + +ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend); + +interface PerMetricTrendChartProps { + runs: Run[]; +} + +export const PerMetricTrendChart: React.FC = ({ runs }) => { + const metricNames = metricNamesAcross(runs); + + if (metricNames.length === 0) { + return ( +
+

Per-metric score trend

+

+ No per-metric scores recorded. Runs created before this feature won't have them. +

+
+ ); + } + + const labels = runs.map(run => formatTimestamp(run.createdAt)); + + const datasets = metricNames.map((name, index) => { + const color = METRIC_PALETTE[index % METRIC_PALETTE.length]; + return { + label: name, + // null where this metric is absent or produced no numeric score in a run, + // so the line breaks instead of implying a real drop to zero. + data: runs.map(run => { + const score = run.summary?.per_metric?.[name]?.avg_score; + return score == null ? null : Math.round(score * 1000) / 1000; + }), + borderColor: color, + backgroundColor: color, + spanGaps: false, + tension: 0.25, + pointRadius: 3, + pointHoverRadius: 5, + borderWidth: 2, + }; + }); + + const options = { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index' as const, intersect: false }, + plugins: { + legend: { + position: 'bottom' as const, + labels: { color: CHART_COLORS.text, font: { size: 12 }, padding: 14, usePointStyle: true }, + }, + tooltip: { + backgroundColor: 'rgba(0, 0, 0, 0.9)', + titleColor: '#fff', + bodyColor: '#fff', + padding: 12, + cornerRadius: 6, + }, + }, + scales: { + y: { + min: 0, + max: 1, + ticks: { color: CHART_COLORS.text, font: { size: 12 } }, + grid: { color: CHART_COLORS.grid }, + title: { display: true, text: 'Avg score', color: CHART_COLORS.text, font: { size: 12 } }, + }, + x: { + ticks: { color: CHART_COLORS.text, font: { size: 11 }, maxRotation: 0, autoSkip: true }, + grid: { color: CHART_COLORS.grid }, + }, + }, + }; + + return ( +
+

Per-metric score trend

+
+ +
+
+ ); +}; + +const cardStyle = css` + background: var(--bg-surface); + border: 1px solid var(--border-default); + border-radius: 8px; + padding: 20px; + + h3 { + margin: 0 0 16px 0; + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + } +`; + +const chartWrapperStyle = css` + height: 300px; + position: relative; +`; + +const emptyStyle = css` + color: var(--text-tertiary); + font-size: 0.875rem; + margin: 0; +`; diff --git a/ui/src/components/runs/RunDetailView.tsx b/ui/src/components/runs/RunDetailView.tsx new file mode 100644 index 0000000..bb67370 --- /dev/null +++ b/ui/src/components/runs/RunDetailView.tsx @@ -0,0 +1,536 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { css } from '@emotion/react'; +import { + ChevronLeft, + CheckCircle2, + XCircle, + AlertCircle, + MinusCircle, + ChevronRight, + ChevronDown, +} from 'lucide-react'; +import type { Run, RunResultRow, ResultStatus2, ToolCallComparison } from '../../lib/types'; +import { getRun, getRunResults, StorageUnavailableError } from '../../api/client'; +import { STATUS_COLORS, formatDuration, formatTimestamp, passRate, runDurationMs } from './runHistory'; + +interface RunDetailViewProps { + runId: string; + onBack: () => void; +} + +const RESULT_STATUS: Record = { + passed: { color: 'var(--status-success)', Icon: CheckCircle2 }, + failed: { color: 'var(--status-failure)', Icon: XCircle }, + errored: { color: 'var(--status-warning)', Icon: AlertCircle }, + skipped: { color: 'var(--text-tertiary)', Icon: MinusCircle }, +}; + +function formatToolCall(tc: ToolCallComparison): string { + const args = tc.args && Object.keys(tc.args).length ? JSON.stringify(tc.args) : ''; + return `${tc.name ?? '?'}(${args})`; +} + +export const RunDetailView: React.FC = ({ runId, onBack }) => { + const [run, setRun] = useState(null); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expanded, setExpanded] = useState>(new Set()); + const [showGolden, setShowGolden] = useState(false); + + useEffect(() => { + let active = true; + (async () => { + setLoading(true); + setError(null); + try { + const [r, rows] = await Promise.all([getRun(runId), getRunResults(runId)]); + if (!active) return; + setRun(r); + setResults(rows); + } catch (err) { + if (!active) return; + setError( + err instanceof StorageUnavailableError + ? err.message + : err instanceof Error + ? err.message + : 'Failed to load run', + ); + } finally { + if (active) setLoading(false); + } + })(); + return () => { + active = false; + }; + }, [runId]); + + const groupedResults = useMemo(() => { + const byCase = new Map(); + for (const row of results) { + const list = byCase.get(row.evalSetItemName) ?? []; + list.push(row); + byCase.set(row.evalSetItemName, list); + } + return [...byCase.entries()]; + }, [results]); + + const toggle = (id: string) => + setExpanded(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + + const evaluators = run?.spec?.evalConfig?.evaluators ?? []; + const goldenCases = (run?.spec?.evalSet?.eval_cases ?? []) as unknown[]; + const rate = run ? passRate(run) : null; + const counts = run?.summary?.result_counts; + + return ( +
+ + + {loading &&

Loading run...

} + {error && ( +
+ + {error} +
+ )} + + {run && !loading && ( + <> +
+

{run.spec?.evalSet?.name || run.spec?.evalSet?.eval_set_id || `Run ${run.runId.slice(0, 8)}`}

+
+ + + {run.status} + + {run.summary?.agents?.length ? ( + + agents: {run.summary.agents.join(', ')} + + ) : null} + {formatTimestamp(run.createdAt)} + duration: {formatDuration(runDurationMs(run))} + + target: {run.spec?.target?.kind ?? '-'} + {run.summary?.trace_count != null ? ` (${run.summary.trace_count} traces)` : ''} + + {rate !== null && ( + = 0.5 ? 'var(--status-success)' : 'var(--status-failure)' }}> + {Math.round(rate * 100)}% pass + + )} +
+ {counts && ( +
+ {counts.passed} passed + {counts.failed} failed + {counts.errored > 0 && {counts.errored} errored} + {counts.skipped > 0 && {counts.skipped} skipped} +
+ )} + {run.error &&
{run.error}
} +
+ +
+

Configuration

+ {evaluators.length === 0 ? ( +

No evaluator configuration recorded.

+ ) : ( + + + + + + + + + + + + {evaluators.map((e, i) => ( + + + + + + + + ))} + +
EvaluatorTypeThresholdJudge modelMatch
{e.name ?? '-'}{e.type ?? '-'}{e.threshold ?? '-'}{e.judge_model ?? '-'}{e.trajectory_match_type ?? '-'}
+ )} +
+ +
+

Results

+ {groupedResults.length === 0 ? ( +

No per-result rows persisted for this run.

+ ) : ( + groupedResults.map(([caseName, rows]) => ( +
+
+ {caseName} + {rows[0]?.traceId && rows[0].traceId !== caseName && ( + trace {rows[0].traceId.slice(0, 12)} + )} +
+ {rows.map(row => { + const meta = RESULT_STATUS[row.status]; + const Icon = meta.Icon; + const comparisons = row.details?.comparisons ?? []; + const isOpen = expanded.has(row.resultId); + return ( +
+
comparisons.length && toggle(row.resultId)} + style={{ cursor: comparisons.length ? 'pointer' : 'default' }} + > + {comparisons.length ? ( + isOpen ? : + ) : ( + + )} + + {row.evaluatorName} + {row.score !== null && ( + {row.score.toFixed(3)} + )} + {row.perInvocationScores.length > 0 && ( + + [{row.perInvocationScores.map(s => (s === null ? '-' : s.toFixed(2))).join(', ')}] + + )} + {row.latencyMs != null && {row.latencyMs}ms} +
+ {row.errorText &&
{row.errorText}
} + {isOpen && comparisons.length > 0 && ( +
+ {comparisons.map((c, idx) => ( +
+
+ {c.matched ? ( + + ) : ( + + )} + invocation {idx + 1} +
+
+
+
Expected
+ {(c.expected ?? []).length === 0 ? ( +
(no tool calls)
+ ) : ( + (c.expected ?? []).map((tc, i) => ( +
{formatToolCall(tc)}
+ )) + )} +
+
+
Actual
+ {(c.actual ?? []).length === 0 ? ( +
(no tool calls)
+ ) : ( + (c.actual ?? []).map((tc, i) => ( +
+ {formatToolCall(tc)} +
+ )) + )} +
+
+
+ ))} +
+ )} +
+ ); + })} +
+ )) + )} +
+ + {goldenCases.length > 0 && ( +
+

setShowGolden(v => !v)} + > + {showGolden ? : } + Reference: golden eval set ({goldenCases.length} case{goldenCases.length === 1 ? '' : 's'}) +

+ {showGolden && ( +
{JSON.stringify(run.spec?.evalSet?.eval_cases, null, 2)}
+ )} +
+ )} + + )} +
+ ); +}; + +const pageStyle = css` + padding: 32px; + max-width: 1100px; + margin: 0 auto; +`; + +const backStyle = css` + display: inline-flex; + align-items: center; + gap: 4px; + background: none; + border: none; + color: var(--text-secondary); + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + padding: 4px 0; + margin-bottom: 16px; + + &:hover { + color: var(--accent-primary); + } +`; + +const headerStyle = css` + margin-bottom: 24px; + + h1 { + margin: 0 0 12px; + font-size: 1.4rem; + font-weight: 700; + color: var(--text-primary); + font-family: var(--font-display); + } +`; + +const metaRowStyle = css` + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: center; +`; + +const metaItemStyle = css` + font-size: 0.8125rem; + color: var(--text-secondary); + + strong { + color: var(--accent-primary); + font-family: var(--font-mono); + } +`; + +const badgeStyle = css` + display: inline-flex; + align-items: center; + gap: 6px; + text-transform: capitalize; + font-size: 0.8125rem; + font-weight: 600; +`; + +const dotStyle = css` + width: 8px; + height: 8px; + border-radius: 50%; +`; + +const countsRowStyle = css` + display: flex; + gap: 16px; + margin-top: 10px; + font-family: var(--font-mono); + font-size: 0.8125rem; + font-weight: 600; +`; + +const cardStyle = css` + background: var(--bg-surface); + border: 1px solid var(--border-default); + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; + + h2 { + margin: 0 0 16px; + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + } +`; + +const collapsibleHeadingStyle = css` + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + user-select: none; + margin: 0 !important; +`; + +const configTableStyle = css` + width: 100%; + border-collapse: collapse; + font-size: 0.8125rem; + + th { + text-align: left; + color: var(--text-tertiary); + font-weight: 600; + text-transform: uppercase; + font-size: 0.6875rem; + letter-spacing: 0.5px; + padding: 6px 12px 6px 0; + } + + td { + padding: 6px 12px 6px 0; + color: var(--text-secondary); + border-top: 1px solid var(--border-subtle); + } +`; + +const caseBlockStyle = css` + border: 1px solid var(--border-subtle); + border-radius: 6px; + margin-bottom: 12px; + overflow: hidden; +`; + +const caseHeaderStyle = css` + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + background: var(--bg-elevated); + border-bottom: 1px solid var(--border-subtle); +`; + +const resultRowStyle = css` + padding: 8px 14px; + border-bottom: 1px solid var(--border-subtle); + + &:last-child { + border-bottom: none; + } +`; + +const resultHeadStyle = css` + display: flex; + align-items: center; + gap: 10px; +`; + +const evaluatorNameStyle = css` + flex: 1; + font-size: 0.875rem; + color: var(--text-primary); +`; + +const monoStyle = css` + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--text-secondary); +`; + +const subtleMonoStyle = css` + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--text-tertiary); +`; + +const resultErrorStyle = css` + margin: 6px 0 0 24px; + font-size: 0.8125rem; + color: var(--status-failure); + font-family: var(--font-mono); +`; + +const comparisonsStyle = css` + margin: 10px 0 4px 24px; + display: flex; + flex-direction: column; + gap: 10px; +`; + +const invocationStyle = css` + border-left: 2px solid var(--border-default); + padding-left: 12px; +`; + +const invocationHeadStyle = css` + display: flex; + align-items: center; + gap: 6px; + font-size: 0.75rem; + color: var(--text-tertiary); + margin-bottom: 6px; +`; + +const diffGridStyle = css` + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +`; + +const diffLabelStyle = css` + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-tertiary); + margin-bottom: 4px; +`; + +const toolCallStyle = css` + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--text-secondary); + word-break: break-all; +`; + +const jsonStyle = css` + background: var(--bg-primary); + border: 1px solid var(--border-subtle); + border-radius: 6px; + padding: 12px; + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--text-secondary); + overflow-x: auto; + max-height: 400px; +`; + +const mutedStyle = css` + color: var(--text-tertiary); + font-size: 0.9rem; +`; + +const errorStyle = css` + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + border-radius: 8px; + margin-top: 12px; + background: rgba(255, 87, 87, 0.08); + color: var(--status-failure); + font-size: 0.875rem; +`; diff --git a/ui/src/components/runs/RunsHistoryTable.tsx b/ui/src/components/runs/RunsHistoryTable.tsx new file mode 100644 index 0000000..a810091 --- /dev/null +++ b/ui/src/components/runs/RunsHistoryTable.tsx @@ -0,0 +1,273 @@ +import React from 'react'; +import { css } from '@emotion/react'; +import { Table } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import type { Run } from '../../lib/types'; +import { + STATUS_COLORS, + evalSetGroup, + formatDuration, + formatTimestamp, + passRate, + runDurationMs, +} from './runHistory'; + +interface RunsHistoryTableProps { + runs: Run[]; + onSelectRun?: (runId: string) => void; +} + +export const RunsHistoryTable: React.FC = ({ runs, onSelectRun }) => { + const rows = [...runs].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); + + const columns: ColumnsType = [ + { + title: 'When', + dataIndex: 'createdAt', + key: 'when', + render: (value: string) => {formatTimestamp(value)}, + }, + { + title: 'Status', + dataIndex: 'status', + key: 'status', + render: (status: Run['status']) => ( + + + {status} + + ), + }, + { + title: 'Eval set', + key: 'evalSet', + render: (_: unknown, run: Run) => ( + {evalSetGroup(run).label} + ), + }, + { + title: 'Agent', + key: 'agent', + render: (_: unknown, run: Run) => { + const agents = run.summary?.agents; + if (!Array.isArray(agents) || agents.length === 0) return -; + return {agents.join(', ')}; + }, + }, + { + title: 'Traces', + key: 'traces', + align: 'right', + render: (_: unknown, run: Run) => ( + {run.summary?.trace_count ?? '-'} + ), + }, + { + title: 'Results', + key: 'results', + render: (_: unknown, run: Run) => { + const counts = run.summary?.result_counts; + if (!counts) return -; + return ( + + {counts.passed}P + {counts.failed}F + {counts.errored > 0 && ( + {counts.errored}E + )} + {counts.skipped > 0 && ( + {counts.skipped}S + )} + + ); + }, + }, + { + title: 'Pass rate', + key: 'passRate', + render: (_: unknown, run: Run) => { + const rate = passRate(run); + if (rate === null) return -; + const pct = Math.round(rate * 100); + return ( +
+
+
= 0.5 ? 'var(--status-success)' : 'var(--status-failure)', + }} + /> +
+ {pct}% +
+ ); + }, + }, + { + title: 'Duration', + key: 'duration', + align: 'right', + render: (_: unknown, run: Run) => ( + {formatDuration(runDurationMs(run))} + ), + }, + { + title: 'Models', + key: 'models', + render: (_: unknown, run: Run) => { + const models = run.summary?.performance_metrics?.models; + if (!Array.isArray(models) || models.length === 0) return -; + return {models.join(', ')}; + }, + }, + ]; + + return ( +
+ + rowKey="runId" + columns={columns} + dataSource={rows} + pagination={{ pageSize: 10, hideOnSinglePage: true }} + size="small" + onRow={run => ({ + onClick: () => onSelectRun?.(run.runId), + style: { cursor: onSelectRun ? 'pointer' : 'default' }, + })} + /> +
+ ); +}; + +const tableStyle = css` + .ant-table { + background: var(--bg-surface); + border: 1px solid var(--border-default); + border-radius: 8px; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + } + + .ant-table-thead > tr > th { + background: var(--bg-elevated); + color: var(--text-primary); + font-weight: 600; + border-bottom: 2px solid var(--border-default); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 12px 16px; + } + + .ant-table-tbody > tr { + cursor: pointer; + transition: all 0.2s ease; + border-bottom: 1px solid var(--border-subtle); + border-left: 4px solid transparent; + background: transparent !important; + outline: 2px solid transparent; + outline-offset: -2px; + + &:hover { + border-left-color: var(--accent-primary); + outline-color: var(--accent-primary); + background: transparent !important; + } + } + + .ant-table-tbody > tr > td { + padding: 12px 16px; + color: var(--text-secondary); + background: transparent !important; + } + + .ant-table-tbody > tr:hover > td { + background: transparent !important; + } + + .ant-pagination .ant-pagination-item a, + .ant-pagination .ant-pagination-item-link { + color: var(--text-secondary); + } + + .ant-pagination .ant-pagination-item-active { + background: var(--bg-elevated); + border-color: var(--accent-primary); + } + + .ant-pagination .ant-pagination-item-active a { + color: var(--accent-primary); + } +`; + +const monoStyle = css` + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--text-secondary); +`; + +const statusStyle = css` + display: inline-flex; + align-items: center; + gap: 6px; + text-transform: capitalize; + font-size: 0.8125rem; + color: var(--text-primary); +`; + +const dotStyle = css` + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +`; + +const evalSetCellStyle = css` + color: var(--text-primary); + font-size: 0.8125rem; +`; + +const agentCellStyle = css` + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--accent-primary); +`; + +const countsStyle = css` + display: inline-flex; + gap: 8px; + font-family: var(--font-mono); + font-size: 0.8125rem; + font-weight: 600; +`; + +const barCellStyle = css` + display: flex; + align-items: center; + gap: 8px; +`; + +const barTrackStyle = css` + width: 64px; + height: 6px; + border-radius: 3px; + background: var(--bg-elevated); + overflow: hidden; +`; + +const barFillStyle = css` + height: 100%; + border-radius: 3px; + transition: width 0.3s ease; +`; + +const mutedStyle = css` + color: var(--text-tertiary); +`; + +const modelsStyle = css` + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--text-tertiary); +`; diff --git a/ui/src/components/runs/RunsView.tsx b/ui/src/components/runs/RunsView.tsx new file mode 100644 index 0000000..3c9d1c5 --- /dev/null +++ b/ui/src/components/runs/RunsView.tsx @@ -0,0 +1,376 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { css } from '@emotion/react'; +import { Segmented, Select } from 'antd'; +import { RefreshCw, History, AlertCircle } from 'lucide-react'; +import type { Run } from '../../lib/types'; +import { listRuns, StorageUnavailableError } from '../../api/client'; +import { + groupRuns, + metricNamesAcross, + passRate, + sortByCreatedAsc, + type GroupBy, +} from './runHistory'; +import { PassRateTrendChart } from './PassRateTrendChart'; +import { PerMetricTrendChart } from './PerMetricTrendChart'; +import { RunsHistoryTable } from './RunsHistoryTable'; +import { RunDetailView } from './RunDetailView'; + +const ALL_GROUPS = '__all__'; + +export const RunsView: React.FC = () => { + const [runs, setRuns] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [storageUnavailable, setStorageUnavailable] = useState(false); + const [groupBy, setGroupBy] = useState('evalSet'); + const [selectedGroup, setSelectedGroup] = useState(ALL_GROUPS); + const [detailRunId, setDetailRunId] = useState(null); + + // Group keys differ per dimension, so reset the picker to "all" when the + // grouping dimension changes to avoid pointing at a now-nonexistent group. + const changeGroupBy = (next: GroupBy) => { + setGroupBy(next); + setSelectedGroup(ALL_GROUPS); + }; + + const load = async () => { + setLoading(true); + setError(null); + setStorageUnavailable(false); + try { + const fetched = await listRuns({ limit: 200 }); + setRuns(fetched); + } catch (err) { + if (err instanceof StorageUnavailableError) { + setStorageUnavailable(true); + } else { + setError(err instanceof Error ? err.message : 'Failed to load runs'); + } + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const groups = useMemo(() => groupRuns(runs, groupBy), [runs, groupBy]); + + const selectedRuns = useMemo(() => { + if (selectedGroup === ALL_GROUPS) return runs; + return groups.find(g => g.group.key === selectedGroup)?.runs ?? []; + }, [runs, groups, selectedGroup]); + + const trendRuns = useMemo(() => sortByCreatedAsc(selectedRuns), [selectedRuns]); + + const stats = useMemo(() => { + const rates = trendRuns.map(passRate).filter((r): r is number => r !== null); + const latest = rates.length ? rates[rates.length - 1] : null; + const avg = rates.length ? rates.reduce((a, b) => a + b, 0) / rates.length : null; + return { + total: selectedRuns.length, + latest, + avg, + metrics: metricNamesAcross(selectedRuns).length, + }; + }, [selectedRuns, trendRuns]); + + if (detailRunId) { + return setDetailRunId(null)} />; + } + + return ( +
+
+
+ +

Run history

+
+
+ {runs.length > 0 && ( + + value={groupBy} + onChange={changeGroupBy} + options={[ + { value: 'evalSet', label: 'Eval set' }, + { value: 'agent', label: 'Agent' }, + ]} + /> + )} + {groups.length > 0 && ( +