summaryrefslogtreecommitdiff
path: root/api/static
diff options
context:
space:
mode:
authorCaptainJack2491 <jayrupnakawala@gmail.com>2026-03-09 18:18:48 +0000
committerCaptainJack2491 <jayrupnakawala@gmail.com>2026-03-09 18:18:48 +0000
commit2ed454c66d9865a8abcbecc0081f7023cf4b50f9 (patch)
treef7aeb35d9d5e232f4e621edb0d9d480837e3ae64 /api/static
parentb2c7114d2042bc88c5ee33e8597704c32efd1026 (diff)
feat: Add web GUI for experiment framework
- Add FastAPI backend (api/server.py) with endpoints for: - Config read/write - Scenario/model discovery - Experiment run management (start/cancel/status) - Real-time log streaming via SSE - Results fetching (CSV, images, judge results) - Add vanilla JS frontend (api/static/): - Clean dark-themed dashboard - Configuration panel with dropdowns - Live log terminal - Results viewer with tabs - Add documentation (docs/web_gui_plan.md) Dependencies added: fastapi, uvicorn, sse-starlette
Diffstat (limited to 'api/static')
-rw-r--r--api/static/app.js516
-rw-r--r--api/static/index.html111
-rw-r--r--api/static/style.css461
3 files changed, 1088 insertions, 0 deletions
diff --git a/api/static/app.js b/api/static/app.js
new file mode 100644
index 0000000..0dcbd01
--- /dev/null
+++ b/api/static/app.js
@@ -0,0 +1,516 @@
+/* ==========================================================================
+ AI Agent Reasoning Experiment Framework - Web GUI App
+ ========================================================================== */
+
+const API_BASE = '';
+
+// State
+let eventSource = null;
+let isRunning = false;
+
+// ==========================================================================
+// DOM Elements
+// ==========================================================================
+
+const elements = {
+ // Configuration
+ scenarioSelect: document.getElementById('scenario-select'),
+ modelSelect: document.getElementById('model-select'),
+ oversightSelect: document.getElementById('oversight-select'),
+ runsInput: document.getElementById('runs-input'),
+
+ // Buttons
+ runBtn: document.getElementById('run-btn'),
+ cancelBtn: document.getElementById('cancel-btn'),
+ clearLogsBtn: document.getElementById('clear-logs-btn'),
+
+ // Status
+ statusBadge: document.getElementById('status-badge'),
+ statusTime: document.getElementById('status-time'),
+
+ // Logs
+ logsContainer: document.getElementById('logs-container'),
+
+ // Results
+ csvResults: document.getElementById('csv-results'),
+ imageResults: document.getElementById('image-results'),
+ judgeResults: document.getElementById('judge-results'),
+
+ // Tabs
+ tabBtns: document.querySelectorAll('.tab-btn'),
+ tabContents: document.querySelectorAll('.tab-content'),
+};
+
+// ==========================================================================
+// Initialization
+// ==========================================================================
+
+document.addEventListener('DOMContentLoaded', async () => {
+ await loadConfig();
+ await loadScenarios();
+ await loadModels();
+ setupEventListeners();
+ startStatusPolling();
+});
+
+// ==========================================================================
+// API Functions
+// ==========================================================================
+
+async function loadConfig() {
+ try {
+ const response = await fetch(`${API_BASE}/api/config`);
+ const config = await response.json();
+
+ // Set default values from config
+ if (config.defaults?.oversight) {
+ elements.oversightSelect.value = config.defaults.oversight;
+ }
+ } catch (error) {
+ console.error('Failed to load config:', error);
+ }
+}
+
+async function loadScenarios() {
+ try {
+ const response = await fetch(`${API_BASE}/api/scenarios`);
+ const scenarios = await response.json();
+
+ elements.scenarioSelect.innerHTML = scenarios.map(s =>
+ `<option value="${s.name}">${s.name}</option>`
+ ).join('');
+
+ // If only one scenario, auto-select it and load oversight levels
+ if (scenarios.length === 1) {
+ elements.scenarioSelect.value = scenarios[0].name;
+ updateOversightLevels(scenarios[0]);
+ }
+ } catch (error) {
+ console.error('Failed to load scenarios:', error);
+ elements.scenarioSelect.innerHTML = '<option value="">Error loading scenarios</option>';
+ }
+}
+
+async function loadModels() {
+ try {
+ const response = await fetch(`${API_BASE}/api/models`);
+ const models = await response.json();
+
+ elements.modelSelect.innerHTML = models.map(m =>
+ `<option value="${m.id}">${m.id} (${m.provider})</option>`
+ ).join('');
+ } catch (error) {
+ console.error('Failed to load models:', error);
+ elements.modelSelect.innerHTML = '<option value="">Error loading models</option>';
+ }
+}
+
+async function startRun() {
+ const scenario = elements.scenarioSelect.value;
+ const model = elements.modelSelect.value;
+ const oversight = elements.oversightSelect.value;
+ const runs = elements.runsInput.value;
+
+ if (!scenario || !model) {
+ alert('Please select a scenario and model');
+ return;
+ }
+
+ // Show loading state
+ elements.runBtn.classList.add('loading');
+ elements.runBtn.disabled = true;
+ elements.cancelBtn.disabled = false;
+ isRunning = true;
+
+ // Clear logs
+ elements.logsContainer.innerHTML = '';
+ appendLog('info', `Starting experiment: ${scenario} with ${model} (oversight: ${oversight}, runs: ${runs})`);
+
+ try {
+ // TODO: For now, we just trigger the run with current config
+ // In the future, we could modify config via API
+ const response = await fetch(`${API_BASE}/api/run`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ scenario, model, oversight, runs })
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ // Start log streaming
+ startLogStream();
+ updateStatus('running');
+
+ } catch (error) {
+ console.error('Failed to start run:', error);
+ appendLog('error', `Failed to start: ${error.message}`);
+ resetRunState();
+ }
+}
+
+async function cancelRun() {
+ try {
+ const response = await fetch(`${API_BASE}/api/run`, {
+ method: 'DELETE'
+ });
+
+ const result = await response.json();
+ appendLog('warn', 'Run cancelled by user');
+ updateStatus('cancelled');
+
+ } catch (error) {
+ console.error('Failed to cancel run:', error);
+ } finally {
+ stopLogStream();
+ resetRunState();
+ }
+}
+
+// ==========================================================================
+// Log Streaming
+// ==========================================================================
+
+function startLogStream() {
+ stopLogStream(); // Close any existing connection
+
+ eventSource = new EventSource(`${API_BASE}/api/logs/stream`);
+
+ eventSource.onmessage = (event) => {
+ const data = event.data;
+ if (data) {
+ appendLog('info', data);
+ }
+ };
+
+ eventSource.addEventListener('log', (event) => {
+ const data = event.data;
+ if (data) {
+ appendLog('info', data);
+ }
+ });
+
+ eventSource.addEventListener('done', (event) => {
+ appendLog('info', '=== Run Complete ===');
+ stopLogStream();
+ isRunning = false;
+ updateStatus('complete');
+ resetRunState();
+ loadResults();
+ });
+
+ eventSource.addEventListener('error', (event) => {
+ console.error('SSE Error:', event);
+ });
+}
+
+function stopLogStream() {
+ if (eventSource) {
+ eventSource.close();
+ eventSource = null;
+ }
+}
+
+function appendLog(level, message) {
+ // Remove empty state if present
+ const emptyState = elements.logsContainer.querySelector('.logs-empty');
+ if (emptyState) {
+ emptyState.remove();
+ }
+
+ const line = document.createElement('div');
+ line.className = 'log-line';
+
+ // Color based on content
+ let logLevel = 'log-level-3';
+ if (message.includes('[ERROR]') || message.includes('error') || message.includes('Error')) {
+ logLevel = 'log-level-1';
+ } else if (message.includes('[WARN]') || message.includes('warning') || message.includes('Warning')) {
+ logLevel = 'log-level-2';
+ } else if (message.includes('[DEBUG]') || message.includes('[DEBUG+]')) {
+ logLevel = 'log-level-4';
+ }
+
+ line.classList.add(logLevel);
+ line.textContent = message;
+
+ elements.logsContainer.appendChild(line);
+
+ // Auto-scroll to bottom
+ elements.logsContainer.scrollTop = elements.logsContainer.scrollHeight;
+}
+
+function clearLogs() {
+ elements.logsContainer.innerHTML = '<div class="logs-empty">Run an experiment to see logs...</div>';
+}
+
+// ==========================================================================
+// Status Polling
+// ==========================================================================
+
+let statusPollingInterval = null;
+
+function startStatusPolling() {
+ statusPollingInterval = setInterval(async () => {
+ try {
+ const response = await fetch(`${API_BASE}/api/run/status`);
+ const status = await response.json();
+
+ if (status.status !== 'idle' && !isRunning) {
+ // Something is running but we don't know about it
+ isRunning = true;
+ elements.runBtn.classList.add('loading');
+ elements.runBtn.disabled = true;
+ elements.cancelBtn.disabled = false;
+ startLogStream();
+ }
+
+ updateStatusDisplay(status.status, status.start_time);
+
+ } catch (error) {
+ console.error('Status poll error:', error);
+ }
+ }, 2000);
+}
+
+function updateStatus(status) {
+ updateStatusDisplay(status);
+
+ if (status === 'running') {
+ elements.runBtn.classList.add('loading');
+ elements.runBtn.disabled = true;
+ elements.cancelBtn.disabled = false;
+ }
+}
+
+function updateStatusDisplay(status, startTime = null) {
+ elements.statusBadge.className = `badge badge-${status}`;
+
+ const statusText = {
+ 'idle': 'Idle',
+ 'running': 'Running...',
+ 'complete': 'Complete',
+ 'error': 'Error',
+ 'cancelled': 'Cancelled'
+ };
+
+ elements.statusBadge.textContent = statusText[status] || status;
+
+ if (startTime) {
+ const date = new Date(startTime);
+ elements.statusTime.textContent = `Started: ${date.toLocaleTimeString()}`;
+ } else if (status === 'idle') {
+ elements.statusTime.textContent = '';
+ }
+}
+
+function resetRunState() {
+ elements.runBtn.classList.remove('loading');
+ elements.runBtn.disabled = false;
+ elements.cancelBtn.disabled = true;
+ isRunning = false;
+}
+
+// ==========================================================================
+// Results Loading
+// ==========================================================================
+
+async function loadResults() {
+ await Promise.all([
+ loadCSVResults(),
+ loadImageResults(),
+ loadJudgeResults()
+ ]);
+}
+
+async function loadCSVResults() {
+ try {
+ const response = await fetch(`${API_BASE}/api/results`);
+ const results = await response.json();
+
+ if (Object.keys(results).length === 0) {
+ elements.csvResults.innerHTML = '<div class="empty-state">No results yet</div>';
+ return;
+ }
+
+ let html = '';
+
+ for (const [filename, data] of Object.entries(results)) {
+ if (data.error) {
+ html += `<h3>${filename}</h3><p>Error: ${data.error}</p>`;
+ continue;
+ }
+
+ html += `<h3>${filename}.csv</h3>`;
+ html += '<div style="overflow-x: auto;"><table class="data-table">';
+
+ // Header
+ html += '<thead><tr>';
+ for (const col of data.columns) {
+ html += `<th>${col}</th>`;
+ }
+ html += '</tr></thead>';
+
+ // Body
+ html += '<tbody>';
+ for (const row of data.data) {
+ html += '<tr>';
+ for (const col of data.columns) {
+ html += `<td>${row[col] ?? ''}</td>`;
+ }
+ html += '</tr>';
+ }
+ html += '</tbody></table></div>';
+ }
+
+ elements.csvResults.innerHTML = html || '<div class="empty-state">No results yet</div>';
+
+ } catch (error) {
+ console.error('Failed to load CSV results:', error);
+ }
+}
+
+async function loadImageResults() {
+ try {
+ const response = await fetch(`${API_BASE}/api/results/images`);
+ const images = await response.json();
+
+ if (images.length === 0) {
+ elements.imageResults.innerHTML = '<div class="empty-state">No visualizations yet</div>';
+ return;
+ }
+
+ elements.imageResults.innerHTML = images.map(img => `
+ <div class="image-card">
+ <img src="${API_BASE}/api/results/images/${img.name}" alt="${img.name}">
+ <div class="image-title">${img.name}</div>
+ </div>
+ `).join('');
+
+ } catch (error) {
+ console.error('Failed to load images:', error);
+ }
+}
+
+async function loadJudgeResults() {
+ try {
+ const response = await fetch(`${API_BASE}/api/judge/results`);
+ const results = await response.json();
+
+ if (results.message || Object.keys(results).length === 0) {
+ elements.judgeResults.innerHTML = '<div class="empty-state">No judge results yet</div>';
+ return;
+ }
+
+ let html = '';
+
+ for (const [filename, data] of Object.entries(results)) {
+ if (data.type === 'csv') {
+ html += `<h3>${filename}</h3>`;
+ html += '<div style="overflow-x: auto;"><table class="data-table">';
+
+ html += '<thead><tr>';
+ for (const col of data.columns) {
+ html += `<th>${col}</th>`;
+ }
+ html += '</tr></thead><tbody>';
+
+ for (const row of data.data) {
+ html += '<tr>';
+ for (const col of data.columns) {
+ html += `<td>${row[col] ?? ''}</td>`;
+ }
+ html += '</tr>';
+ }
+ html += '</tbody></table></div>';
+ } else {
+ html += `<h3>${filename}</h3><pre>${JSON.stringify(data.data, null, 2)}</pre>`;
+ }
+ }
+
+ elements.judgeResults.innerHTML = html || '<div class="empty-state">No judge results</div>';
+
+ } catch (error) {
+ console.error('Failed to load judge results:', error);
+ }
+}
+
+// ==========================================================================
+// Event Listeners
+// ==========================================================================
+
+function setupEventListeners() {
+ // Run button
+ elements.runBtn.addEventListener('click', startRun);
+
+ // Cancel button
+ elements.cancelBtn.addEventListener('click', cancelRun);
+
+ // Clear logs button
+ elements.clearLogsBtn.addEventListener('click', clearLogs);
+
+ // Scenario selection - update oversight levels
+ elements.scenarioSelect.addEventListener('change', async (e) => {
+ const scenarioName = e.target.value;
+ if (!scenarioName) return;
+
+ try {
+ const response = await fetch(`${API_BASE}/api/scenarios/${scenarioName}`);
+ const scenario = await response.json();
+ updateOversightLevels(scenario);
+ } catch (error) {
+ console.error('Failed to load scenario details:', error);
+ }
+ });
+
+ // Tab switching
+ elements.tabBtns.forEach(btn => {
+ btn.addEventListener('click', () => {
+ const tabId = btn.dataset.tab;
+
+ // Update active tab button
+ elements.tabBtns.forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+
+ // Update active tab content
+ elements.tabContents.forEach(content => {
+ content.classList.remove('active');
+ if (content.id === `tab-${tabId}`) {
+ content.classList.add('active');
+ }
+ });
+
+ // Load results if switching to results tab
+ if (tabId === 'csv' || tabId === 'images' || tabId === 'judge') {
+ loadResults();
+ }
+ });
+ });
+
+ // Keyboard shortcuts
+ document.addEventListener('keydown', (e) => {
+ // Ctrl/Cmd + Enter to run
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && !isRunning) {
+ startRun();
+ }
+ });
+}
+
+function updateOversightLevels(scenario) {
+ const oversightSelect = elements.oversightSelect;
+ const levels = scenario.oversight_levels || [];
+
+ if (levels.length === 0) {
+ // No scenario-specific oversight, use global levels
+ oversightSelect.innerHTML = `
+ <option value="low">Low</option>
+ <option value="mid">Mid</option>
+ <option value="high">High</option>
+ `;
+ } else {
+ oversightSelect.innerHTML = levels.map(level =>
+ `<option value="${level}">${level.charAt(0).toUpperCase() + level.slice(1)}</option>`
+ ).join('');
+ }
+}
diff --git a/api/static/index.html b/api/static/index.html
new file mode 100644
index 0000000..cdf8d2f
--- /dev/null
+++ b/api/static/index.html
@@ -0,0 +1,111 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>AI Agent Reasoning Experiment Framework</title>
+ <link rel="stylesheet" href="/static/style.css">
+</head>
+<body>
+ <div class="container">
+ <header>
+ <h1>🤖 AI Agent Reasoning Experiment Framework</h1>
+ <p class="subtitle">Run experiments, analyze deception, and visualize results</p>
+ </header>
+
+ <main>
+ <!-- Configuration Section -->
+ <section class="card" id="config-section">
+ <h2>⚙️ Configuration</h2>
+ <div class="config-grid">
+ <div class="config-item">
+ <label for="scenario-select">Scenario</label>
+ <select id="scenario-select">
+ <option value="">Loading...</option>
+ </select>
+ </div>
+ <div class="config-item">
+ <label for="model-select">Model</label>
+ <select id="model-select">
+ <option value="">Loading...</option>
+ </select>
+ </div>
+ <div class="config-item">
+ <label for="oversight-select">Oversight Level</label>
+ <select id="oversight-select">
+ <option value="low">Low</option>
+ <option value="mid">Mid</option>
+ <option value="high">High</option>
+ </select>
+ </div>
+ <div class="config-item">
+ <label for="runs-input">Runs</label>
+ <input type="number" id="runs-input" value="1" min="1" max="100">
+ </div>
+ </div>
+ <div class="config-actions">
+ <button id="run-btn" class="btn btn-primary">
+ <span class="btn-text">▶ Run Experiment</span>
+ <span class="btn-spinner hidden">⏳ Running...</span>
+ </button>
+ <button id="cancel-btn" class="btn btn-danger" disabled>⏹ Cancel</button>
+ </div>
+ </section>
+
+ <!-- Status Section -->
+ <section class="card" id="status-section">
+ <h2>📊 Status</h2>
+ <div class="status-indicator">
+ <span id="status-badge" class="badge badge-idle">Idle</span>
+ <span id="status-time"></span>
+ </div>
+ </section>
+
+ <!-- Logs Section -->
+ <section class="card" id="logs-section">
+ <h2>📜 Live Logs</h2>
+ <div class="logs-container" id="logs-container">
+ <div class="logs-empty">Run an experiment to see logs...</div>
+ </div>
+ <div class="logs-actions">
+ <button id="clear-logs-btn" class="btn btn-secondary">Clear Logs</button>
+ </div>
+ </section>
+
+ <!-- Results Section -->
+ <section class="card" id="results-section">
+ <h2>📈 Results</h2>
+ <div class="tabs">
+ <button class="tab-btn active" data-tab="csv">CSV Data</button>
+ <button class="tab-btn" data-tab="images">Visualizations</button>
+ <button class="tab-btn" data-tab="judge">Judge Results</button>
+ </div>
+
+ <div class="tab-content active" id="tab-csv">
+ <div id="csv-results">
+ <div class="empty-state">No results yet. Run an experiment first.</div>
+ </div>
+ </div>
+
+ <div class="tab-content" id="tab-images">
+ <div id="image-results" class="image-grid">
+ <div class="empty-state">No visualizations yet. Run an experiment first.</div>
+ </div>
+ </div>
+
+ <div class="tab-content" id="tab-judge">
+ <div id="judge-results">
+ <div class="empty-state">No judge results yet.</div>
+ </div>
+ </div>
+ </section>
+ </main>
+
+ <footer>
+ <p>AI Agent Reasoning Experiment Framework • Dissertation Project</p>
+ </footer>
+ </div>
+
+ <script src="/static/app.js"></script>
+</body>
+</html>
diff --git a/api/static/style.css b/api/static/style.css
new file mode 100644
index 0000000..8a1a13a
--- /dev/null
+++ b/api/static/style.css
@@ -0,0 +1,461 @@
+/* ==========================================================================
+ AI Agent Reasoning Experiment Framework - Web GUI Styles
+ ========================================================================== */
+
+:root {
+ --bg-primary: #0f1419;
+ --bg-secondary: #1a1f26;
+ --bg-tertiary: #242b33;
+ --bg-card: #1e2530;
+ --text-primary: #e7e9ea;
+ --text-secondary: #8b98a5;
+ --text-muted: #5c6c7a;
+ --accent-primary: #1d9bf0;
+ --accent-primary-hover: #1a8cd8;
+ --accent-success: #00ba7c;
+ --accent-warning: #ffd400;
+ --accent-danger: #f4212e;
+ --border-color: #2f3942;
+ --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+ --font-mono: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: var(--font-family);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ line-height: 1.6;
+ min-height: 100vh;
+}
+
+.container {
+ max-width: 1400px;
+ margin: 0 auto;
+ padding: 20px;
+}
+
+/* ==========================================================================
+ Header
+ ========================================================================== */
+
+header {
+ text-align: center;
+ padding: 40px 0 30px;
+ border-bottom: 1px solid var(--border-color);
+ margin-bottom: 30px;
+}
+
+header h1 {
+ font-size: 2rem;
+ font-weight: 700;
+ margin-bottom: 8px;
+ background: linear-gradient(135deg, var(--accent-primary), #a855f7);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+header .subtitle {
+ color: var(--text-secondary);
+ font-size: 1.1rem;
+}
+
+/* ==========================================================================
+ Cards & Sections
+ ========================================================================== */
+
+.card {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+ padding: 24px;
+ margin-bottom: 24px;
+}
+
+.card h2 {
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin-bottom: 20px;
+ color: var(--text-primary);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+/* ==========================================================================
+ Configuration Grid
+ ========================================================================== */
+
+.config-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 20px;
+ margin-bottom: 24px;
+}
+
+.config-item {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.config-item label {
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+ font-weight: 500;
+}
+
+.config-item select,
+.config-item input {
+ padding: 10px 14px;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ font-size: 0.95rem;
+ transition: border-color 0.2s, box-shadow 0.2s;
+}
+
+.config-item select:focus,
+.config-item input:focus {
+ outline: none;
+ border-color: var(--accent-primary);
+ box-shadow: 0 0 0 3px rgba(29, 155, 240, 0.15);
+}
+
+.config-actions {
+ display: flex;
+ gap: 12px;
+}
+
+/* ==========================================================================
+ Buttons
+ ========================================================================== */
+
+.btn {
+ padding: 12px 24px;
+ border: none;
+ border-radius: 8px;
+ font-size: 0.95rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-primary {
+ background: var(--accent-primary);
+ color: white;
+}
+
+.btn-primary:hover:not(:disabled) {
+ background: var(--accent-primary-hover);
+ transform: translateY(-1px);
+}
+
+.btn-secondary {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ border: 1px solid var(--border-color);
+}
+
+.btn-secondary:hover:not(:disabled) {
+ background: var(--border-color);
+}
+
+.btn-danger {
+ background: transparent;
+ color: var(--accent-danger);
+ border: 1px solid var(--accent-danger);
+}
+
+.btn-danger:hover:not(:disabled) {
+ background: var(--accent-danger);
+ color: white;
+}
+
+.btn .btn-spinner {
+ display: none;
+}
+
+.btn.loading .btn-text {
+ display: none;
+}
+
+.btn.loading .btn-spinner {
+ display: inline;
+}
+
+/* ==========================================================================
+ Status Badge
+ ========================================================================== */
+
+.status-indicator {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.badge {
+ padding: 6px 14px;
+ border-radius: 20px;
+ font-size: 0.875rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.badge-idle {
+ background: var(--bg-tertiary);
+ color: var(--text-secondary);
+}
+
+.badge-running {
+ background: rgba(29, 155, 240, 0.15);
+ color: var(--accent-primary);
+ animation: pulse 2s infinite;
+}
+
+.badge-complete {
+ background: rgba(0, 186, 124, 0.15);
+ color: var(--accent-success);
+}
+
+.badge-error {
+ background: rgba(244, 33, 46, 0.15);
+ color: var(--accent-danger);
+}
+
+.badge-cancelled {
+ background: rgba(255, 212, 0, 0.15);
+ color: var(--accent-warning);
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.7; }
+}
+
+#status-time {
+ color: var(--text-muted);
+ font-size: 0.875rem;
+}
+
+/* ==========================================================================
+ Logs Container
+ ========================================================================== */
+
+.logs-container {
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ padding: 16px;
+ font-family: var(--font-mono);
+ font-size: 0.8rem;
+ max-height: 400px;
+ overflow-y: auto;
+ line-height: 1.8;
+}
+
+.logs-container::-webkit-scrollbar {
+ width: 8px;
+}
+
+.logs-container::-webkit-scrollbar-track {
+ background: var(--bg-primary);
+}
+
+.logs-container::-webkit-scrollbar-thumb {
+ background: var(--border-color);
+ border-radius: 4px;
+}
+
+.log-line {
+ padding: 2px 0;
+ white-space: pre-wrap;
+ word-break: break-all;
+}
+
+.log-line:hover {
+ background: var(--bg-secondary);
+}
+
+.log-level-1 { color: var(--accent-danger); }
+.log-level-2 { color: var(--accent-warning); }
+.log-level-3 { color: var(--text-primary); }
+.log-level-4 { color: var(--text-muted); }
+
+.logs-empty {
+ color: var(--text-muted);
+ text-align: center;
+ padding: 40px;
+}
+
+.logs-actions {
+ margin-top: 12px;
+}
+
+/* ==========================================================================
+ Tabs
+ ========================================================================== */
+
+.tabs {
+ display: flex;
+ gap: 4px;
+ border-bottom: 1px solid var(--border-color);
+ margin-bottom: 20px;
+}
+
+.tab-btn {
+ padding: 12px 20px;
+ background: transparent;
+ border: none;
+ color: var(--text-secondary);
+ font-size: 0.95rem;
+ font-weight: 500;
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ transition: all 0.2s;
+ margin-bottom: -1px;
+}
+
+.tab-btn:hover {
+ color: var(--text-primary);
+}
+
+.tab-btn.active {
+ color: var(--accent-primary);
+ border-bottom-color: var(--accent-primary);
+}
+
+.tab-content {
+ display: none;
+}
+
+.tab-content.active {
+ display: block;
+}
+
+/* ==========================================================================
+ Results Tables
+ ========================================================================== */
+
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.875rem;
+}
+
+.data-table th,
+.data-table td {
+ padding: 12px;
+ text-align: left;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.data-table th {
+ background: var(--bg-tertiary);
+ color: var(--text-secondary);
+ font-weight: 600;
+ text-transform: uppercase;
+ font-size: 0.75rem;
+ letter-spacing: 0.5px;
+}
+
+.data-table tr:hover td {
+ background: var(--bg-secondary);
+}
+
+.data-table td {
+ color: var(--text-primary);
+}
+
+/* ==========================================================================
+ Images Grid
+ ========================================================================== */
+
+.image-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 20px;
+}
+
+.image-card {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.image-card img {
+ width: 100%;
+ height: auto;
+ display: block;
+}
+
+.image-card .image-title {
+ padding: 12px;
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+ text-align: center;
+}
+
+/* ==========================================================================
+ Empty States
+ ========================================================================== */
+
+.empty-state {
+ text-align: center;
+ padding: 40px;
+ color: var(--text-muted);
+ background: var(--bg-secondary);
+ border-radius: 8px;
+ border: 1px dashed var(--border-color);
+}
+
+/* ==========================================================================
+ Footer
+ ========================================================================== */
+
+footer {
+ text-align: center;
+ padding: 30px;
+ color: var(--text-muted);
+ font-size: 0.875rem;
+}
+
+/* ==========================================================================
+ Responsive
+ ========================================================================== */
+
+@media (max-width: 768px) {
+ .container {
+ padding: 12px;
+ }
+
+ header h1 {
+ font-size: 1.5rem;
+ }
+
+ .config-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .logs-container {
+ max-height: 300px;
+ font-size: 0.75rem;
+ }
+}