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 = `
Level ${level}
${pct}% (${stats.count} runs)
`;
container.appendChild(div);
});
}
// ==========================================
// RUNNER & TERMINAL
// ==========================================
async function loadScenariosForRunner() {
const data = await apiGet('/scenarios');
if (!data) return;
const select = document.getElementById('scenario-select');
select.innerHTML = '';
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 = 'EXECUTING...';
const sid = document.getElementById('scenario-select').value;
const lvl = document.getElementById('level-select').value;
const output = document.getElementById('runner-output');
output.innerHTML = '_ Initializing Docker sandbox...
';
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 = `Execution Failed: ${err.message}
`;
} finally {
btn.disabled = false;
btn.innerHTML = 'INITIALIZE RUN⚡';
}
});
function renderTerminalExecution(container, data) {
container.innerHTML = '';
const title = document.createElement('div');
title.style.marginBottom = '1rem';
title.innerHTML = `Scenario: ${data.scenario_id}`;
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 = `
${step.action}
↳ ${statusText}
${rewardSign}${step.reward.toFixed(1)}
`;
container.appendChild(block);
});
const sum = document.createElement('div');
sum.className = 'term-summary';
const finalStatus = data.solved ? 'SOLVED ✓' : 'FAILED ✗';
sum.innerHTML = `
Result: ${finalStatus}
Steps: ${data.total_steps}
Total Reward: ${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 = `
${ep.episode_id.split('-')[0]}
${rSign}${ep.total_reward.toFixed(1)}
${ep.scenario_id} ${bText}
`;
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 = 'Failed to load replay data
';
}
});
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 = `
${sc.id}
${sc.description}
${(sc.hint_commands||[]).map(h => `${h}`).join('')}
Level ${sc.level}
${(scStats.solve_rate * 100).toFixed(1)}% Solved (${scStats.count})
`;
list.appendChild(card);
});
}
function escapeHtml(unsafe) {
return (unsafe||'').replace(/&/g, "&").replace(//g, ">");
}