const express = require('express'); const fs = require('fs'); const path = require('path'); const { exec } = require('child_process'); const { promisify } = require('util'); const execAsync = promisify(exec); const Database = require('better-sqlite3'); const app = express(); const PORT = 4000; app.use(express.json()); app.use(express.static(path.join(__dirname, '../frontend/dist'))); const SCENARIOS_DIR = path.join(__dirname, '../scenarios/data'); const BUNDLES_DIR = path.join(__dirname, '../scenarios/bundles'); const DB_FILE = process.env.PROGRESS_DB || '/data/progress.db'; // ── SQLite progress store ───────────────────────────────────────────────────── let _db = null; function getDb() { if (_db) return _db; const dir = path.dirname(DB_FILE); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); _db = new Database(DB_FILE); _db.exec(` CREATE TABLE IF NOT EXISTS progress ( scenario_id TEXT PRIMARY KEY, status TEXT, attempts INTEGER DEFAULT 0, last_validated TEXT, completed_at TEXT ) `); // Run dynamic schema migrations to add missing columns to the progress table try { const tableInfo = _db.prepare("PRAGMA table_info(progress)").all(); const existingColumns = tableInfo.map(col => col.name); const requiredColumns = [ { name: 'started_at', type: 'TEXT' }, { name: 'notes', type: 'TEXT' }, { name: 'time_spent_seconds', type: 'INTEGER' } ]; for (const col of requiredColumns) { if (!existingColumns.includes(col.name)) { console.log(`Migrating progress database schema: Adding column '${col.name}' (${col.type})`); _db.exec(`ALTER TABLE progress ADD COLUMN ${col.name} ${col.type}`); } } } catch (e) { console.error('Failed to run schema migrations on progress table:', e.message); } _db.exec(` CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, bundle_id TEXT NOT NULL, started_at TEXT NOT NULL, submitted_at TEXT, status TEXT NOT NULL DEFAULT 'active', exam_minutes INTEGER NOT NULL DEFAULT 120, duration_secs INTEGER, snapshot TEXT, scenario_ids TEXT ) `); // Migrate: add scenario_ids column if missing try { const cols = _db.prepare("PRAGMA table_info(sessions)").all().map(c => c.name); if (!cols.includes('scenario_ids')) { _db.exec(`ALTER TABLE sessions ADD COLUMN scenario_ids TEXT`); } } catch (e) { console.error('Failed to migrate sessions table:', e.message); } // Separate exam-session progress table — tracks completions per session _db.exec(` CREATE TABLE IF NOT EXISTS exam_progress ( session_id TEXT NOT NULL, scenario_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'in_progress', attempts INTEGER NOT NULL DEFAULT 0, completed_at TEXT, PRIMARY KEY (session_id, scenario_id), FOREIGN KEY (session_id) REFERENCES sessions(id) ) `); return _db; } function loadProgress() { try { const rows = getDb().prepare('SELECT * FROM progress').all(); return Object.fromEntries(rows.map(r => [r.scenario_id, { status: r.status, attempts: r.attempts, last_validated: r.last_validated, completed_at: r.completed_at, started_at: r.started_at || null, notes: r.notes || null, time_spent_seconds: r.time_spent_seconds || 0 }])); } catch { return {}; } } function saveProgress(progress) { try { const db = getDb(); const upsert = db.prepare(` INSERT INTO progress (scenario_id, status, attempts, last_validated, completed_at, started_at, notes, time_spent_seconds) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(scenario_id) DO UPDATE SET status = excluded.status, attempts = excluded.attempts, last_validated = excluded.last_validated, completed_at = excluded.completed_at, started_at = excluded.started_at, notes = excluded.notes, time_spent_seconds = excluded.time_spent_seconds `); const tx = db.transaction((entries) => { for (const [id, p] of entries) { upsert.run( id, p.status || null, p.attempts || 0, p.last_validated || null, p.completed_at || null, p.started_at || null, p.notes || null, p.time_spent_seconds || 0 ); } }); tx(Object.entries(progress)); } catch (e) { console.error('Failed to save progress:', e.message); } } function loadJsonDir(dir) { return fs.readdirSync(dir) .filter(f => f.endsWith('.json')) .map(f => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))); } let scenariosCache = []; let bundlesCache = []; function reloadCache() { try { scenariosCache = loadJsonDir(SCENARIOS_DIR); bundlesCache = loadJsonDir(BUNDLES_DIR); console.log(`Loaded ${scenariosCache.length} scenarios and ${bundlesCache.length} bundles into cache.`); } catch (e) { console.error('Failed to reload cache:', e.message); } } // Initial cache populate reloadCache(); function loadScenarios() { return scenariosCache; } function loadBundles() { return bundlesCache; } async function runCommand(cmd, timeoutMs = 15000) { try { const { stdout } = await execAsync(cmd, { timeout: timeoutMs, encoding: 'utf8', env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG || '/root/.kube/config' } }); return { success: true, output: stdout.trim() }; } catch (e) { return { success: false, output: (e.stdout || '').trim(), error: (e.stderr || e.message || '').trim() }; } } function checkMatch(actual, expected, matchType) { const a = String(actual).trim(); const e = String(expected).trim(); if (matchType === 'exact') return a === e; if (matchType === 'contains') return a.includes(e); if (matchType === 'not_contains') return !a.includes(e); if (matchType === 'regex') return new RegExp(e).test(a); return a === e; } // Active WebSocket terminal clients — write output directly (NOT as shell input) const activeWsClients = new Set() // Active PTY shells — used only to write '\r' and trigger PS1 prompt repaint const activeShells = new Set() // Inject text directly into all terminals as output (never touches shell stdin) function injectToTerminal(text) { for (const ws of activeWsClients) { try { if (ws.readyState === WebSocket.OPEN) ws.send(text) } catch (_) {} } } // Write a carriage-return to every active shell so bash repaints its PS1 prompt function refreshPrompt(delayMs = 80) { setTimeout(() => { for (const shell of activeShells) { try { shell.write('\r') } catch (_) {} } }, delayMs) } // ── Routes ──────────────────────────────────────────────────────────────────── // POST /api/progress/reset — reset progress for scenario | category | bundle app.post('/api/progress/reset', (req, res) => { const { scope, scenarioId, category, bundleId } = req.body; const db = getDb(); const resetProgressStmt = db.prepare(` UPDATE progress SET status = 'not_started', attempts = 0, completed_at = NULL, last_validated = NULL WHERE scenario_id = ? `); try { if (scope === 'scenario') { resetProgressStmt.run(scenarioId); } else if (scope === 'category') { const scenarios = loadScenarios(); const ids = scenarios.filter(s => s.category === category).map(s => s.id); const tx = db.transaction(ids => ids.forEach(id => resetProgressStmt.run(id))); tx(ids); } else if (scope === 'bundle') { const bundle = loadBundles().find(b => b.id === bundleId); if (!bundle) return res.status(404).json({ error: 'Bundle not found' }); const tx = db.transaction(ids => ids.forEach(id => resetProgressStmt.run(id))); tx(bundle.scenario_ids); } else { return res.status(400).json({ error: 'Invalid scope' }); } res.json({ ok: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ── Exam sessions ───────────────────────────────────────────────────────────── // Fisher-Yates shuffle (returns a new array) function shuffle(arr) { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } // POST /api/sessions — start a new exam session app.post('/api/sessions', (req, res) => { const { bundleId, examMinutes, scenarioCount } = req.body; const bundle = loadBundles().find(b => b.id === bundleId); if (!bundle) return res.status(404).json({ error: 'Bundle not found' }); const db = getDb(); const mins = Math.max(5, Math.min(300, Number(examMinutes) || bundle.exam_minutes || 120)); // Shuffle and optionally slice scenario IDs const allIds = bundle.scenario_ids || []; const count = Math.max(1, Math.min(allIds.length, Number(scenarioCount) || allIds.length)); const sessionScenarioIds = shuffle(allIds).slice(0, count); // Abandon any existing active session db.prepare(`UPDATE sessions SET status='abandoned', submitted_at=datetime('now') WHERE status='active'`).run(); const id = `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; db.prepare(`INSERT INTO sessions (id, bundle_id, started_at, status, exam_minutes, scenario_ids) VALUES (?, ?, datetime('now'), 'active', ?, ?)`) .run(id, bundleId, mins, JSON.stringify(sessionScenarioIds)); res.json({ id, bundleId, status: 'active', exam_minutes: mins, scenario_ids: sessionScenarioIds }); }); // GET /api/sessions/active — get the current active session app.get('/api/sessions/active', (req, res) => { const db = getDb(); const session = db.prepare(`SELECT * FROM sessions WHERE status='active' ORDER BY started_at DESC LIMIT 1`).get(); if (!session) return res.json(null); // Use session-specific scenario_ids (shuffled/sliced at start time) let scenarioIds = []; try { scenarioIds = session.scenario_ids ? JSON.parse(session.scenario_ids) : []; } catch (_) {} if (!scenarioIds.length) { // Fallback for sessions created before this feature const bundle = loadBundles().find(b => b.id === session.bundle_id); scenarioIds = bundle?.scenario_ids || []; } const completed = db.prepare( `SELECT COUNT(*) as cnt FROM exam_progress WHERE session_id=? AND status='completed'` ).get(session.id)?.cnt || 0; res.json({ ...session, scenario_ids: scenarioIds, scenarioCount: scenarioIds.length, completedCount: completed }); }); // GET /api/sessions/:id/exam-progress — return per-scenario progress for an exam session app.get('/api/sessions/:id/exam-progress', (req, res) => { const db = getDb(); const rows = db.prepare(`SELECT * FROM exam_progress WHERE session_id=?`).all(req.params.id); // Return as a map: scenario_id -> { status, attempts, completed_at } const result = {}; for (const r of rows) { result[r.scenario_id] = { status: r.status, attempts: r.attempts, completed_at: r.completed_at }; } res.json(result); }); // GET /api/sessions/history — list all completed/abandoned exam sessions (newest first) app.get('/api/sessions/history', (req, res) => { const db = getDb(); const sessions = db.prepare( `SELECT * FROM sessions WHERE status != 'active' ORDER BY started_at DESC` ).all(); const bundles = loadBundles(); const result = sessions.map(s => { const bundle = bundles.find(b => b.id === s.bundle_id); let snapshot = []; try { snapshot = s.snapshot ? JSON.parse(s.snapshot) : []; } catch (_) {} const completed = snapshot.filter(x => x.status === 'completed'); const totalWeight = snapshot.reduce((a, x) => a + (x.weight || 0), 0); const earnedWeight = completed.reduce((a, x) => a + (x.weight || 0), 0); const pct = totalWeight > 0 ? Math.round((earnedWeight / totalWeight) * 100) : 0; return { id: s.id, bundle_id: s.bundle_id, bundle_name: bundle?.name || s.bundle_id, bundle_icon: bundle?.icon || '🎓', status: s.status, started_at: s.started_at, submitted_at: s.submitted_at, exam_minutes: s.exam_minutes, duration_secs: s.duration_secs, scenarioCount: snapshot.length, completedCount: completed.length, totalWeight, earnedWeight, pct, passed: pct >= 66, snapshot, }; }); res.json(result); }); // POST /api/sessions/:id/submit — submit the exam session app.post('/api/sessions/:id/submit', (req, res) => { const db = getDb(); const session = db.prepare(`SELECT * FROM sessions WHERE id=?`).get(req.params.id); if (!session) return res.status(404).json({ error: 'Session not found' }); const bundle = loadBundles().find(b => b.id === session.bundle_id); const scenarios = loadScenarios(); // Use session-specific scenario IDs (preserves shuffle order) let sessionIds = []; try { sessionIds = session.scenario_ids ? JSON.parse(session.scenario_ids) : []; } catch (_) {} if (!sessionIds.length) sessionIds = bundle?.scenario_ids || []; const bundleScenarios = sessionIds.map(id => scenarios.find(s => s.id === id)).filter(Boolean); // Build snapshot from exam_progress (exam-specific), falling back to 'not_started' const examProgressRows = db.prepare(`SELECT * FROM exam_progress WHERE session_id=?`).all(req.params.id); const examProgressMap = {}; for (const r of examProgressRows) examProgressMap[r.scenario_id] = r; // Also pull time_spent_seconds from global progress (tracked per scenario during the exam) const globalProgress = loadProgress(); const snapshot = bundleScenarios.map(s => ({ id: s.id, title: s.title, weight: s.weight, category: s.category, type: s.type, difficulty: s.difficulty, status: examProgressMap[s.id]?.status || 'not_started', completed_at: examProgressMap[s.id]?.completed_at || null, attempts: examProgressMap[s.id]?.attempts || 0, time_spent_seconds: globalProgress[s.id]?.time_spent_seconds || 0, })); const startedAt = new Date(session.started_at + 'Z'); const durationSecs = Math.round((Date.now() - startedAt.getTime()) / 1000); db.prepare(`UPDATE sessions SET status='submitted', submitted_at=datetime('now'), duration_secs=?, snapshot=? WHERE id=?`) .run(durationSecs, JSON.stringify(snapshot), req.params.id); // Reset timers of all scenarios in this bundle const clearTimer = db.prepare(`UPDATE progress SET started_at = NULL, time_spent_seconds = 0 WHERE scenario_id = ?`); const tx = db.transaction(ids => ids.forEach(id => clearTimer.run(id))); tx(bundle.scenario_ids); res.json({ ok: true, snapshot, durationSecs }); }); // POST /api/sessions/:id/abandon — forfeit the exam without a score report app.post('/api/sessions/:id/abandon', (req, res) => { const db = getDb(); const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(req.params.id); if (!session) return res.status(404).json({ error: 'Session not found' }); const bundles = loadBundles(); const bundle = bundles.find(b => b.id === session.bundle_id); db.prepare(`UPDATE sessions SET status='abandoned', submitted_at=datetime('now') WHERE id=? AND status='active'`).run(req.params.id); if (bundle) { const clearTimer = db.prepare(`UPDATE progress SET started_at = NULL, time_spent_seconds = 0 WHERE scenario_id = ?`); const tx = db.transaction(ids => ids.forEach(id => clearTimer.run(id))); tx(bundle.scenario_ids); } res.json({ ok: true }); }); // POST /api/scenarios/:id/teardown — run teardown_commands app.post('/api/scenarios/:id/teardown', async (req, res) => { const scenarios = loadScenarios(); const scenario = scenarios.find(s => s.id === req.params.id); if (!scenario) return res.status(404).json({ error: 'Scenario not found' }); const results = []; for (const item of (scenario.teardown_commands || [])) { const cmd = item.command; const result = await runCommand(cmd, 30000); results.push({ command: cmd, ...result }); } res.json({ ok: true, results }); }); // ── Context sync (Feature 3) ────────────────────────────────────────────────── // POST /api/scenarios/:id/context — inject namespace + banner into active terminals app.post('/api/scenarios/:id/context', async (req, res) => { const scenarios = loadScenarios(); const scenario = scenarios.find(s => s.id === req.params.id); if (!scenario) return res.status(404).json({ error: 'Scenario not found' }); const ns = scenario.default_namespace || 'default'; // VT sequences: clear visible screen + scrollback, then move cursor to top-left const clearScreen = '\x1b[2J\x1b[3J\x1b[H'; const line = '\u2500'.repeat(54); const banner = [ `\x1b[2m# ${line}\x1b[0m\r\n`, `\x1b[1m\x1b[36m\u2388 Scenario : \x1b[0m\x1b[1m\x1b[97m${scenario.title}\x1b[0m\r\n`, `\x1b[2m Namespace: \x1b[0m\x1b[33m${ns}\x1b[0m`, ` \x1b[2mDifficulty: \x1b[0m${scenario.difficulty === 'Easy' ? '\x1b[32m' : scenario.difficulty === 'Hard' ? '\x1b[31m' : '\x1b[33m'}${scenario.difficulty}\x1b[0m\r\n`, `\x1b[2m# ${line}\x1b[0m\r\n`, ].join(''); // Set kubectl context namespace silently await runCommand(`kubectl config set-context --current --namespace=${ns}`, 5000); // 1) Clear screen and write banner as terminal output (never touches shell stdin) injectToTerminal(clearScreen + banner); // 2) After banner renders, write \r to each PTY so bash repaints its PS1 prompt refreshPrompt(80); res.json({ ok: true, namespace: ns }); }); // GET /api/bundles — list bundles with per-bundle progress stats app.get('/api/bundles', (req, res) => { const bundles = loadBundles(); const scenarios = loadScenarios(); const progress = loadProgress(); const result = bundles.map(b => { const total = b.scenario_ids.length; const completed = b.scenario_ids.filter(id => progress[id]?.status === 'completed').length; return { ...b, stats: { total, completed } }; }); res.json(result); }); // GET /api/scenarios — list scenarios; optional ?bundle= and ?session= filter app.get('/api/scenarios', (req, res) => { const scenarios = loadScenarios() const progress = loadProgress() const { bundle, session: sessionId } = req.query // Session-scoped: return only session scenario_ids in their shuffled order if (sessionId) { const db = getDb() const session = db.prepare(`SELECT scenario_ids FROM sessions WHERE id=?`).get(sessionId) let sessionIds = [] try { sessionIds = session?.scenario_ids ? JSON.parse(session.scenario_ids) : [] } catch (_) {} const list = sessionIds .map(id => scenarios.find(s => s.id === id)) .filter(Boolean) .map(s => ({ id: s.id, title: s.title, category: s.category, difficulty: s.difficulty, type: s.type, weight: s.weight, progress: progress[s.id] || { status: 'not_started', attempts: 0 } })) return res.json(list) } let filtered = scenarios if (bundle) { const bundles = loadBundles() const b = bundles.find(x => x.id === bundle) if (b) filtered = scenarios.filter(s => b.scenario_ids.includes(s.id)) } const list = filtered.map(s => ({ id: s.id, title: s.title, category: s.category, difficulty: s.difficulty, type: s.type, weight: s.weight, progress: progress[s.id] || { status: 'not_started', attempts: 0 } })) res.json(list) }) // GET /api/scenarios/:id — full scenario detail app.get('/api/scenarios/:id', (req, res) => { const scenarios = loadScenarios(); const scenario = scenarios.find(s => s.id === req.params.id); if (!scenario) return res.status(404).json({ error: 'Scenario not found' }); const progress = loadProgress(); res.json({ ...scenario, progress: progress[scenario.id] || { status: 'not_started', attempts: 0 } }); }); // POST /api/scenarios/:id/setup — run setup_commands for a scenario app.post('/api/scenarios/:id/setup', async (req, res) => { const scenarios = loadScenarios(); const scenario = scenarios.find(s => s.id === req.params.id); if (!scenario) return res.status(404).json({ error: 'Scenario not found' }); const results = []; for (const item of (scenario.setup_commands || [])) { const cmd = item.command; const result = await runCommand(cmd, 30000); results.push({ command: cmd, ...result }); if (!result.success) { // Non-fatal: setup commands like "kubectl create namespace" fail if already exists console.warn(`Setup command warning: ${cmd} -> ${result.error}`); } } // Mark scenario as in-progress const progress = loadProgress(); if (!progress[scenario.id] || progress[scenario.id].status === 'not_started') { progress[scenario.id] = { ...progress[scenario.id], status: 'in_progress', attempts: (progress[scenario.id]?.attempts || 0), started_at: progress[scenario.id]?.started_at || new Date().toISOString() }; saveProgress(progress); } res.json({ setup_results: results }); }); // POST /api/scenarios/:id/validate — validate task-based scenario app.post('/api/scenarios/:id/validate', async (req, res) => { const scenarios = loadScenarios(); const scenario = scenarios.find(s => s.id === req.params.id); if (!scenario) return res.status(404).json({ error: 'Scenario not found' }); if (scenario.type !== 'task') return res.status(400).json({ error: 'Not a task scenario' }); const checks = []; let allPassed = true; for (const check of (scenario.validation?.commands || [])) { const result = await runCommand(check.command, 5000); // Prefer stdout. Only fall back to stderr for non-API errors: // - kubectl auth can-i prints "yes"/"no" to stdout → already in result.output. // - kubectl get prints "Error from server (NotFound):" to stderr // and nothing to stdout → suppress, return '' so the check fails cleanly. const isKubectlApiError = result.error && /^Error from server|^error:|^Error:/i.test(result.error.trim()); const actual = result.output || (isKubectlApiError ? '' : result.error) || ''; const passed = checkMatch(actual, check.expected_output, check.match); checks.push({ description: check.description, command: check.command, expected: check.expected_output, actual, passed }); if (!passed) allPassed = false; } // Update global practice progress const progress = loadProgress(); const prev = progress[scenario.id] || { attempts: 0 }; progress[scenario.id] = { ...prev, status: allPassed ? 'completed' : 'in_progress', attempts: (prev.attempts || 0) + 1, last_validated: new Date().toISOString(), completed_at: allPassed ? new Date().toISOString() : prev.completed_at }; saveProgress(progress); // Also update exam_progress if there's an active session const db = getDb(); const activeSession = db.prepare(`SELECT id FROM sessions WHERE status='active' LIMIT 1`).get(); if (activeSession) { const ep = db.prepare(`SELECT * FROM exam_progress WHERE session_id=? AND scenario_id=?`) .get(activeSession.id, scenario.id); const epAttempts = (ep?.attempts || 0) + 1; db.prepare(` INSERT INTO exam_progress (session_id, scenario_id, status, attempts, completed_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(session_id, scenario_id) DO UPDATE SET status = excluded.status, attempts = excluded.attempts, completed_at = COALESCE(excluded.completed_at, exam_progress.completed_at) `).run( activeSession.id, scenario.id, allPassed ? 'completed' : 'in_progress', epAttempts, allPassed ? new Date().toISOString() : null ); } res.json({ passed: allPassed, checks, attempts: progress[scenario.id].attempts }); }); // POST /api/scenarios/:id/answer — submit MCQ answer app.post('/api/scenarios/:id/answer', (req, res) => { const { selected } = req.body; const scenarios = loadScenarios(); const scenario = scenarios.find(s => s.id === req.params.id); if (!scenario) return res.status(404).json({ error: 'Scenario not found' }); if (scenario.type !== 'mcq') return res.status(400).json({ error: 'Not an MCQ scenario' }); const correct = selected === scenario.correct_option; // Update global practice progress const progress = loadProgress(); const prev = progress[scenario.id] || { attempts: 0 }; progress[scenario.id] = { ...prev, status: correct ? 'completed' : 'in_progress', attempts: (prev.attempts || 0) + 1, last_answer: selected, last_validated: new Date().toISOString(), completed_at: correct ? new Date().toISOString() : prev.completed_at }; saveProgress(progress); // Also update exam_progress if there's an active session const db = getDb(); const activeSession = db.prepare(`SELECT id FROM sessions WHERE status='active' LIMIT 1`).get(); if (activeSession) { const ep = db.prepare(`SELECT * FROM exam_progress WHERE session_id=? AND scenario_id=?`) .get(activeSession.id, scenario.id); const epAttempts = (ep?.attempts || 0) + 1; db.prepare(` INSERT INTO exam_progress (session_id, scenario_id, status, attempts, completed_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(session_id, scenario_id) DO UPDATE SET status = excluded.status, attempts = excluded.attempts, completed_at = COALESCE(excluded.completed_at, exam_progress.completed_at) `).run( activeSession.id, scenario.id, correct ? 'completed' : 'in_progress', epAttempts, correct ? new Date().toISOString() : null ); } res.json({ correct, correct_option: scenario.correct_option, explanation: scenario.explanation, attempts: progress[scenario.id].attempts }); }); // POST /api/scenarios/:id/time — update time spent on a scenario app.post('/api/scenarios/:id/time', (req, res) => { const { time_spent_seconds } = req.body; if (typeof time_spent_seconds !== 'number') { return res.status(400).json({ error: 'Invalid time_spent_seconds' }); } const db = getDb(); const activeSession = db.prepare("SELECT 1 FROM sessions WHERE status='active'").get(); if (!activeSession) { return res.json({ ok: true, message: 'No active session' }); } const progress = loadProgress(); if (!progress[req.params.id]) { progress[req.params.id] = { status: 'in_progress', attempts: 0, started_at: new Date().toISOString(), time_spent_seconds: 0 }; } else if (!progress[req.params.id].started_at) { progress[req.params.id].started_at = new Date().toISOString(); } progress[req.params.id].time_spent_seconds = time_spent_seconds; saveProgress(progress); res.json({ ok: true }); }); // GET /api/progress — full progress summary app.get('/api/progress', (req, res) => { const scenarios = loadScenarios(); const progress = loadProgress(); const total = scenarios.length; const completed = Object.values(progress).filter(p => p.status === 'completed').length; const totalWeight = scenarios.reduce((sum, s) => sum + (s.weight || 0), 0); const earnedWeight = scenarios .filter(s => progress[s.id]?.status === 'completed') .reduce((sum, s) => sum + (s.weight || 0), 0); res.json({ total, completed, score_pct: totalWeight > 0 ? Math.round((earnedWeight / totalWeight) * 100) : 0, details: progress }); }); // POST /api/progress/reset/:id — reset a scenario app.post('/api/progress/reset/:id', (req, res) => { try { const db = getDb(); db.prepare(` UPDATE progress SET status = 'not_started', attempts = 0, completed_at = NULL, last_validated = NULL WHERE scenario_id = ? `).run(req.params.id); res.json({ ok: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // POST /api/cache/reload — reload scenarios and bundles cache app.post('/api/cache/reload', (req, res) => { reloadCache(); res.json({ ok: true, message: 'Cache reloaded successfully', scenarios_count: loadScenarios().length, bundles_count: loadBundles().length }); }); // GET /api/health app.get('/api/health', async (req, res) => { const kube = await runCommand('kubectl cluster-info --request-timeout=3s 2>&1 | head -1'); res.json({ api: 'ok', cluster: kube.success ? 'ready' : 'not_ready', cluster_info: kube.output }); }); // Fallback to frontend SPA app.get('*', (req, res) => { res.sendFile(path.join(__dirname, '../frontend/dist/index.html')); }); // ── WebSocket PTY terminal ──────────────────────────────────────────────────── const http = require('http'); const WebSocket = require('ws'); const pty = require('node-pty'); const server = http.createServer(app); const wss = new WebSocket.Server({ noServer: true }); wss.on('connection', (ws) => { activeWsClients.add(ws); const shell = pty.spawn('/bin/bash', [], { name: 'xterm-256color', cols: 80, rows: 24, cwd: '/root', env: { ...process.env, KUBECONFIG: '/root/.kube/config', HOME: '/root', TERM: 'xterm-256color', }, }); activeShells.add(shell); // Forward PTY output → browser shell.onData((data) => { if (ws.readyState === WebSocket.OPEN) ws.send(data); }); shell.onExit(() => { activeWsClients.delete(ws); activeShells.delete(shell); if (ws.readyState === WebSocket.OPEN) ws.close(); }); // Forward browser input → PTY ws.on('message', (msg) => { try { const parsed = JSON.parse(msg); if (parsed.type === 'resize') { shell.resize(Number(parsed.cols) || 80, Number(parsed.rows) || 24); return; } } catch (_) { /* not JSON → raw input */ } shell.write(typeof msg === 'string' ? msg : msg.toString()); }); ws.on('close', () => { activeWsClients.delete(ws); activeShells.delete(shell); try { shell.kill(); } catch (_) {} }); ws.on('error', () => { activeWsClients.delete(ws); activeShells.delete(shell); try { shell.kill(); } catch (_) {} }); }); // Handle WebSocket upgrades only for /shell-ws server.on('upgrade', (req, socket, head) => { if (req.url === '/shell-ws') { wss.handleUpgrade(req, socket, head, (ws) => { wss.emit('connection', ws, req); }); } else { socket.destroy(); } }); server.listen(PORT, () => console.log(`API server running on :${PORT}`));