Spaces:
Sleeping
Sleeping
| const API_URL = window.location.origin; | |
| // Global state | |
| let currentEpisodes = []; | |
| let chartInstance = null; | |
| // DOM Elements | |
| const statusIndicator = document.getElementById('api-status'); | |
| const statusText = document.getElementById('api-status-text'); | |
| // Initialize | |
| document.addEventListener('DOMContentLoaded', () => { | |
| initNavigation(); | |
| checkApiStatus(); | |
| setInterval(checkApiStatus, 10000); | |
| }); | |
| // Navigation | |
| function initNavigation() { | |
| document.querySelectorAll('.nav-btn').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active')); | |
| document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); | |
| const target = e.currentTarget; | |
| target.classList.add('active'); | |
| const tabId = target.getAttribute('data-tab'); | |
| document.getElementById(`tab-${tabId}`).classList.add('active'); | |
| // Tab specific logic | |
| if (tabId === 'dashboard') loadStats(); | |
| if (tabId === 'runner') loadScenariosForRunner(); | |
| if (tabId === 'replay') loadRecentReplays(); | |
| if (tabId === 'scenarios') loadScenarios(); | |
| }); | |
| }); | |
| } | |
| // API Health | |
| async function checkApiStatus() { | |
| try { | |
| const res = await fetch(`${API_URL}/`); | |
| if (res.ok) { | |
| statusIndicator.className = 'status-indicator online'; | |
| statusText.textContent = 'API Online'; | |
| if (currentEpisodes.length === 0) { | |
| // Initial load | |
| loadStats(); | |
| loadScenariosForRunner(); | |
| } | |
| } else { | |
| throw new Error('Bad response'); | |
| } | |
| } catch (e) { | |
| statusIndicator.className = 'status-indicator error'; | |
| statusText.textContent = 'API Offline'; | |
| } | |
| } | |
| async function apiGet(path) { | |
| try { | |
| const res = await fetch(`${API_URL}${path}`); | |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | |
| return await res.json(); | |
| } catch (err) { | |
| console.error(`API GET ${path} failed:`, err); | |
| return null; | |
| } | |
| } | |
| // ========================================== | |
| // DASHBOARD (Chart.js & Stats) | |
| // ========================================== | |
| async function loadStats() { | |
| try { | |
| const stats = await apiGet('/stats'); | |
| if (!stats) return; | |
| document.getElementById('stat-total-episodes').textContent = stats.total_episodes || 0; | |
| let totalEps = 0; | |
| let totalSolved = 0; | |
| let totalReward = 0; | |
| Object.values(stats.levels || {}).forEach(l => { | |
| totalEps += l.count; | |
| totalSolved += l.solve_rate * l.count; | |
| totalReward += l.mean_reward * l.count; | |
| }); | |
| if (totalEps > 0) { | |
| document.getElementById('stat-overall-solve-rate').textContent = `${((totalSolved / totalEps) * 100).toFixed(1)}%`; | |
| document.getElementById('stat-mean-reward').textContent = (totalReward / totalEps).toFixed(1); | |
| } else { | |
| document.getElementById('stat-overall-solve-rate').textContent = `0%`; | |
| document.getElementById('stat-mean-reward').textContent = `0.0`; | |
| } | |
| const recentData = await apiGet('/recent?n=100'); | |
| if (recentData && recentData.episodes && recentData.episodes.length > 0) { | |
| currentEpisodes = recentData.episodes; | |
| const rev = [...recentData.episodes].reverse(); | |
| let sumSolved = 0; | |
| let chartLabels = []; | |
| let rewardData = []; | |
| let solveData = []; | |
| rev.forEach((ep, i) => { | |
| if (ep.solved) sumSolved++; | |
| chartLabels.push(`Ep ${ep.episode_id.slice(0,4)}`); | |
| rewardData.push(ep.total_reward); | |
| solveData.push((sumSolved / (i+1)) * 100); | |
| }); | |
| renderChart(chartLabels, rewardData, solveData); | |
| } | |
| renderLevelCards(stats.levels || {}); | |
| } catch (e) { | |
| console.error('Failed to load stats', e); | |
| } | |
| } | |
| function renderChart(labels, rewardData, solveData) { | |
| const ctx = document.getElementById('rewardChart').getContext('2d'); | |
| if (chartInstance) chartInstance.destroy(); | |
| chartInstance = new Chart(ctx, { | |
| type: 'line', | |
| data: { | |
| labels: labels, | |
| datasets: [ | |
| { | |
| label: 'Total Reward', | |
| data: rewardData, | |
| borderColor: '#00f0ff', | |
| backgroundColor: 'rgba(0, 240, 255, 0.1)', | |
| borderWidth: 2, | |
| tension: 0.3, | |
| fill: true, | |
| yAxisID: 'y' | |
| }, | |
| { | |
| label: 'Solve Rate (%)', | |
| data: solveData, | |
| borderColor: '#00ff9d', | |
| borderWidth: 2, | |
| borderDash: [5, 5], | |
| tension: 0.3, | |
| yAxisID: 'y1' | |
| } | |
| ] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| interaction: { mode: 'index', intersect: false }, | |
| scales: { | |
| x: { display: false }, | |
| y: { type: 'linear', display: true, position: 'left', grid: { color: 'rgba(255,255,255,0.05)' } }, | |
| y1: { type: 'linear', display: true, position: 'right', grid: { drawOnChartArea: false }, min: 0, max: 100 } | |
| }, | |
| plugins: { | |
| legend: { labels: { color: '#e2e8f0', font: { family: 'JetBrains Mono' } } } | |
| } | |
| } | |
| }); | |
| } | |
| function renderLevelCards(levels) { | |
| const container = document.getElementById('level-cards-container'); | |
| container.innerHTML = ''; | |
| [1, 2, 3].forEach(level => { | |
| const stats = levels[level] || { solve_rate: 0, count: 0 }; | |
| const pct = (stats.solve_rate * 100).toFixed(1); | |
| const div = document.createElement('div'); | |
| div.className = 'level-stat-row'; | |
| div.innerHTML = ` | |
| <div class="top"> | |
| <span class="name">Level ${level}</span> | |
| <span class="rate">${pct}% (${stats.count} runs)</span> | |
| </div> | |
| <div class="progress-track"> | |
| <div class="progress-fill" style="width: ${pct}%"></div> | |
| </div> | |
| `; | |
| container.appendChild(div); | |
| }); | |
| } | |
| // ========================================== | |
| // RUNNER & TERMINAL | |
| // ========================================== | |
| async function loadScenariosForRunner() { | |
| const data = await apiGet('/scenarios'); | |
| if (!data) return; | |
| const select = document.getElementById('scenario-select'); | |
| select.innerHTML = '<option value="">(Random Scenario)</option>'; | |
| data.scenarios.forEach(scen => { | |
| const opt = document.createElement('option'); | |
| opt.value = scen.id; | |
| opt.textContent = `${scen.id} (Level ${scen.level})`; | |
| select.appendChild(opt); | |
| }); | |
| } | |
| document.getElementById('run-episode-btn').addEventListener('click', async (e) => { | |
| const btn = e.currentTarget; | |
| btn.disabled = true; | |
| btn.innerHTML = '<span class="btn-text">EXECUTING...</span><span class="btn-icon spinner"></span>'; | |
| const sid = document.getElementById('scenario-select').value; | |
| const lvl = document.getElementById('level-select').value; | |
| const output = document.getElementById('runner-output'); | |
| output.innerHTML = '<div class="term-empty-state"><span class="blink">_</span> Initializing Docker sandbox...</div>'; | |
| try { | |
| const body = {}; | |
| if (sid) body.scenario_id = sid; | |
| if (lvl) body.level = parseInt(lvl); | |
| const res = await fetch(`${API_URL}/episode/run`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(body) | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error("Failed to run episode."); | |
| renderTerminalExecution(output, data); | |
| loadStats(); | |
| } catch (err) { | |
| output.innerHTML = `<div class="term-error">Execution Failed: ${err.message}</div>`; | |
| } finally { | |
| btn.disabled = false; | |
| btn.innerHTML = '<span class="btn-text">INITIALIZE RUN</span><span class="btn-icon">⚡</span>'; | |
| } | |
| }); | |
| function renderTerminalExecution(container, data) { | |
| container.innerHTML = ''; | |
| const title = document.createElement('div'); | |
| title.style.marginBottom = '1rem'; | |
| title.innerHTML = `<strong>Scenario:</strong> <span style="color:#00f0ff">${data.scenario_id}</span>`; | |
| container.appendChild(title); | |
| data.steps.forEach(step => { | |
| const block = document.createElement('div'); | |
| block.className = 'term-step'; | |
| let statusClass = 'term-success'; | |
| if (step.execution_result?.blocked) statusClass = 'term-blocked'; | |
| else if (step.execution_result?.exit_code !== 0) statusClass = 'term-error'; | |
| const rewardClass = step.reward >= 0 ? 'pos' : 'neg'; | |
| const rewardSign = step.reward >= 0 ? '+' : ''; | |
| let statusText = step.execution_result?.blocked ? '⚠ BLOCKED' : | |
| step.solved ? '✓ SOLVED' : | |
| step.execution_result?.exit_code === 0 ? 'ok (exit 0)' : `failed (exit ${step.execution_result?.exit_code})`; | |
| block.innerHTML = ` | |
| <div class="term-cmd">${step.action}</div> | |
| <div class="term-result ${statusClass}"> | |
| ↳ ${statusText} | |
| <span class="term-reward ${rewardClass}">${rewardSign}${step.reward.toFixed(1)}</span> | |
| </div> | |
| `; | |
| container.appendChild(block); | |
| }); | |
| const sum = document.createElement('div'); | |
| sum.className = 'term-summary'; | |
| const finalStatus = data.solved ? '<span class="term-success">SOLVED ✓</span>' : '<span class="term-error">FAILED ✗</span>'; | |
| sum.innerHTML = ` | |
| <strong>Result:</strong> ${finalStatus}<br> | |
| <strong>Steps:</strong> ${data.total_steps}<br> | |
| <strong>Total Reward:</strong> ${data.total_reward.toFixed(1)} | |
| `; | |
| container.appendChild(sum); | |
| } | |
| // ========================================== | |
| // REPLAY VIEWER | |
| // ========================================== | |
| async function loadRecentReplays() { | |
| const data = await apiGet('/recent?n=20'); | |
| if (!data) return; | |
| const list = document.getElementById('replay-recent-list'); | |
| list.innerHTML = ''; | |
| data.episodes.forEach(ep => { | |
| const item = document.createElement('div'); | |
| item.className = 'history-item'; | |
| const rClass = ep.total_reward >= 0 ? 'pos' : 'neg'; | |
| const rSign = ep.total_reward >= 0 ? '+' : ''; | |
| const bClass = ep.solved ? 'win' : ''; | |
| const bText = ep.solved ? 'SOLVED' : 'FAILED'; | |
| item.innerHTML = ` | |
| <div class="hi-top"> | |
| <span class="hi-id">${ep.episode_id.split('-')[0]}</span> | |
| <span class="hi-reward ${rClass}">${rSign}${ep.total_reward.toFixed(1)}</span> | |
| </div> | |
| <div class="hi-scenario">${ep.scenario_id} <span class="hi-badge ${bClass}">${bText}</span></div> | |
| `; | |
| item.addEventListener('click', async () => { | |
| const term = document.getElementById('replay-viewer'); | |
| document.getElementById('replay-title').textContent = `replay_${ep.episode_id.split('-')[0]}.sh`; | |
| try { | |
| const epData = await apiGet(`/replay/${ep.episode_id}`); | |
| renderTerminalExecution(term, epData); | |
| } catch (e) { | |
| term.innerHTML = '<div class="term-error">Failed to load replay data</div>'; | |
| } | |
| }); | |
| list.appendChild(item); | |
| }); | |
| } | |
| // ========================================== | |
| // SCENARIOS REGISTRY | |
| // ========================================== | |
| async function loadScenarios() { | |
| const scData = await apiGet('/scenarios'); | |
| const stData = await apiGet('/stats'); | |
| if (!scData || !stData) return; | |
| const list = document.getElementById('scenarios-list'); | |
| list.innerHTML = ''; | |
| scData.scenarios.forEach(sc => { | |
| const scStats = (stData.scenarios && stData.scenarios[sc.id]) || { count: 0, solve_rate: 0 }; | |
| const card = document.createElement('div'); | |
| card.className = 'glass-panel scenario-card'; | |
| card.innerHTML = ` | |
| <div class="sc-name">${sc.id}</div> | |
| <div class="sc-desc">${sc.description}</div> | |
| <div class="sc-hints"> | |
| ${(sc.hint_commands||[]).map(h => `<span>${h}</span>`).join('')} | |
| </div> | |
| <div class="sc-stats"> | |
| <span>Level ${sc.level}</span> | |
| <span style="color:var(--success)">${(scStats.solve_rate * 100).toFixed(1)}% Solved (${scStats.count})</span> | |
| </div> | |
| `; | |
| list.appendChild(card); | |
| }); | |
| } | |
| function escapeHtml(unsafe) { | |
| return (unsafe||'').replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); | |
| } | |