diff options
| author | CaptainJack2491 <jayrupnakawala@gmail.com> | 2026-03-09 18:18:48 +0000 |
|---|---|---|
| committer | CaptainJack2491 <jayrupnakawala@gmail.com> | 2026-03-09 18:18:48 +0000 |
| commit | 2ed454c66d9865a8abcbecc0081f7023cf4b50f9 (patch) | |
| tree | f7aeb35d9d5e232f4e621edb0d9d480837e3ae64 /api/static | |
| parent | b2c7114d2042bc88c5ee33e8597704c32efd1026 (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.js | 516 | ||||
| -rw-r--r-- | api/static/index.html | 111 | ||||
| -rw-r--r-- | api/static/style.css | 461 |
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; + } +} |
