diff options
Diffstat (limited to 'api/static/js')
| -rw-r--r-- | api/static/js/api.js | 46 | ||||
| -rw-r--r-- | api/static/js/components/results.js | 502 | ||||
| -rw-r--r-- | api/static/js/main.js | 31 | ||||
| -rw-r--r-- | api/static/js/ui.js | 43 |
4 files changed, 622 insertions, 0 deletions
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 |
