summaryrefslogtreecommitdiff
path: root/api
diff options
context:
space:
mode:
Diffstat (limited to 'api')
-rw-r--r--api/__init__.py1
-rw-r--r--api/endpoints/__init__.py1
-rw-r--r--api/server.py226
-rw-r--r--api/static/css/base.css66
-rw-r--r--api/static/css/components.css269
-rw-r--r--api/static/css/layout.css71
-rw-r--r--api/static/css/results.css113
-rw-r--r--api/static/index.html138
-rw-r--r--api/static/js/api.js46
-rw-r--r--api/static/js/components/results.js502
-rw-r--r--api/static/js/main.js31
-rw-r--r--api/static/js/ui.js43
12 files changed, 1507 insertions, 0 deletions
diff --git a/api/__init__.py b/api/__init__.py
new file mode 100644
index 0000000..7183f8a
--- /dev/null
+++ b/api/__init__.py
@@ -0,0 +1 @@
+# API module for Web GUI
diff --git a/api/endpoints/__init__.py b/api/endpoints/__init__.py
new file mode 100644
index 0000000..4663a01
--- /dev/null
+++ b/api/endpoints/__init__.py
@@ -0,0 +1 @@
+# API Endpoints
diff --git a/api/server.py b/api/server.py
new file mode 100644
index 0000000..c6c788c
--- /dev/null
+++ b/api/server.py
@@ -0,0 +1,226 @@
+"""
+FastAPI server for the AI Evaluation Visualization Dashboard.
+Serves read-only results, JSON logs, and static visuals.
+"""
+import os
+import sys
+from pathlib import Path
+from typing import Dict, Any, List
+import pandas as pd
+import json
+import math
+
+from fastapi import FastAPI, HTTPException
+from fastapi.responses import HTMLResponse, FileResponse
+from fastapi.staticfiles import StaticFiles
+
+# Add project root to path to import core modules
+PROJECT_ROOT = Path(__file__).parent.parent
+sys.path.insert(0, str(PROJECT_ROOT))
+from src.config_loader import ConfigLoader
+
+app = FastAPI(
+ title="AI Agent Evaluation Dashboard",
+ description="Web GUI for analyzing experiment results",
+ version="0.2.0"
+)
+
+# Mount static files
+static_dir = Path(__file__).parent / "static"
+if static_dir.exists():
+ app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
+
+def get_config():
+ """Helper to load config safely."""
+ try:
+ config = ConfigLoader(str(PROJECT_ROOT / "config.yaml"))
+ config.load()
+ return config
+ except Exception as e:
+ print(f"Warning: Could not load config: {e}")
+ return None
+
+# ============================================================================
+# Root Endpoint - Serve HTML
+# ============================================================================
+
+@app.get("/", response_class=HTMLResponse)
+async def root():
+ """Serve the main HTML page."""
+ index_path = static_dir / "index.html"
+ if index_path.exists():
+ return FileResponse(index_path)
+ return HTMLResponse(content="<h1>index.html not found</h1>", status_code=404)
+
+# ============================================================================
+# Results Endpoints
+# ============================================================================
+
+@app.get("/api/results/files")
+async def get_results_files():
+ """List all CSV files in the output directory."""
+ config = get_config()
+ if not config:
+ raise HTTPException(status_code=500, detail="Config not loaded")
+
+ output_dir = PROJECT_ROOT / config.output_dir
+ if not output_dir.exists():
+ return []
+
+ files = []
+ for p in output_dir.rglob("*.csv"):
+ files.append(str(p.relative_to(output_dir)))
+ return sorted(files)
+
+@app.get("/api/results")
+async def get_results(file: str = None):
+ """Get experiment results as JSON, optionally for a specific file."""
+ config = get_config()
+ if not config:
+ raise HTTPException(status_code=500, detail="Config not loaded")
+
+ output_dir = PROJECT_ROOT / config.output_dir
+
+ csv_files = []
+ if file:
+ file_path = output_dir / file
+ if not file_path.exists() or ".." in file:
+ raise HTTPException(status_code=404, detail="File not found")
+ csv_files.append(file_path)
+ else:
+ csv_files = list(output_dir.glob("*.csv")) if output_dir.exists() else []
+
+ results = {}
+ for csv_file in csv_files:
+ try:
+ df = pd.read_csv(csv_file)
+
+ # Convert NaN values to None for JSON serialization
+ def clean_value(val):
+ if isinstance(val, float) and (math.isnan(val) or math.isinf(val)):
+ return None
+ return val
+
+ # Clean each row
+ cleaned_data = []
+ for record in df.to_dict(orient="records"):
+ cleaned_record = {k: clean_value(v) for k, v in record.items()}
+ cleaned_data.append(cleaned_record)
+
+ results[csv_file.stem] = {
+ "columns": df.columns.tolist(),
+ "data": cleaned_data
+ }
+ except Exception as e:
+ results[csv_file.stem] = {"error": str(e)}
+
+ return results
+
+@app.get("/api/results/images")
+async def list_result_images():
+ """List generated visualization images."""
+ config = get_config()
+ if not config:
+ return []
+
+ output_dir = PROJECT_ROOT / config.output_dir
+ viz_dir = output_dir / "viz"
+
+ if not viz_dir.exists():
+ return []
+
+ images = []
+ for img in viz_dir.glob("*"):
+ if img.suffix.lower() in ['.png', '.jpg', '.jpeg', '.gif', '.svg']:
+ images.append({
+ "name": img.name,
+ "path": str(img.relative_to(PROJECT_ROOT))
+ })
+
+ return images
+
+@app.get("/api/results/images/{image_name}")
+async def get_result_image(image_name: str):
+ """Serve a specific image."""
+ config = get_config()
+ if not config:
+ raise HTTPException(status_code=500, detail="Config not loaded")
+
+ output_dir = PROJECT_ROOT / config.output_dir
+ viz_dir = output_dir / "viz"
+ image_path = viz_dir / image_name
+
+ if not image_path.exists():
+ raise HTTPException(status_code=404, detail="Image not found")
+
+ return FileResponse(image_path)
+
+# ============================================================================
+# Judge Results Endpoints
+# ============================================================================
+
+@app.get("/api/judge/files")
+async def get_judge_files():
+ """Get list of judge result files."""
+ config = get_config()
+ if not config:
+ raise HTTPException(status_code=500, detail="Config not loaded")
+
+ judge_log_dir = config._config.get('judge', {}).get('log_dir', 'judge_logs')
+ judge_dir = PROJECT_ROOT / judge_log_dir
+
+ if not judge_dir.exists():
+ return []
+
+ files = []
+ for p in judge_dir.rglob("*"):
+ if p.suffix in ['.csv', '.json']:
+ files.append(str(p.relative_to(judge_dir)))
+ return sorted(files)
+
+@app.get("/api/judge/results")
+async def get_judge_results(file: str = None):
+ """Get judge results if available."""
+ config = get_config()
+ if not config:
+ raise HTTPException(status_code=500, detail="Config not loaded")
+
+ judge_log_dir = config._config.get('judge', {}).get('log_dir', 'judge_logs')
+ judge_dir = PROJECT_ROOT / judge_log_dir
+
+ if not judge_dir.exists():
+ return {"message": "No judge results found"}
+
+ result_files = []
+ if file:
+ file_path = judge_dir / file
+ if not file_path.exists() or ".." in file:
+ raise HTTPException(status_code=404, detail="File not found")
+ result_files.append(file_path)
+ else:
+ result_files = list(judge_dir.glob("*.csv")) + list(judge_dir.glob("*.json"))
+
+ results = {}
+ for rf in result_files:
+ if rf.suffix == '.csv':
+ try:
+ df = pd.read_csv(rf)
+ results[rf.stem] = {
+ "type": "csv",
+ "columns": df.columns.tolist(),
+ "data": df.to_dict(orient="records")
+ }
+ except Exception as e:
+ results[rf.stem] = {"error": str(e)}
+ elif rf.suffix == '.json':
+ try:
+ with open(rf) as f:
+ results[rf.stem] = {"type": "json", "data": json.load(f)}
+ except Exception as e:
+ results[rf.stem] = {"error": str(e)}
+
+ return results
+
+if __name__ == "__main__":
+ import uvicorn
+ uvicorn.run(app, host="0.0.0.0", port=8000)
diff --git a/api/static/css/base.css b/api/static/css/base.css
new file mode 100644
index 0000000..aa28ba5
--- /dev/null
+++ b/api/static/css/base.css
@@ -0,0 +1,66 @@
+:root {
+ /* Colors - Deep Industrial Dark */
+ --color-bg-0: #0a0a0b; /* Deepest background */
+ --color-bg-1: #121214; /* Sidebar / Cards */
+ --color-bg-2: #1c1c1f; /* Input fields */
+ --color-bg-3: #2a2a2e; /* Borders / Hover */
+
+ /* Text */
+ --color-text-primary: #f0f0f2;
+ --color-text-secondary: #9ea0a6;
+ --color-text-muted: #62646c;
+
+ /* Accent - Pure Purple */
+ --color-accent: #9d4edd;
+ --color-accent-hover: #7b2cbf;
+ --color-accent-text: #ffffff;
+
+ /* Semantic */
+ --color-success: #10b981;
+ --color-warning: #f59e0b;
+ --color-danger: #ef4444;
+ --color-info: #3b82f6;
+
+ /* Spacing & Borders */
+ --border-width: 1px;
+ --border-radius: 4px; /* Crisp corners */
+ --space-xs: 4px;
+ --space-sm: 8px;
+ --space-md: 16px;
+ --space-lg: 24px;
+ --space-xl: 32px;
+
+ /* Transitions */
+ --transition-fast: 0.15s ease;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ background-color: var(--color-bg-0);
+ color: var(--color-text-primary);
+ line-height: 1.5;
+ height: 100vh;
+ overflow: hidden; /* Main scroll happens in panes */
+}
+
+/* Scrollbar Styling */
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+::-webkit-scrollbar-track {
+ background: var(--color-bg-0);
+}
+::-webkit-scrollbar-thumb {
+ background: var(--color-bg-3);
+ border-radius: 10px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: var(--color-accent);
+}
diff --git a/api/static/css/components.css b/api/static/css/components.css
new file mode 100644
index 0000000..e576b3a
--- /dev/null
+++ b/api/static/css/components.css
@@ -0,0 +1,269 @@
+/* Buttons */
+.btn {
+ appearance: none;
+ border: none;
+ font-family: inherit;
+ font-size: 0.875rem;
+ font-weight: 600;
+ padding: 10px 16px;
+ border-radius: var(--border-radius);
+ cursor: pointer;
+ transition: var(--transition-fast);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-sm);
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+}
+
+.btn-primary {
+ background-color: var(--color-accent);
+ color: var(--color-accent-text);
+}
+.btn-primary:hover:not(:disabled) {
+ background-color: var(--color-accent-hover);
+}
+
+.btn-danger {
+ background-color: transparent;
+ border: var(--border-width) solid var(--color-danger);
+ color: var(--color-danger);
+}
+.btn-danger:hover:not(:disabled) {
+ background-color: var(--color-danger);
+ color: white;
+}
+
+.btn-ghost {
+ background-color: transparent;
+ color: var(--color-text-secondary);
+ border: var(--border-width) solid var(--color-bg-3);
+}
+.btn-ghost:hover:not(:disabled) {
+ background-color: var(--color-bg-3);
+ color: var(--color-text-primary);
+}
+
+.btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ filter: grayscale(1);
+}
+
+/* Forms */
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+ margin-bottom: var(--space-md);
+}
+
+.form-label {
+ font-size: 0.75rem;
+ font-weight: 700;
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+}
+
+.input-field {
+ background-color: var(--color-bg-2);
+ border: var(--border-width) solid var(--color-bg-3);
+ border-radius: var(--border-radius);
+ padding: 10px 12px;
+ color: var(--color-text-primary);
+ font-size: 0.9375rem;
+ width: 100%;
+ transition: border-color var(--transition-fast);
+}
+
+.input-field:focus {
+ outline: none;
+ border-color: var(--color-accent);
+}
+
+/* Tabs */
+.tab-nav {
+ display: flex;
+ border-bottom: var(--border-width) solid var(--color-bg-3);
+ padding: 0 var(--space-lg);
+ background-color: var(--color-bg-1);
+}
+
+.tab-trigger {
+ background: none;
+ border: none;
+ padding: 14px 20px;
+ color: var(--color-text-secondary);
+ font-size: 0.875rem;
+ font-weight: 600;
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ transition: var(--transition-fast);
+}
+
+.tab-trigger:hover {
+ color: var(--color-text-primary);
+}
+
+.tab-trigger.active {
+ color: var(--color-accent);
+ border-bottom-color: var(--color-accent);
+}
+
+.tab-panel {
+ display: none;
+ padding: var(--space-lg);
+}
+
+.tab-panel.active {
+ display: block;
+}
+
+/* Cards */
+.card {
+ background-color: var(--color-bg-1);
+ border: var(--border-width) solid var(--color-bg-3);
+ border-radius: var(--border-radius);
+ overflow: hidden;
+}
+
+.card-header {
+ padding: var(--space-md);
+ border-bottom: var(--border-width) solid var(--color-bg-3);
+ font-size: 0.75rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ color: var(--color-text-muted);
+}
+
+.card-body {
+ padding: var(--space-md);
+}
+
+/* Badges */
+.badge {
+ font-size: 0.7rem;
+ font-weight: 800;
+ padding: 2px 8px;
+ border-radius: 2px;
+ text-transform: uppercase;
+}
+
+.badge-idle { background: var(--color-bg-3); color: var(--color-text-secondary); }
+.badge-running { background: var(--color-accent); color: white; animation: blink 1.5s infinite; }
+.badge-complete { background: var(--color-success); color: white; }
+
+/* Checkbox Groups for Filtering */
+.checkbox-group {
+ background-color: var(--color-bg-0);
+ border: var(--border-width) solid var(--color-bg-3);
+ border-radius: var(--border-radius);
+ padding: var(--space-xs) 0;
+ max-height: 160px;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+}
+
+.checkbox-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ padding: 6px 10px;
+ cursor: pointer;
+ transition: var(--transition-fast);
+ font-size: 0.8rem;
+ user-select: none;
+}
+
+.checkbox-item:hover {
+ background-color: var(--color-bg-3);
+}
+
+.checkbox-item input[type="checkbox"] {
+ accent-color: var(--color-accent);
+ cursor: pointer;
+}
+
+.checkbox-item span {
+ color: var(--color-text-secondary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.checkbox-item:has(input:checked) span {
+ color: var(--color-text-primary);
+ font-weight: 600;
+}
+
+.filter-actions {
+ display: flex;
+ gap: 8px;
+}
+
+.filter-link {
+ font-size: 0.6rem;
+ font-weight: 800;
+ text-transform: uppercase;
+ color: var(--color-accent);
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+
+.filter-link:hover {
+ color: var(--color-text-primary);
+ text-decoration: underline;
+}
+
+/* Modal */
+.modal-overlay {
+ position: fixed;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background-color: rgba(0, 0, 0, 0.85);
+ backdrop-filter: blur(4px);
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-xl);
+}
+
+.modal-content {
+ background-color: var(--color-bg-1);
+ border: var(--border-width) solid var(--color-bg-3);
+ border-radius: var(--border-radius);
+ width: 90vw;
+ max-width: 1200px;
+ position: relative;
+ padding: var(--space-lg);
+ box-shadow: 0 10px 30px rgba(0,0,0,0.5);
+}
+
+.btn-close {
+ position: absolute;
+ top: 10px;
+ right: 15px;
+ background: none;
+ border: none;
+ color: var(--color-text-muted);
+ font-size: 1.5rem;
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+
+.btn-close:hover {
+ color: var(--color-danger);
+}
+
+/* Make chart containers clickable */
+.chart-container canvas {
+ cursor: pointer;
+}
+
+@keyframes blink {
+ 0% { opacity: 1; }
+ 50% { opacity: 0.6; }
+ 100% { opacity: 1; }
+}
diff --git a/api/static/css/layout.css b/api/static/css/layout.css
new file mode 100644
index 0000000..2946461
--- /dev/null
+++ b/api/static/css/layout.css
@@ -0,0 +1,71 @@
+.dashboard-root {
+ display: grid;
+ grid-template-columns: 320px 1fr;
+ height: 100vh;
+ width: 100vw;
+}
+
+/* Sidebar - Left Column */
+.sidebar {
+ grid-column: 1;
+ grid-row: 1;
+ background-color: var(--color-bg-1);
+ border-right: var(--border-width) solid var(--color-bg-3);
+ padding: var(--space-lg);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xl);
+ z-index: 10;
+}
+
+/* Main Area - Right */
+.main-viewport {
+ grid-column: 2;
+ grid-row: 1;
+ overflow-y: auto;
+ background-color: var(--color-bg-0);
+ display: flex;
+ flex-direction: column;
+}
+
+/* Header */
+.app-header {
+ padding: var(--space-md) var(--space-lg);
+ border-bottom: var(--border-width) solid var(--color-bg-3);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.logo-area {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+}
+
+.logo-area h1 {
+ font-size: 1rem;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: var(--color-accent);
+}
+
+/* Mobile notice */
+@media (max-width: 900px) {
+ .dashboard-root {
+ grid-template-columns: 1fr;
+ grid-template-rows: auto 1fr;
+ overflow-y: auto;
+ }
+ .sidebar {
+ grid-row: 1;
+ border-right: none;
+ border-bottom: var(--border-width) solid var(--color-bg-3);
+ }
+ .main-viewport {
+ grid-column: 1;
+ grid-row: 2;
+ height: auto;
+ }
+} \ No newline at end of file
diff --git a/api/static/css/results.css b/api/static/css/results.css
new file mode 100644
index 0000000..9a532b8
--- /dev/null
+++ b/api/static/css/results.css
@@ -0,0 +1,113 @@
+.results-container {
+ padding: var(--space-sm);
+}
+
+.results-controls {
+ padding: var(--space-md) var(--space-lg);
+ background-color: var(--color-bg-1);
+ border-bottom: var(--border-width) solid var(--color-bg-3);
+ display: flex;
+ align-items: center;
+}
+
+.data-table-wrapper {
+ margin-bottom: var(--space-xl);
+ border: var(--border-width) solid var(--color-bg-3);
+ border-radius: var(--border-radius);
+ overflow: hidden;
+}
+
+.data-table-title {
+ background-color: var(--color-bg-1);
+ padding: var(--space-sm) var(--space-md);
+ font-size: 0.75rem;
+ font-weight: 800;
+ color: var(--color-accent);
+ text-transform: uppercase;
+ border-bottom: var(--border-width) solid var(--color-bg-3);
+}
+
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.8125rem;
+ background-color: var(--color-bg-1);
+ table-layout: auto;
+}
+
+.data-table th {
+ text-align: left;
+ padding: 12px 16px;
+ background-color: var(--color-bg-2);
+ color: var(--color-text-secondary);
+ font-weight: 700;
+ text-transform: uppercase;
+ font-size: 0.65rem;
+ letter-spacing: 0.05em;
+ border-bottom: var(--border-width) solid var(--color-bg-3);
+ white-space: nowrap;
+}
+
+.data-table td {
+ padding: 10px 16px;
+ border-bottom: var(--border-width) solid var(--color-bg-3);
+ color: var(--color-text-primary);
+ max-width: 400px;
+ white-space: normal;
+ word-wrap: break-word;
+ vertical-align: top;
+}
+
+.data-table tr:last-child td {
+ border-bottom: none;
+}
+
+.data-table tr:hover td {
+ background-color: var(--color-bg-2);
+}
+
+/* Image Grid */
+.image-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
+ gap: var(--space-lg);
+ padding: var(--space-lg);
+}
+
+.image-card {
+ background-color: var(--color-bg-1);
+ border: var(--border-width) solid var(--color-bg-3);
+ border-radius: var(--border-radius);
+ overflow: hidden;
+ transition: var(--transition-fast);
+}
+
+.image-card:hover {
+ border-color: var(--color-accent);
+}
+
+.image-card img {
+ width: 100%;
+ height: auto;
+ display: block;
+ background-color: white; /* Charts usually have white background */
+}
+
+.image-card-footer {
+ padding: var(--space-sm) var(--space-md);
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--color-text-secondary);
+ text-align: center;
+ border-top: var(--border-width) solid var(--color-bg-3);
+}
+
+/* Chart Canvas Container */
+.chart-container {
+ background-color: var(--color-bg-1);
+ border: var(--border-width) solid var(--color-bg-3);
+ border-radius: var(--border-radius);
+ padding: var(--space-md);
+ position: relative;
+ height: 300px;
+}
diff --git a/api/static/index.html b/api/static/index.html
new file mode 100644
index 0000000..5338fca
--- /dev/null
+++ b/api/static/index.html
@@ -0,0 +1,138 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>AI Agent Evaluation Dashboard</title>
+ <!-- CSS Modules -->
+ <link rel="stylesheet" href="/static/css/base.css">
+ <link rel="stylesheet" href="/static/css/layout.css">
+ <link rel="stylesheet" href="/static/css/components.css">
+ <link rel="stylesheet" href="/static/css/results.css">
+ <!-- Google Fonts - Inter -->
+ <link rel="preconnect" href="https://fonts.googleapis.com">
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
+ <!-- Chart.js -->
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
+</head>
+<body>
+ <div class="dashboard-root">
+ <!-- Sidebar: Data Filters -->
+ <aside class="sidebar">
+ <div class="logo-area">
+ <h1>AI Dashboard</h1>
+ </div>
+
+ <nav class="config-panel">
+ <div class="form-group">
+ <label class="form-label">Data Source (CSV)</label>
+ <select id="csv-file-select" class="input-field">
+ <option value="">Loading...</option>
+ </select>
+ </div>
+
+ <div class="form-group" style="margin-top: var(--space-lg);">
+ <label class="form-label">Global Filters</label>
+ <div style="font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: var(--space-md);">Toggle multiple attributes:</div>
+
+ <div class="filter-section">
+ <div style="display: flex; justify-content: space-between; align-items: center;">
+ <label class="form-label" style="font-size: 0.65rem;">Models</label>
+ <div class="filter-actions">
+ <span class="filter-link" onclick="window.toggleFilter('model', true)">All</span>
+ <span class="filter-link" onclick="window.toggleFilter('model', false)">None</span>
+ </div>
+ </div>
+ <div id="filter-model-list" class="checkbox-group"></div>
+ </div>
+
+ <div class="filter-section" style="margin-top: 12px;">
+ <div style="display: flex; justify-content: space-between; align-items: center;">
+ <label class="form-label" style="font-size: 0.65rem;">Scenarios</label>
+ <div class="filter-actions">
+ <span class="filter-link" onclick="window.toggleFilter('scenario', true)">All</span>
+ <span class="filter-link" onclick="window.toggleFilter('scenario', false)">None</span>
+ </div>
+ </div>
+ <div id="filter-scenario-list" class="checkbox-group"></div>
+ </div>
+
+ <div class="filter-section" style="margin-top: 12px;">
+ <div style="display: flex; justify-content: space-between; align-items: center;">
+ <label class="form-label" style="font-size: 0.65rem;">Oversight</label>
+ <div class="filter-actions">
+ <span class="filter-link" onclick="window.toggleFilter('oversight', true)">All</span>
+ <span class="filter-link" onclick="window.toggleFilter('oversight', false)">None</span>
+ </div>
+ </div>
+ <div id="filter-oversight-list" class="checkbox-group"></div>
+ </div>
+ </div>
+
+ <div class="sidebar-actions" style="margin-top: auto;">
+ <button id="refresh-btn" class="btn btn-primary" style="width: 100%;">Apply Filters</button>
+ </div>
+ </nav>
+ </aside>
+
+ <!-- Main Viewport: Results & Analytics -->
+ <main class="main-viewport">
+ <header class="app-header">
+ <div class="tab-nav" style="border-bottom: none; background: none; padding: 0;">
+ <button class="tab-trigger active" data-tab="images">Visual Analytics</button>
+ <button class="tab-trigger" data-tab="csv">Raw Dataset</button>
+ <button class="tab-trigger" data-tab="judge">Judge Reports</button>
+ </div>
+ </header>
+
+ <div class="viewport-content">
+ <div id="tab-images" class="tab-panel active">
+ <div class="results-controls">
+ <h2 style="font-size: 1.1rem; color: var(--color-text-primary); margin: 0;">Interactive Dashboard</h2>
+ <span id="data-stats" style="margin-left: auto; font-size: 0.8rem; font-weight: 600; color: var(--color-accent);"></span>
+ </div>
+
+ <div id="interactive-charts" class="image-grid" style="margin-top: var(--space-md);">
+ <div class="empty-state" style="grid-column: 1 / -1;">Select a CSV to generate visuals.</div>
+ </div>
+
+ <h3 style="margin: var(--space-xl) var(--space-lg) var(--space-sm); font-size: 0.8rem; text-transform: uppercase; color: var(--color-text-muted); border-bottom: 1px solid var(--color-bg-3); padding-bottom: 8px;">Static Visualizations (viz/)</h3>
+ <div id="image-results" class="image-grid">
+ <div class="empty-state" style="grid-column: 1 / -1;">No visualizations generated.</div>
+ </div>
+ </div>
+
+ <div id="tab-csv" class="tab-panel">
+ <div id="csv-results" class="results-container">
+ <div class="empty-state">No active data.</div>
+ </div>
+ </div>
+
+ <div id="tab-judge" class="tab-panel">
+ <div class="results-controls">
+ <label class="form-label">Select Report:</label>
+ <select id="judge-file-select" class="input-field" style="max-width: 400px; display: inline-block; margin-left: var(--space-sm);">
+ <option value="">Loading...</option>
+ </select>
+ </div>
+ <div id="judge-results" class="results-container">
+ <div class="empty-state">No judge reports.</div>
+ </div>
+ </div>
+ </div>
+ </main>
+ </div>
+
+ <!-- Main Entry Point -->
+ <script type="module" src="/static/js/main.js"></script>
+
+ <!-- Chart Zoom Modal -->
+ <div id="chart-modal" class="modal-overlay" style="display: none;">
+ <div class="modal-content">
+ <button id="close-modal-btn" class="btn-close">&times;</button>
+ <div id="modal-chart-container" class="chart-container" style="height: 80vh; border: none; background: transparent;"></div>
+ </div>
+ </div>
+</body>
+</html> \ No newline at end of file
diff --git a/api/static/js/api.js b/api/static/js/api.js
new file mode 100644
index 0000000..ecfe30d
--- /dev/null
+++ b/api/static/js/api.js
@@ -0,0 +1,46 @@
+/**
+ * api.js - Core API communication
+ */
+
+const API_BASE = '';
+
+export const api = {
+ async get(endpoint) {
+ const response = await fetch(`${API_BASE}${endpoint}`);
+ if (!response.ok) throw new Error(`API Error: ${response.status}`);
+ return await response.json();
+ },
+
+ async post(endpoint, data) {
+ const response = await fetch(`${API_BASE}${endpoint}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data)
+ });
+ if (!response.ok) throw new Error(`API Error: ${response.status}`);
+ return await response.json();
+ },
+
+ async delete(endpoint) {
+ const response = await fetch(`${API_BASE}${endpoint}`, { method: 'DELETE' });
+ if (!response.ok) throw new Error(`API Error: ${response.status}`);
+ return await response.json();
+ },
+
+ streamLogs(onLog, onDone, onError) {
+ const eventSource = new EventSource(`${API_BASE}/api/logs/stream`);
+
+ eventSource.onmessage = (e) => onLog(e.data);
+ eventSource.addEventListener('log', (e) => onLog(e.data));
+ eventSource.addEventListener('done', () => {
+ eventSource.close();
+ onDone();
+ });
+ eventSource.onerror = (e) => {
+ console.error('SSE Error:', e);
+ if (onError) onError(e);
+ };
+
+ return eventSource;
+ }
+};
diff --git a/api/static/js/components/results.js b/api/static/js/components/results.js
new file mode 100644
index 0000000..b143fa3
--- /dev/null
+++ b/api/static/js/components/results.js
@@ -0,0 +1,502 @@
+/**
+ * results.js - Data visualization and reports
+ */
+import { api } from '../api.js';
+import { ui } from '../ui.js';
+
+let activeCharts = [];
+let currentCsvData = null; // Store fetched data locally for quick filtering
+let currentCsvColumns = [];
+let modalChart = null;
+
+export const results = {
+ async init() {
+ // Bind events
+ ui.elements.csvFileSelect.addEventListener('change', (e) => this.handleCsvChange(e.target.value));
+ ui.elements.judgeFileSelect.addEventListener('change', (e) => this.loadJudgeData(e.target.value));
+ ui.elements.refreshBtn.addEventListener('click', () => this.applyFilters());
+
+ // Modal Setup
+ ui.elements.closeModalBtn.addEventListener('click', () => this.closeChartModal());
+ ui.elements.chartModal.addEventListener('click', (e) => {
+ if (e.target === ui.elements.chartModal) this.closeChartModal();
+ });
+
+ await Promise.all([
+ this.updateCSVFileList(),
+ this.updateJudgeFileList(),
+ this.loadImages()
+ ]);
+ },
+
+ closeChartModal() {
+ ui.elements.chartModal.style.display = 'none';
+ if (modalChart) {
+ modalChart.destroy();
+ modalChart = null;
+ }
+ },
+
+ openChartModal(config) {
+ ui.elements.chartModal.style.display = 'flex';
+ ui.elements.modalChartContainer.innerHTML = '<canvas id="modal-canvas"></canvas>';
+ const ctx = document.getElementById('modal-canvas').getContext('2d');
+
+ const modalOptions = { ...config.options, maintainAspectRatio: false };
+ if (modalOptions.plugins && modalOptions.plugins.legend) {
+ modalOptions.plugins.legend.labels = { color: '#e7e9ea', font: { size: 14 } };
+ }
+ if (modalOptions.plugins && modalOptions.plugins.title) {
+ modalOptions.plugins.title.font = { size: 20, family: 'Inter' };
+ }
+
+ modalChart = new Chart(ctx, {
+ type: config.type,
+ data: config.data,
+ options: modalOptions
+ });
+ },
+
+ async updateCSVFileList() {
+ try {
+ const files = await api.get('/api/results/files');
+ const options = files.length > 0
+ ? files.map(f => `<option value="${f}">${f}</option>`).join('')
+ : '<option value="">No files found</option>';
+
+ ui.elements.csvFileSelect.innerHTML = options;
+
+ const defaultFile = files.find(f => f.endsWith('results.csv')) || files[0];
+ if (defaultFile) {
+ ui.elements.csvFileSelect.value = defaultFile;
+ await this.handleCsvChange(defaultFile);
+ }
+ } catch (error) {
+ console.error("Failed loading CSV list", error);
+ }
+ },
+
+ async updateJudgeFileList() {
+ try {
+ const files = await api.get('/api/judge/files');
+ const options = files.length > 0
+ ? files.map(f => `<option value="${f}">${f}</option>`).join('')
+ : '<option value="">No judge reports found</option>';
+
+ ui.elements.judgeFileSelect.innerHTML = options;
+ if (files.length > 0) {
+ ui.elements.judgeFileSelect.value = files[0];
+ await this.loadJudgeData(files[0]);
+ }
+ } catch (error) {
+ console.error("Failed loading Judge file list", error);
+ }
+ },
+
+ async handleCsvChange(filePath) {
+ if (!filePath) return;
+ ui.elements.csvResults.innerHTML = '<div class="empty-state">Loading data...</div>';
+ ui.elements.interactiveCharts.innerHTML = '<div class="empty-state" style="grid-column: 1 / -1;">Loading charts...</div>';
+
+ try {
+ const data = await api.get(`/api/results?file=${encodeURIComponent(filePath)}`);
+ const content = Object.values(data)[0];
+
+ if (!content || content.error) {
+ const err = content?.error || "Unknown error";
+ ui.elements.csvResults.innerHTML = `<div class="empty-state">Error: ${err}</div>`;
+ ui.elements.interactiveCharts.innerHTML = `<div class="empty-state" style="grid-column: 1 / -1;">Error: ${err}</div>`;
+ return;
+ }
+
+ currentCsvData = content.data;
+ currentCsvColumns = content.columns;
+
+ this.populateFilters();
+ this.applyFilters(); // This draws charts and tables
+
+ } catch (err) {
+ ui.elements.csvResults.innerHTML = `<div class="empty-state">Error: ${err.message}</div>`;
+ ui.elements.interactiveCharts.innerHTML = `<div class="empty-state" style="grid-column: 1 / -1;">Error: ${err.message}</div>`;
+ }
+ },
+
+ populateFilters() {
+ const models = new Set();
+ const scenarios = new Set();
+ const oversights = new Set();
+
+ currentCsvData.forEach(row => {
+ if (row.model) models.add(row.model);
+ if (row.scenario) scenarios.add(row.scenario);
+ if (row.oversight) oversights.add(row.oversight);
+ });
+
+ this.renderCheckboxList(ui.elements.filterModelList, Array.from(models).sort(), 'model');
+ this.renderCheckboxList(ui.elements.filterScenarioList, Array.from(scenarios).sort(), 'scenario');
+
+ // Custom Oversight Sorting
+ const oversightPriority = { 'low': 0, 'mid': 1, 'medium': 1, 'high': 2 };
+ const sortedOversights = Array.from(oversights).sort((a, b) => {
+ const pA = oversightPriority[a.toLowerCase()] ?? 99;
+ const pB = oversightPriority[b.toLowerCase()] ?? 99;
+ return pA - pB;
+ });
+ this.renderCheckboxList(ui.elements.filterOversightList, sortedOversights, 'oversight');
+ },
+
+ renderCheckboxList(container, items, type) {
+ if (items.length === 0) {
+ container.innerHTML = '<div class="checkbox-item"><span>No data</span></div>';
+ return;
+ }
+
+ container.innerHTML = items.map(item => `
+ <label class="checkbox-item">
+ <input type="checkbox" name="filter-${type}" value="${item}" checked>
+ <span title="${item}">${item}</span>
+ </label>
+ `).join('');
+ },
+
+ applyFilters() {
+ if (!currentCsvData) return;
+
+ const getChecked = (name) => Array.from(document.querySelectorAll(`input[name="filter-${name}"]:checked`)).map(cb => cb.value);
+
+ const modelFilters = getChecked('model');
+ const scenarioFilters = getChecked('scenario');
+ const oversightFilters = getChecked('oversight');
+
+ const filteredData = currentCsvData.filter(row => {
+ return (modelFilters.length === 0 || modelFilters.includes(row.model)) &&
+ (scenarioFilters.length === 0 || scenarioFilters.includes(row.scenario)) &&
+ (oversightFilters.length === 0 || oversightFilters.includes(row.oversight));
+ });
+
+ ui.elements.dataStats.textContent = `Showing ${filteredData.length} of ${currentCsvData.length} records`;
+
+ this.renderTable(filteredData);
+ this.renderCharts(filteredData);
+ },
+
+ renderTable(data) {
+ if (data.length === 0) {
+ ui.elements.csvResults.innerHTML = '<div class="empty-state">No data matches filters.</div>';
+ return;
+ }
+
+ let html = `<div class="data-table-wrapper">
+ <div style="overflow-x: auto;">
+ <table class="data-table">
+ <thead><tr>${currentCsvColumns.map(c => `<th>${c}</th>`).join('')}</tr></thead>
+ <tbody>
+ ${data.map(row => `
+ <tr>${currentCsvColumns.map(col => `<td>${row[col] ?? ''}</td>`).join('')}</tr>
+ `).join('')}
+ </tbody>
+ </table>
+ </div>
+ </div>`;
+
+ ui.elements.csvResults.innerHTML = html;
+ },
+
+ renderCharts(data) {
+ activeCharts.forEach(chart => chart.destroy());
+ activeCharts = [];
+ ui.elements.interactiveCharts.innerHTML = '';
+
+ if (data.length === 0) {
+ ui.elements.interactiveCharts.innerHTML = '<div class="empty-state" style="grid-column: 1 / -1;">No data to chart.</div>';
+ return;
+ }
+
+ const cols = currentCsvColumns;
+
+ if (cols.includes('blackbox_category')) {
+ this.renderDoughnutChart('Blackbox Categories', data, 'blackbox_category');
+ }
+
+ if (cols.includes('glassbox_category')) {
+ this.renderDoughnutChart('Glassbox Categories', data, 'glassbox_category');
+ }
+
+ if (cols.includes('model') && cols.includes('blackbox_category')) {
+ this.renderStackedBarChart('Blackbox Category by Model', data, 'model', 'blackbox_category');
+ }
+
+ if (cols.includes('oversight') && cols.includes('blackbox_category')) {
+ this.renderStackedBarChart('Blackbox Category by Oversight', data, 'oversight', 'blackbox_category');
+ }
+
+ if (cols.includes('model') && cols.includes('total_tokens')) {
+ this.renderAverageTokenUsageChart('Average Token Usage by Model', data);
+ this.renderTotalTokenUsageChart('Total Token Usage by Model', data);
+ }
+
+ if (ui.elements.interactiveCharts.innerHTML === '') {
+ ui.elements.interactiveCharts.innerHTML = '<div class="empty-state" style="grid-column: 1 / -1;">No plottable categorical columns found.</div>';
+ }
+ },
+
+ renderAverageTokenUsageChart(title, data) {
+ const metrics = {}; // { model: { total: 0, count: 0, prompt: 0, completion: 0 } }
+
+ data.forEach(row => {
+ const model = row.model || 'Unknown';
+ if (!row.total_tokens) return;
+
+ if (!metrics[model]) {
+ metrics[model] = { total: 0, prompt: 0, completion: 0, count: 0 };
+ }
+ metrics[model].total += row.total_tokens || 0;
+ metrics[model].prompt += row.prompt_tokens || 0;
+ metrics[model].completion += row.completion_tokens || 0;
+ metrics[model].count += 1;
+ });
+
+ const labels = Object.keys(metrics).sort();
+ if (labels.length === 0) return;
+
+ const datasets = [
+ {
+ label: 'Avg Prompt Tokens',
+ data: labels.map(l => metrics[l].prompt / metrics[l].count),
+ backgroundColor: '#3b82f6',
+ },
+ {
+ label: 'Avg Completion Tokens',
+ data: labels.map(l => metrics[l].completion / metrics[l].count),
+ backgroundColor: '#10b981',
+ }
+ ];
+
+ const config = {
+ type: 'bar',
+ data: { labels, datasets },
+ options: {
+ ...this.getChartOptions(title),
+ scales: {
+ x: { stacked: true, ticks: { color: '#9ea0a6' }, grid: { color: '#2a2a2e' } },
+ y: { stacked: true, ticks: { color: '#9ea0a6' }, grid: { color: '#2a2a2e' } }
+ }
+ }
+ };
+
+ const canvasId = `chart-${Math.random().toString(36).substr(2, 9)}`;
+ this.createChartContainer(canvasId, config);
+
+ const ctx = document.getElementById(canvasId).getContext('2d');
+ const chart = new Chart(ctx, config);
+ activeCharts.push(chart);
+ },
+
+ renderTotalTokenUsageChart(title, data) {
+ const metrics = {}; // { model: { prompt: 0, completion: 0 } }
+
+ data.forEach(row => {
+ const model = row.model || 'Unknown';
+ if (!row.total_tokens) return;
+
+ if (!metrics[model]) {
+ metrics[model] = { prompt: 0, completion: 0 };
+ }
+ metrics[model].prompt += row.prompt_tokens || 0;
+ metrics[model].completion += row.completion_tokens || 0;
+ });
+
+ const labels = Object.keys(metrics).sort();
+ if (labels.length === 0) return;
+
+ const datasets = [
+ {
+ label: 'Total Prompt Tokens',
+ data: labels.map(l => metrics[l].prompt),
+ backgroundColor: '#6366f1',
+ },
+ {
+ label: 'Total Completion Tokens',
+ data: labels.map(l => metrics[l].completion),
+ backgroundColor: '#f59e0b',
+ }
+ ];
+
+ const config = {
+ type: 'bar',
+ data: { labels, datasets },
+ options: {
+ ...this.getChartOptions(title),
+ scales: {
+ x: { stacked: true, ticks: { color: '#9ea0a6' }, grid: { color: '#2a2a2e' } },
+ y: { stacked: true, ticks: { color: '#9ea0a6' }, grid: { color: '#2a2a2e' } }
+ }
+ }
+ };
+
+ const canvasId = `chart-${Math.random().toString(36).substr(2, 9)}`;
+ this.createChartContainer(canvasId, config);
+
+ const ctx = document.getElementById(canvasId).getContext('2d');
+ const chart = new Chart(ctx, config);
+ activeCharts.push(chart);
+ },
+
+ renderDoughnutChart(title, data, column) {
+ const counts = {};
+ data.forEach(row => {
+ const val = row[column] || 'Unknown';
+ counts[val] = (counts[val] || 0) + 1;
+ });
+
+ const config = {
+ type: 'doughnut',
+ data: {
+ labels: Object.keys(counts),
+ datasets: [{
+ data: Object.values(counts),
+ backgroundColor: ['#9d4edd', '#ef4444', '#10b981', '#f59e0b', '#3b82f6', '#6366f1'],
+ borderWidth: 0
+ }]
+ },
+ options: this.getChartOptions(title)
+ };
+
+ const canvasId = `chart-${Math.random().toString(36).substr(2, 9)}`;
+ this.createChartContainer(canvasId, config);
+
+ const ctx = document.getElementById(canvasId).getContext('2d');
+ const chart = new Chart(ctx, config);
+ activeCharts.push(chart);
+ },
+
+ renderStackedBarChart(title, data, xCol, groupCol) {
+ const matrix = {};
+ const groups = new Set();
+
+ data.forEach(row => {
+ const xVal = row[xCol] || 'Unknown';
+ const gVal = row[groupCol] || 'Unknown';
+ if (!matrix[xVal]) matrix[xVal] = {};
+ matrix[xVal][gVal] = (matrix[xVal][gVal] || 0) + 1;
+ groups.add(gVal);
+ });
+
+ let labels = Object.keys(matrix);
+
+ // Custom sorting for X-axis labels (like Oversight)
+ if (xCol === 'oversight') {
+ const oversightPriority = { 'low': 0, 'mid': 1, 'medium': 1, 'high': 2 };
+ labels.sort((a, b) => (oversightPriority[a.toLowerCase()] ?? 99) - (oversightPriority[b.toLowerCase()] ?? 99));
+ } else {
+ labels.sort();
+ }
+
+ const datasets = Array.from(groups).map((group, i) => {
+ const colors = ['#ef4444', '#10b981', '#f59e0b', '#3b82f6', '#9d4edd', '#6366f1'];
+ return {
+ label: group,
+ data: labels.map(label => matrix[label][group] || 0),
+ backgroundColor: colors[i % colors.length],
+ }
+ });
+
+ const config = {
+ type: 'bar',
+ data: { labels, datasets },
+ options: {
+ ...this.getChartOptions(title),
+ scales: {
+ x: { stacked: true, ticks: { color: '#9ea0a6' }, grid: { color: '#2a2a2e' } },
+ y: { stacked: true, ticks: { color: '#9ea0a6', stepSize: 1 }, grid: { color: '#2a2a2e' } }
+ }
+ }
+ };
+
+ const canvasId = `chart-${Math.random().toString(36).substr(2, 9)}`;
+ this.createChartContainer(canvasId, config);
+
+ const ctx = document.getElementById(canvasId).getContext('2d');
+ const chart = new Chart(ctx, config);
+ activeCharts.push(chart);
+ },
+
+ createChartContainer(canvasId, config) {
+ const container = document.createElement('div');
+ container.className = 'chart-container';
+ container.innerHTML = `<canvas id="${canvasId}" title="Click to expand"></canvas>`;
+ ui.elements.interactiveCharts.appendChild(container);
+
+ setTimeout(() => {
+ const canvas = document.getElementById(canvasId);
+ if (canvas && config) {
+ canvas.addEventListener('click', () => this.openChartModal(config));
+ }
+ }, 0);
+ },
+
+ getChartOptions(title) {
+ return {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ title: { display: true, text: title, color: '#f0f0f2', font: { family: 'Inter', size: 14 } },
+ legend: { labels: { color: '#9ea0a6' }, position: 'bottom' }
+ }
+ };
+ },
+
+ async loadImages() {
+ const images = await api.get('/api/results/images');
+ if (images.length === 0) return;
+
+ ui.elements.imageResults.innerHTML = images.map(img => `
+ <div class="image-card">
+ <img src="/api/results/images/${img.name}" alt="${img.name}" loading="lazy">
+ <div class="image-card-footer">${img.name}</div>
+ </div>
+ `).join('');
+ },
+
+ async loadJudgeData(filePath) {
+ if (!filePath) return;
+ ui.elements.judgeResults.innerHTML = '<div class="empty-state">Loading data...</div>';
+
+ try {
+ const data = await api.get(`/api/judge/results?file=${encodeURIComponent(filePath)}`);
+ if (Object.keys(data).length === 0 || data.message) {
+ ui.elements.judgeResults.innerHTML = '<div class="empty-state">No data available in this report.</div>';
+ return;
+ }
+
+ const content = Object.values(data)[0];
+ if (content.error) {
+ ui.elements.judgeResults.innerHTML = `<div class="empty-state">Error: ${content.error}</div>`;
+ return;
+ }
+
+ let html = '';
+ if (content.type === 'csv') {
+ html += `<div class="data-table-wrapper">
+ <div style="overflow-x: auto;">
+ <table class="data-table">
+ <thead><tr>${content.columns.map(c => `<th>${c}</th>`).join('')}</tr></thead>
+ <tbody>
+ ${content.data.map(row => `
+ <tr>${content.columns.map(col => `<td>${row[col] ?? ''}</td>`).join('')}</tr>
+ `).join('')}
+ </tbody>
+ </table>
+ </div>
+ </div>`;
+ } else {
+ html += `<div class="data-table-wrapper">
+ <pre style="padding: 16px; color: #f0f0f2; font-family: monospace; white-space: pre-wrap; word-wrap: break-word;">${JSON.stringify(content.data, null, 2)}</pre>
+ </div>`;
+ }
+ ui.elements.judgeResults.innerHTML = html;
+ } catch (err) {
+ ui.elements.judgeResults.innerHTML = `<div class="empty-state">Error: ${err.message}</div>`;
+ }
+ }
+}; \ No newline at end of file
diff --git a/api/static/js/main.js b/api/static/js/main.js
new file mode 100644
index 0000000..2995943
--- /dev/null
+++ b/api/static/js/main.js
@@ -0,0 +1,31 @@
+import { ui } from './ui.js';
+import { results } from './components/results.js';
+
+async function init() {
+ console.log('Initializing AI Reasoning Dashboard...');
+
+ // Global utility for filters
+ window.toggleFilter = (type, state) => {
+ document.querySelectorAll(`input[name="filter-${type}"]`).forEach(cb => cb.checked = state);
+ };
+
+ await results.init();
+
+ // Init UI behaviors
+ ui.initTabs((tabId) => {
+ // We could lazy load data here, but for now everything triggers on file/filter change
+ });
+}
+
+function start() {
+ init().catch(err => {
+ console.error("Initialization error:", err);
+ alert(`Dashboard Failed to Load: ${err.message}`);
+ });
+}
+
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', start);
+} else {
+ start();
+} \ No newline at end of file
diff --git a/api/static/js/ui.js b/api/static/js/ui.js
new file mode 100644
index 0000000..2c402ab
--- /dev/null
+++ b/api/static/js/ui.js
@@ -0,0 +1,43 @@
+export const ui = {
+ get elements() {
+ return {
+ csvFileSelect: document.getElementById('csv-file-select'),
+ judgeFileSelect: document.getElementById('judge-file-select'),
+
+ filterModelList: document.getElementById('filter-model-list'),
+ filterScenarioList: document.getElementById('filter-scenario-list'),
+ filterOversightList: document.getElementById('filter-oversight-list'),
+
+ refreshBtn: document.getElementById('refresh-btn'),
+
+ csvResults: document.getElementById('csv-results'),
+ imageResults: document.getElementById('image-results'),
+ judgeResults: document.getElementById('judge-results'),
+ interactiveCharts: document.getElementById('interactive-charts'),
+ dataStats: document.getElementById('data-stats'),
+
+ chartModal: document.getElementById('chart-modal'),
+ closeModalBtn: document.getElementById('close-modal-btn'),
+ modalChartContainer: document.getElementById('modal-chart-container'),
+
+ tabTriggers: document.querySelectorAll('.tab-trigger'),
+ tabPanels: document.querySelectorAll('.tab-panel')
+ };
+ },
+
+ initTabs(onTabChange) {
+ this.elements.tabTriggers.forEach(trigger => {
+ trigger.addEventListener('click', () => {
+ const tabId = trigger.dataset.tab;
+
+ this.elements.tabTriggers.forEach(t => t.classList.remove('active'));
+ this.elements.tabPanels.forEach(p => p.classList.remove('active'));
+
+ trigger.classList.add('active');
+ document.getElementById(`tab-${tabId}`).classList.add('active');
+
+ if (onTabChange) onTabChange(tabId);
+ });
+ });
+ }
+}; \ No newline at end of file