cka-sim/web/index.html

494 lines
34 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Exam simulator</title>
<style>
:root {
--bg: #0a0c10; --bg-alt: #10131a; --pane: #14171f; --pane-2: #1a1e28;
--ink: #e8ebf1; --muted: #8891a3; --line: #232833; --line-soft: #1c212b;
--accent: #5b8cff; --accent-hover: #7aa2ff; --accent-ink: #ffffff;
--warn: #eab34a; --danger: #f2555f; --ok: #35d48f; --flag: #eab34a;
--warn-soft: rgba(234,179,74,.14); --danger-soft: rgba(242,85,95,.14);
--ok-soft: rgba(53,212,143,.14); --accent-soft: rgba(91,140,255,.14); --flag-soft: rgba(234,179,74,.16);
--term: #0a0c10; --shadow: 0 12px 32px rgba(0,0,0,.45);
--sans: "IBM Plex Sans", "Segoe UI", system-ui, -apple-system, sans-serif;
--mono: "IBM Plex Mono", "JetBrains Mono", ui-monospace, Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body { font: 14px/1.55 var(--sans); color: var(--ink); background: var(--bg); }
button { font: inherit; cursor: pointer; }
a { color: var(--accent); }
code { font-family: var(--mono); font-size: .92em; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
::selection { background: var(--accent-soft); color: var(--ink); }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--line); border-radius: 6px; border: 2px solid transparent; background-clip: padding-box; }
::-webkit-scrollbar-thumb:hover { background: #2c3341; background-clip: padding-box; }
* { scrollbar-color: var(--line) transparent; scrollbar-width: thin; }
/* ---- top bar: exam name left, clock right, controls far right ---- */
header { height: 52px; display: flex; align-items: center; gap: 16px; padding: 0 16px;
background: var(--bg-alt); color: var(--ink); border-bottom: 1px solid var(--line); }
header .name { font-weight: 600; letter-spacing: .01em; }
header .mode { color: var(--muted); font-size: 13px; }
header .spacer { flex: 1; }
#clock { font-family: var(--mono); font-size: 20px; letter-spacing: .04em; font-variant-numeric: tabular-nums; color: var(--ink); }
#clock.warn { color: var(--warn); } #clock.danger { color: var(--danger); animation: pulse 1.4s ease-in-out infinite; } #clock.paused { color: var(--muted); }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .55; } }
.btn { border: 1px solid var(--line); background: var(--pane-2); color: var(--ink); border-radius: 6px; padding: 7px 14px;
transition: border-color .15s, background .15s, transform .1s; }
.btn:hover:not(:disabled) { border-color: #333a48; background: #1f2430; }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); font-weight: 600; }
.btn.primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn.danger { background: var(--danger); border-color: var(--danger); color: #fff; }
.btn.danger:hover:not(:disabled) { filter: brightness(1.1); }
.btn.ghost { background: transparent; color: var(--ink); border-color: var(--line); }
.btn.ghost:hover:not(:disabled) { background: var(--pane-2); }
.btn:disabled { opacity: .4; cursor: default; }
/* ---- main split ---- */
main { height: calc(100% - 52px); display: grid; grid-template-columns: 56px 440px 6px 1fr; }
#qnav { background: var(--bg-alt); border-right: 1px solid var(--line); overflow-y: auto; padding: 10px 0; }
#qnav button { display: block; width: 38px; height: 32px; margin: 4px auto; border: 1px solid var(--line); border-radius: 6px;
background: var(--pane); color: var(--ink); position: relative; font-variant-numeric: tabular-nums;
transition: border-color .12s, transform .08s, background .12s; }
#qnav button:hover { border-color: var(--accent); transform: translateY(-1px); }
#qnav button.current { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), 0 0 12px var(--accent-soft); font-weight: 600; color: var(--accent-hover); }
#qnav button.flagged::after { content: ""; position: absolute; top: -4px; right: -4px; width: 10px; height: 10px;
background: var(--flag); border-radius: 50%; border: 2px solid var(--bg-alt); }
#qnav button.pass { background: var(--ok-soft); border-color: var(--ok); color: var(--ok); }
#qnav button.partial { background: var(--warn-soft); border-color: var(--warn); color: var(--warn); }
#qnav button.fail { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); }
#task { background: var(--pane); display: flex; flex-direction: column; min-height: 0; }
#task .head { padding: 16px 20px 12px; border-bottom: 1px solid var(--line); }
#task .head .sub { color: var(--muted); font-size: 12.5px; text-transform: uppercase; letter-spacing: .05em; }
#task h2 { margin: 4px 0 0; font-size: 18px; font-weight: 600; letter-spacing: -.01em; }
#task .body { flex: 1; overflow-y: auto; padding: 16px 20px; }
.prose p { margin: 0 0 12px; white-space: pre-wrap; }
.prose code { background: var(--pane-2); padding: 1px 6px; border-radius: 4px; cursor: copy; border: 1px solid var(--line); color: #c9d4f2; }
.prose code:hover { background: var(--accent-soft); border-color: var(--accent); }
.prose code.copied { background: var(--ok-soft); border-color: var(--ok); color: var(--ok); }
#task .foot { border-top: 1px solid var(--line); padding: 12px 20px; display: flex; gap: 8px; align-items: center; }
#task .foot .spacer { flex: 1; }
.btn.flag.on { background: var(--flag-soft); border-color: var(--flag); color: var(--flag); }
#checks { margin-top: 14px; background: var(--pane-2); border: 1px solid var(--line); border-radius: 6px; padding: 12px 14px; font-size: 13px; }
#checks div { padding: 3px 0; }
#solution { margin-top: 14px; border: 1px solid var(--line); border-radius: 6px; padding: 10px 14px; font-size: 13px; background: var(--pane-2); }
#solution summary { cursor: pointer; font-weight: 600; color: var(--ink); }
#solution pre { margin: 8px 0 0; white-space: pre-wrap; font: 12.5px/1.55 var(--mono); background: var(--bg-alt); border: 1px solid var(--line); padding: 12px; border-radius: 6px; color: #c9d4f2; }
#checks .ok::before { content: "✓ "; color: var(--ok); } #checks .no::before { content: "✗ "; color: var(--danger); }
#notes { width: 100%; height: 120px; border: 0; border-top: 1px solid var(--line); resize: none; padding: 12px 20px;
font: 13px/1.5 var(--mono); background: var(--bg-alt); color: var(--ink); }
#notes::placeholder { color: var(--muted); }
#handle { background: var(--line); cursor: col-resize; transition: background .15s; }
#handle:hover { background: var(--accent); }
#termwrap { background: var(--term); position: relative; min-width: 0; }
#term { width: 100%; height: 100%; border: 0; display: block; }
#noterm { color: var(--muted); padding: 32px; font-family: var(--mono); font-size: 13px; line-height: 1.7; max-width: 70ch; }
#noterm code { color: var(--warn); }
/* ---- overlays: start, confirm, results ---- */
.overlay { position: fixed; inset: 0; background: rgba(4,5,8,.7); backdrop-filter: blur(2px); display: flex; align-items: center; justify-content: center; }
.card { position: relative; background: var(--pane); border: 1px solid var(--line); box-shadow: var(--shadow); border-radius: 10px;
padding: 28px 30px; width: min(640px, 92vw); max-height: 88vh; overflow-y: auto; }
.card h1 { margin: 0 0 4px; font-size: 20px; letter-spacing: -.01em; }
.card .sub { color: var(--muted); margin-bottom: 18px; }
.card .row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin-top: 16px; }
.opts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.opt { border: 1px solid var(--line); border-radius: 8px; padding: 15px; text-align: left; background: var(--pane-2);
transition: border-color .15s, transform .12s, box-shadow .15s; }
.opt:hover { border-color: var(--accent); transform: translateY(-1px); box-shadow: 0 6px 18px rgba(0,0,0,.35); }
.opt b { display: block; margin-bottom: 4px; color: var(--ink); }
.opt span { color: var(--muted); font-size: 13px; }
.picker { columns: 2; margin: 10px 0 0; font-size: 13px; }
.picker label { display: block; padding: 3px 0; }
pre.log { background: var(--bg-alt); border: 1px solid var(--line); padding: 12px; font: 12px/1.5 var(--mono); max-height: 240px; overflow: auto; border-radius: 6px; color: var(--muted); }
.score { font-size: 36px; font-family: var(--mono); font-weight: 600; }
.score small { font-size: 14px; color: var(--muted); font-family: var(--sans); font-weight: 400; }
.verdict { display: inline-block; padding: 3px 12px; border-radius: 100px; color: #fff; margin-left: 10px; font-size: 13px; font-weight: 600; vertical-align: middle; }
.verdict.pass { background: var(--ok); } .verdict.fail { background: var(--danger); }
.bar { display: grid; grid-template-columns: 70px 1fr 60px; gap: 10px; align-items: center; margin: 8px 0; font-size: 13px; }
.bar .track { height: 8px; background: var(--pane-2); border: 1px solid var(--line); border-radius: 5px; overflow: hidden; }
.bar .fill { height: 100%; background: var(--accent); border-radius: 5px; }
.qres { margin-top: 16px; }
.qres details { border-top: 1px solid var(--line); padding: 8px 0; }
.qres summary { cursor: pointer; }
.qres summary .pts { float: right; font-family: var(--mono); }
.qres .t-pass summary .pts { color: var(--ok); } .qres .t-fail summary .pts { color: var(--danger); } .qres .t-partial summary .pts { color: var(--warn); }
.hidden { display: none !important; }
.pill { font-size:12px; padding:3px 10px; border-radius:100px; border:1px solid var(--line); color:var(--muted); background: var(--pane-2); transition: border-color .12s, color .12s; }
.pill.sel { background:var(--accent); border-color:var(--accent); color:#fff; }
.pill.pick { cursor:pointer; }
.pill.pick:hover { border-color:var(--accent); color:var(--accent-hover); }
.pill.done.pick:hover { color:var(--ok); }
.pill.done { background:var(--ok-soft); border-color:var(--ok); color:var(--ok); }
.hardtag { font-size:11px; background:var(--danger); color:#fff; border-radius:3px; padding:1px 5px; margin-left:6px; vertical-align:middle; }
@media (prefers-reduced-motion: no-preference) { .prose code { transition: background .15s, border-color .15s; } }
@media (max-width: 900px) { main { grid-template-columns: 56px 1fr; } #handle, #termwrap { display: none; } }
</style>
</head>
<body>
<header>
<span class="name" id="trackName">Exam simulator</span>
<span class="mode" id="modeLabel"></span>
<span class="spacer"></span>
<span id="clock">--:--:--</span>
<button class="btn ghost" id="btnPause">Pause</button>
<button class="btn ghost" id="btnHistory">History</button>
<button class="btn danger" id="btnEnd">Submit exam</button>
</header>
<main>
<nav id="qnav" aria-label="Questions"></nav>
<section id="task">
<div class="head">
<div class="sub" id="qMeta"></div>
<h2 id="qTitle"></h2>
</div>
<div class="body">
<div class="prose" id="qBody"></div>
<div id="checks" class="hidden"></div>
<details id="solution" class="hidden"><summary>Reference solution</summary><pre id="solutionBody"></pre></details>
</div>
<div class="foot">
<button class="btn" id="btnPrev">Previous</button>
<button class="btn" id="btnNext">Next</button>
<span class="spacer"></span>
<button class="btn" id="btnCheck">Check this task</button>
<button class="btn flag" id="btnFlag">Flag for review</button>
</div>
<textarea id="notes" placeholder="Notepad — kept with the attempt, not graded."></textarea>
</section>
<div id="handle" role="separator" aria-orientation="vertical"></div>
<section id="termwrap"></section>
</main>
<!-- start / lobby -->
<div class="overlay" id="lobby">
<div class="card">
<h1 id="lobbyTitle">Exam simulator</h1>
<div class="sub" id="lobbySub"></div>
<div id="ver" style="position:absolute;right:28px;top:30px;color:var(--muted);font-size:12px"></div>
<div id="trackPick" style="display:flex;gap:6px;margin:0 0 14px"></div>
<div style="color:var(--muted);font-size:12px;margin:-8px 0 4px">Drill a domain (✓ = drilled at least once):</div>
<div id="coverage" style="display:flex;gap:6px;margin:0 0 14px;flex-wrap:wrap"></div>
<label style="display:block;margin-bottom:14px"><input type="checkbox" id="hardMode"> <b>Hard mode</b>
<span id="hardDesc" style="color:var(--muted)"> — killer.sh-style: more tasks in the same time, no hints, traps, exact-output tasks, compound failures, collateral checks.</span></label>
<div class="opts" id="lobbyOpts">
<button class="opt" id="optStudy"><b>Study mode</b><span>5 tasks, no clock, reference solutions visible from the start. Nothing scored or recorded.</span></button>
<button class="opt" id="optNext"><b>Drill next domain</b><span id="nextDesc"></span></button>
<button class="opt" id="optAll"><b>Drill the whole bank</b><span id="allDesc"></span></button>
<button class="opt" id="optDrill"><b>Drill: weak / pick</b><span>Tasks you're weak on, or choose your own. Feedback per task while you work.</span></button>
<span id="simOpts" style="display:contents"></span>
<button class="opt" id="optExam" style="grid-column:1/3"><b>Full exam</b><span id="examDesc"></span></button>
</div>
<div id="gate" class="hidden" style="color:var(--warn);margin-top:10px;font-size:13px"></div>
<div id="drillPick" class="hidden">
<div class="sub" style="margin-top:14px">Pick tasks to drill. Pre-selected: weak or never attempted.</div>
<div class="picker" id="picker"></div>
<div class="row"><button class="btn primary" id="btnStartDrill">Start drill</button><button class="btn" id="btnBackDrill">Back</button></div>
</div>
<div id="seeding" class="hidden">
<div class="sub">Seeding scenarios on the cluster…</div>
<pre class="log" id="seedLog"></pre>
</div>
<div id="lobbyErr" class="hidden" style="color:var(--danger);margin-top:12px;white-space:pre-wrap"></div>
</div>
</div>
<!-- confirm submit -->
<div class="overlay hidden" id="confirm">
<div class="card">
<h1>Submit and end the session?</h1>
<div class="sub">Every task is graded against the cluster as it is right now. You can't return after submitting.</div>
<div id="confirmFlags"></div>
<div class="row"><button class="btn danger" id="btnConfirmEnd">Submit</button><button class="btn" id="btnCancelEnd">Keep working</button></div>
</div>
</div>
<!-- results -->
<div class="overlay hidden" id="results">
<div class="card">
<h1>Results</h1>
<div class="sub" id="resSub"></div>
<div><span class="score" id="resScore"></span><span class="verdict" id="resVerdict"></span></div>
<div id="resDomains" style="margin-top:14px"></div>
<div class="qres" id="resQuestions"></div>
<div class="row">
<button class="btn primary" id="btnDrillMissed">Drill what I missed</button>
<button class="btn" id="btnNewExam">New full exam</button>
<button class="btn" id="btnCloseRes">Review tasks</button>
</div>
</div>
</div>
<!-- history -->
<div class="overlay hidden" id="histOv">
<div class="card">
<h1>Attempt history</h1>
<div class="sub" id="histSub"></div>
<div id="histBody"></div>
<div class="row"><button class="btn" id="btnCloseHist">Close</button></div>
</div>
</div>
<script>
(() => {
const $ = s => document.querySelector(s);
let S = null, cur = null, skew = 0, clockT = null, autoSubmitted = false;
const api = (p, body) => fetch(p, body ? {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)} : {})
.then(async r => { const j = await r.json(); if (!r.ok) throw new Error(j.error || r.statusText); return j; });
const fmt = s => { s = Math.max(0, Math.floor(s)); return [s/3600, s%3600/60, s%60].map(n => String(Math.floor(n)).padStart(2,'0')).join(':'); };
const esc = s => s.replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
// minimal markdown: paragraphs, **bold**, `code` (click to copy). Lists stay as literal lines.
function render(md) {
return md.split(/\n\s*\n/).map(p => '<p>' + esc(p).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
.replace(/`([^`]+)`/g, (m, c) => `<code title="click to copy">${c}</code>`) + '</p>').join('');
}
function qResultClass(q) {
const r = S.session && S.session.results[q.id];
if (!r) return '';
return r.pass ? 'pass' : (r.earned ? 'partial' : 'fail');
}
function paint() {
const sess = S.session, live = sess && !sess.ended_at;
$('#trackName').textContent = `${S.track.name} · Kubernetes ${S.track.k8s}`;
$('#modeLabel').textContent = sess ? (sess.untimed ? 'Study' : (sess.mode === 'drill' ? 'Drill' : 'Exam')) + (sess.hard ? ' · HARD' : '') : '';
$('#btnEnd').classList.toggle('hidden', !live);
if (live && sess.untimed) $('#btnEnd').textContent = 'End study session';
$('#btnPause').classList.toggle('hidden', !live || sess.untimed);
if (live) $('#btnPause').textContent = sess.paused ? 'Resume' : 'Pause';
$('#btnCheck').classList.toggle('hidden', !(live && sess.mode === 'drill'));
$('#lobby').classList.toggle('hidden', !!live || (sess && sess.ended_at && !$('#results').classList.contains('hidden')));
// question nav
const nav = $('#qnav'); nav.innerHTML = '';
S.questions.forEach(q => {
const b = document.createElement('button'); b.textContent = q.id;
b.title = `${S.questions.indexOf(q) + 1} of ${S.questions.length} — Q${q.id}: ${q.title}`;
b.className = [q.id === cur ? 'current' : '', sess && sess.flags.includes(q.id) ? 'flagged' : '',
(sess && (sess.mode === 'drill' || sess.ended_at)) ? qResultClass(q) : ''].join(' ');
b.onclick = () => { cur = q.id; paint(); };
nav.appendChild(b);
});
if (!S.questions.find(q => q.id === cur)) cur = S.questions[0] && S.questions[0].id;
const q = S.questions.find(q => q.id === cur);
if (q) {
const pos = S.questions.indexOf(q) + 1;
$('#qMeta').textContent = `Question ${pos} of ${S.questions.length} · Q${q.id} · ${S.track.domains[q.domain]} · ${q.weight} points`;
$('#qTitle').innerHTML = esc(q.title) + (q.difficulty === 'hard' ? '<span class="hardtag">hard</span>' : '');
$('#qBody').innerHTML = render(q.task);
$('#qBody').querySelectorAll('code').forEach(c => c.onclick = () => {
navigator.clipboard.writeText(c.textContent).then(() => { c.classList.add('copied'); setTimeout(() => c.classList.remove('copied'), 700); });
});
$('#btnFlag').classList.toggle('on', !!(sess && sess.flags.includes(q.id)));
$('#btnFlag').textContent = sess && sess.flags.includes(q.id) ? 'Flagged' : 'Flag for review';
const r = sess && sess.results[q.id], show = r && (sess.mode === 'drill' || sess.ended_at);
$('#checks').classList.toggle('hidden', !show);
$('#solution').classList.toggle('hidden', !q.solution);
if (q.solution) $('#solutionBody').textContent = q.solution;
if (show) $('#checks').innerHTML = `<b>${r.earned}/${r.total} points</b>` +
r.checks.map(c => `<div class="${c.pass ? 'ok' : 'no'}">${esc(c.name)} <span style="color:var(--muted)">(${c.pts})</span></div>`).join('');
const i = S.questions.indexOf(q);
$('#btnPrev').disabled = i === 0; $('#btnNext').disabled = i === S.questions.length - 1;
}
if (sess && document.activeElement !== $('#notes')) $('#notes').value = sess.notes || '';
// terminal
const tw = $('#termwrap');
if (!tw.dataset.ready) {
tw.dataset.ready = 1;
tw.innerHTML = S.ttyd ? `<iframe id="term" src="http://127.0.0.1:${S.ttyd_port}/" title="terminal"></iframe>` :
`<div id="noterm">The terminal pane needs <code>ttyd</code>, which isn't installed.<br><br>
Install it (<code>dnf install ttyd</code>, <code>pacman -S ttyd</code>, <code>brew install ttyd</code>, or a release from github.com/tsl0922/ttyd)
and restart <code>./exam web</code>.<br><br>Until then, work in any terminal with kubectl pointed at <code>${S.cluster}</code>.
Task files go under <code>${S.workspace}/</code>, relative to the cka-sim directory.</div>`;
}
tickClock();
}
function tickClock() {
clearInterval(clockT);
const sess = S.session, el = $('#clock');
if (!sess || sess.ended_at) { el.textContent = sess ? 'ended' : '--:--:--'; el.className = ''; return; }
if (sess.untimed) { el.textContent = 'study'; el.className = 'paused'; return; }
const step = () => {
const now = Date.now()/1000 - skew;
const pausedTotal = sess.paused_total + (sess.paused ? (now - sess.server_time) : 0);
const left = sess.ends_at + pausedTotal - now;
el.textContent = (sess.paused ? '⏸ ' : '') + fmt(left);
el.className = sess.paused ? 'paused' : (left < 5*60 ? 'danger' : (left < 15*60 ? 'warn' : ''));
if (sess.paused) return;
if (left <= 0 && !autoSubmitted) { autoSubmitted = true; clearInterval(clockT); submit(); }
};
step(); clockT = setInterval(step, 1000);
}
async function load() {
S = await api('/api/session');
if (S.session) skew = Date.now()/1000 - S.session.server_time;
const sess = S.session;
const hard = $('#hardMode').checked, qn = hard ? (S.track.quota_hard || {}) : (S.track.quota || {});
const total = Object.values(qn).reduce((a, b) => a + b, 0) || S.bank.length;
$('#examDesc').textContent = `${total} tasks sampled across the ${Object.keys(S.track.domains).length} domains, ${S.track.minutes} minutes, pass mark ${S.track.pass_pct}%. No feedback until you submit.`;
const drilled = S.drilled || {}, undone = Object.keys(S.track.domains).filter(d => !drilled[d]);
$('#coverage').innerHTML = Object.keys(S.track.domains).map(d =>
`<span class="pill pick ${drilled[d] ? 'done' : ''}" data-domain="${d}" title="Drill ${S.track.domains[d]} — 5 tasks">${d} ${drilled[d] ? '✓' : '·'}</span>`).join('');
$('#coverage').querySelectorAll('[data-domain]').forEach(el => el.onclick = () => start('drill', null, false, false, el.dataset.domain));
const nxt = Object.keys(S.track.domains).sort((a, b) => (drilled[a] || 0) - (drilled[b] || 0))[0];
const eligible = S.bank.filter(q => hard || q.difficulty !== 'hard'), seenN = eligible.filter(q => (S.seen || []).includes(q.id)).length;
$('#allDesc').textContent = `${seenN}/${eligible.length} tasks attempted. 5 unseen tasks per session, round-robin across domains${hard ? ', hard first' : ''}; then whatever you failed.`;
$('#nextDesc').textContent = `${S.track.domains[nxt]} — 5 tasks, ${hard ? 5 : 6} min each. Drills rotate through all domains before the exam.`;
// one button per fixed set the track defines - never hard-code the letters: newer sets used to
// be unreachable from here
$('#simOpts').innerHTML = Object.entries(S.sets || {}).map(([k, info]) =>
`<button class="opt" data-sim="${k}"><b>${esc(info.name)}</b><span>${info.count} fixed tasks, ${S.track.minutes} min, exam rules, no feedback until you submit.</span></button>`).join('');
$('#simOpts').querySelectorAll('[data-sim]').forEach(el => el.onclick = () => startSim(el.dataset.sim));
$('#gate').classList.toggle('hidden', !undone.length);
$('#gate').innerHTML = undone.length ? `Drill every domain once before the full exam. Not yet drilled: ${undone.join(', ')}. <a href="#" id="forceExam">Start the exam anyway</a>` : '';
$('#optExam').disabled = !!undone.length; $('#optExam').style.opacity = undone.length ? .5 : 1;
const fe = $('#forceExam'); if (fe) fe.onclick = e => { e.preventDefault(); start('exam'); };
$('#lobbyTitle').textContent = S.track.name;
// only the track that owns the k8s-c* contexts (CKA) switches between them
$('#hardDesc').textContent = ' — killer.sh-style: more tasks in the same time, no hints, traps, exact-output tasks, compound failures, collateral checks'
+ (S.track.exam_contexts ? ', exam contexts.' : '.');
// each track runs on its own cluster at its exam's Kubernetes version
$('#trackPick').innerHTML = (S.tracks || []).map(t =>
`<span class="pill pick ${t === S.track_id ? 'sel' : ''}" data-track="${t}" title="Kubernetes ${(S.track_k8s || {})[t] || '?'}">${t.toUpperCase()} <span style="opacity:.6">${(S.track_k8s || {})[t] || ''}</span></span>`).join('');
$('#trackPick').querySelectorAll('[data-track]').forEach(el => el.onclick = async () => {
if (el.dataset.track === S.track_id) return;
S = await api('/api/track', {track: el.dataset.track}); cur = null; load();
}); $('#ver').textContent = 'exam ' + (S.version || '?');
$('#lobbySub').textContent = sess && sess.ended_at ? `Last session ended with ${sess.score.pct}%. Start another, or review it under History.` : 'Choose a session. Seeding the cluster takes about a minute.';
if (sess && sess.ended_at && !$('#results').dataset.shown) showResults();
paint();
}
async function startSim(name) {
$('#lobbyOpts').classList.add('hidden'); $('#lobbyErr').classList.add('hidden');
$('#seeding').classList.remove('hidden'); $('#seedLog').textContent = 'starting…';
try {
await api('/api/start', {mode: 'exam', sim: name, hard: true});
for (;;) {
await new Promise(r => setTimeout(r, 1200));
const s = await api('/api/session');
$('#seedLog').textContent = ((s.setup_log || []).join('\n') || 'starting…').replace(/\x1b\[[0-9;]*m/g, '');
$('#seedLog').scrollTop = $('#seedLog').scrollHeight;
if (!s.seeding) { if (s.seed_error) throw new Error(s.seed_error); S = s; break; }
}
skew = Date.now()/1000 - S.session.server_time; autoSubmitted = false;
$('#results').dataset.shown = ''; $('#results').classList.add('hidden'); cur = S.questions[0].id; paint();
} catch (e) {
$('#lobbyErr').textContent = e.message; $('#lobbyErr').classList.remove('hidden'); $('#lobbyOpts').classList.remove('hidden');
} finally { $('#seeding').classList.add('hidden'); }
}
async function start(mode, ids, next, campaign, domain, study) {
$('#lobbyOpts').classList.add('hidden'); $('#drillPick').classList.add('hidden'); $('#lobbyErr').classList.add('hidden');
$('#seeding').classList.remove('hidden'); $('#seedLog').textContent = 'starting…';
try {
await api('/api/start', {mode, ids, next: !!next, campaign: !!campaign, domain: domain || null, study: !!study, hard: $('#hardMode').checked});
// seeding runs in the background; stream its log until it finishes
for (;;) {
await new Promise(r => setTimeout(r, 1200));
const s = await api('/api/session');
$('#seedLog').textContent = ((s.setup_log || []).join('\n') || 'starting…').replace(/\x1b\[[0-9;]*m/g, '');
$('#seedLog').scrollTop = $('#seedLog').scrollHeight;
if (!s.seeding) {
if (s.seed_error) throw new Error(s.seed_error);
S = s; break;
}
}
skew = Date.now()/1000 - S.session.server_time; autoSubmitted = false;
$('#results').dataset.shown = ''; $('#results').classList.add('hidden'); cur = S.questions[0].id; paint();
} catch (e) {
$('#lobbyErr').textContent = e.message === 'Failed to fetch' ? 'Lost contact with ./exam web — is it still running? Restart it and reload this page.' : e.message;
$('#lobbyErr').classList.remove('hidden'); $('#lobbyOpts').classList.remove('hidden');
} finally { $('#seeding').classList.add('hidden'); }
}
async function submit() {
$('#confirm').classList.add('hidden');
$('#btnEnd').disabled = true; $('#btnEnd').textContent = 'Grading…';
try { S = await api('/api/end', {}); }
catch (e) { alert(e.message); return; } // e.g. the cluster is down - nothing was graded or recorded
finally { $('#btnEnd').disabled = false; $('#btnEnd').textContent = 'Submit exam'; }
showResults(); paint();
}
function showResults() {
const s = S.session, sc = s.score;
$('#results').dataset.shown = 1;
$('#resSub').textContent = `${s.mode === 'drill' ? 'Drill' : 'Full exam'} · ${fmt(s.ended_at - s.started_at - s.paused_total)} used${s.over_time ? ' (over time)' : ''}`;
$('#resScore').innerHTML = `${sc.pct}% <small>${sc.earned} / ${sc.total} points</small>`;
const v = $('#resVerdict'); v.textContent = sc.pct >= sc.pass_pct ? 'Pass' : 'Fail'; v.className = 'verdict ' + (sc.pct >= sc.pass_pct ? 'pass' : 'fail');
$('#resDomains').innerHTML = Object.entries(s.domains).map(([d, x]) =>
`<div class="bar"><span>${d}</span><div class="track"><div class="fill" style="width:${100*x.earned/x.total}%"></div></div><span>${x.earned}/${x.total}</span></div>`).join('');
$('#resQuestions').innerHTML = S.questions.map(q => { const r = s.results[q.id]; if (!r) return '';
const cls = r.pass ? 't-pass' : (r.earned ? 't-partial' : 't-fail');
return `<details class="${cls}"><summary>Q${q.id} ${esc(q.title)}<span class="pts">${r.earned}/${r.total}</span></summary>` +
r.checks.map(c => `<div class="${c.pass ? 'ok' : 'no'}" style="padding-left:14px">${c.pass ? '✓' : '✗'} ${esc(c.name)} (${c.pts})</div>`).join('') +
(q.solution ? `<details style="margin:6px 0 4px 14px"><summary style="font-size:13px">Reference solution</summary><pre style="white-space:pre-wrap;font:12px/1.5 var(--mono);background:var(--bg-alt);border:1px solid var(--line);color:#c9d4f2;padding:8px;border-radius:6px">${esc(q.solution)}</pre></details>` : '') + '</details>'; }).join('');
const missed = S.questions.filter(q => s.results[q.id] && !s.results[q.id].pass).map(q => q.id);
$('#btnDrillMissed').disabled = !missed.length; $('#btnDrillMissed').onclick = () => { $('#results').classList.add('hidden'); start('drill', missed); };
$('#results').classList.remove('hidden'); $('#lobby').classList.add('hidden');
}
async function showHistory() {
const h = await api('/api/history');
$('#histSub').textContent = h.history.length ? `${h.history.length} attempt(s) on this track` : 'No attempts yet.';
$('#histBody').innerHTML = h.history.slice().reverse().map(e =>
`<div class="bar"><span>${e.mode}${e.hard ? '*' : ''}</span><div><b>${e.score_pct}%</b> · ${e.at.replace('T',' ')} · ${fmt(e.time_used)} · ${e.ids.length === S.questions.length ? 'all tasks' : 'Q' + e.ids.join(', Q')}</div><span></span></div>`).join('') +
(h.weak.length ? `<div style="margin-top:12px;color:var(--muted)">Weak or unattempted: Q${h.weak.join(', Q')}</div>` : '');
$('#histOv').classList.remove('hidden');
}
// ---- wiring ----
$('#optExam').onclick = () => start('exam');
$('#optNext').onclick = () => start('drill', null, true);
$('#optStudy').onclick = () => start('drill', null, true, false, null, true);
$('#optAll').onclick = () => start('drill', null, false, true);
$('#hardMode').onchange = () => load();
$('#optDrill').onclick = async () => {
const h = await api('/api/history');
const hard = $('#hardMode').checked;
$('#picker').innerHTML = S.bank.filter(q => hard || q.difficulty !== 'hard').map(q => `<label><input type="checkbox" value="${q.id}" ${h.weak.includes(q.id) ? 'checked' : ''}> Q${q.id} ${q.domain} ${esc(q.title)}${q.difficulty === 'hard' ? '<span class="hardtag">hard</span>' : ''}</label>`).join('');
$('#lobbyOpts').classList.add('hidden'); $('#drillPick').classList.remove('hidden');
};
$('#btnBackDrill').onclick = () => { $('#drillPick').classList.add('hidden'); $('#lobbyOpts').classList.remove('hidden'); };
$('#btnStartDrill').onclick = () => { const ids = [...$('#picker').querySelectorAll('input:checked')].map(i => +i.value); if (ids.length) start('drill', ids); };
$('#btnPrev').onclick = () => { const i = S.questions.findIndex(q => q.id === cur); cur = S.questions[i-1].id; paint(); };
$('#btnNext').onclick = () => { const i = S.questions.findIndex(q => q.id === cur); cur = S.questions[i+1].id; paint(); };
$('#btnFlag').onclick = async () => { S = await api('/api/flag', {id: cur}); paint(); };
$('#btnCheck').onclick = async () => { $('#btnCheck').disabled = true; try { S = await api('/api/grade', {id: cur}); paint(); } catch (e) { alert(e.message); } finally { $('#btnCheck').disabled = false; } };
let nt; $('#notes').oninput = () => { clearTimeout(nt); nt = setTimeout(() => api('/api/notes', {notes: $('#notes').value}), 600); };
$('#btnEnd').onclick = () => {
const f = S.session.flags, un = S.questions.filter(q => !S.session.results[q.id]).length;
$('#confirmFlags').innerHTML = f.length ? `<div>Flagged for review: Q${f.join(', Q')}</div>` : '';
$('#confirm').classList.remove('hidden');
};
$('#btnCancelEnd').onclick = () => $('#confirm').classList.add('hidden');
$('#btnConfirmEnd').onclick = submit;
$('#btnCloseRes').onclick = () => { $('#results').classList.add('hidden'); $('#lobby').classList.add('hidden'); };
$('#btnNewExam').onclick = () => { $('#results').classList.add('hidden'); $('#lobby').classList.remove('hidden'); $('#lobbyOpts').classList.remove('hidden'); };
$('#btnHistory').onclick = showHistory;
$('#btnPause').onclick = async () => { S = await api('/api/pause', {paused: !S.session.paused}); skew = Date.now()/1000 - S.session.server_time; paint(); };
$('#btnCloseHist').onclick = () => $('#histOv').classList.add('hidden');
// draggable split
const handle = $('#handle'), main = document.querySelector('main');
handle.onmousedown = e => { e.preventDefault(); const mv = ev => { main.style.gridTemplateColumns = `56px ${Math.max(320, ev.clientX - 56)}px 6px 1fr`; };
const up = () => { removeEventListener('mousemove', mv); removeEventListener('mouseup', up); }; addEventListener('mousemove', mv); addEventListener('mouseup', up); };
load();
})();
</script>
</body>
</html>