kubestronaut-sim/web/index.html
vumas3r c266eeb4b5 Every session against the clock; skip what you know
Study mode is gone: no more untimed sessions with the reference
solutions open from the start. The path is now objective drills against
the clock, then a mock, then the fixed sets, and an objective is done
when every task passed on its latest timed attempt. In the lobby "Study
mode" becomes "Drill an objective" (the objective list, with one
"Against the clock" button per objective); ./ksim study is removed and
./ksim next always drills. study-<track>.json keeps focus, known and the
done stamps; results of old study sessions stay in it, read by nothing.

Also in this commit, from the work before it:
- ./ksim skip / unskip, and links beside each domain in the lobby: mark
  a domain or an objective as known, so the path, the no-argument drill
  and drill all leave it out; a mock still draws from every domain
- DOMAIN:N picks N tasks of a domain, spread over its objectives
- a command with neither --track nor KSIM_TRACK is for the track whose
  session is under way; ./ksim show says where the task's host resolves
- CKAD Q60: the check names which file holds which run's output

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZPd3GGWyxTgAkL6a4gAJz
2026-10-15 15:01:29 -06:00

613 lines
45 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kubestronaut 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); }
#qHost { margin: 10px 0 0; padding: 7px 10px; background: var(--accent-soft); border: 1px solid var(--accent); border-radius: 6px; font-size: 13px; }
#qHost code { background: transparent; border: 0; color: var(--ink); font-weight: 600; cursor: copy; padding: 0 2px; }
#qHost .n { display: block; color: var(--muted); font-size: 12px; margin-top: 2px; }
#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; }
body.dragging #term { pointer-events: none; } /* an iframe swallows the mouse: let the split handle keep it */
#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); }
/* ---- curriculum: objective list in the lobby, notes beside a task ---- */
.notes h4 { margin: 12px 0 4px; font-size: 11.5px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 600; }
.notes ul { margin: 0; padding-left: 18px; } .notes li { margin: 3px 0; }
.notes code { background: var(--pane-2); padding: 1px 5px; border-radius: 4px; border: 1px solid var(--line); color: #c9d4f2; }
.notes pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.55 var(--mono); background: var(--bg-alt); border: 1px solid var(--line); padding: 10px 12px; border-radius: 6px; color: #c9d4f2; }
.notes a { overflow-wrap: anywhere; }
#objNotes { margin-top: 14px; border: 1px solid var(--line); border-radius: 6px; padding: 10px 14px; font-size: 13px; background: var(--pane-2); }
#objNotes summary { cursor: pointer; font-weight: 600; }
#objList { max-height: 56vh; overflow-y: auto; margin-top: 8px; padding-right: 4px; }
#objList h3 { margin: 14px 0 4px; font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 600; }
details.obj { border-top: 1px solid var(--line); padding: 7px 0; font-size: 13px; }
details.obj > summary { cursor: pointer; display: grid; grid-template-columns: 16px 30px 1fr auto; gap: 8px; align-items: baseline; list-style: none; }
details.obj > summary::-webkit-details-marker { display: none; }
details.obj > summary:hover .otext { color: var(--accent-hover); }
details.obj[open] > summary { margin-bottom: 6px; }
details.obj .oid { font-family: var(--mono); color: var(--muted); }
.mk { font-family: var(--mono); font-weight: 600; text-align: center; }
.st-done, .st-passed { color: var(--ok); } .st-started, .st-focus { color: var(--warn); } .st-failed { color: var(--danger); } .st-new { color: var(--muted); } .st-known { color: var(--accent-hover); }
#objList h3 a.known, #objList h3 a.touch { text-transform: none; letter-spacing: 0; font-weight: 400; margin-left: 8px; }
.pill.known { color: var(--accent-hover); }
.chips { font: 12px var(--mono); white-space: nowrap; color: var(--muted); }
.chips span { margin-left: 6px; }
.ladder { display: grid; grid-template-columns: auto 1fr auto; gap: 2px 12px; font-size: 13px; margin: 0 0 14px; padding: 10px 12px;
background: var(--pane-2); border: 1px solid var(--line); border-radius: 8px; }
.ladder .here { color: var(--accent-hover); font-weight: 600; } .ladder .muted { color: var(--muted); }
.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 id="qHost" class="hidden"></div>
</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>
<details id="objNotes" class="hidden"><summary id="objNotesSum"></summary><div class="notes" id="objNotesBody"></div></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, » = marked known):</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="ladder hidden" id="ladder"></div>
<div class="opts" id="lobbyOpts">
<button class="opt hidden" id="optPath" style="grid-column:1/3;border-color:var(--accent)"><b id="pathTitle">Continue the path</b><span id="pathDesc"></span></button>
<button class="opt hidden" id="optObjective"><b>Drill an objective</b><span id="objectiveDesc"></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="objPick" class="hidden">
<div class="sub" style="margin:14px 0 0" id="objSub"></div>
<div id="objList"></div>
<div class="row"><button class="btn" id="btnBackObj">Back</button></div>
</div>
<div id="seeding" class="hidden">
<div class="sub">Preparing your exam environment: one cluster per task, then its scenario…</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 on its own host as it is right now. You can't return after submitting; the hosts stay up for review until the next session.</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 id="resPath" style="margin-top:12px;font-size:13px"></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('');
}
// ---- curriculum ----
const MARK = {done: '✓', passed: '✓', started: '-', failed: '✗', new: '·', known: '»'};
const mk = st => `<span class="mk st-${st}">${MARK[st] || '·'}</span>`;
const inline = s => esc(s).replace(/`([^`]+)`/g, '<code>$1</code>');
function notesHtml(o) {
return `<h4>Know</h4><ul>${o.know.map(x => `<li>${inline(x)}</li>`).join('')}</ul>` +
`<h4>Fast</h4><pre>${esc(o.fast)}</pre>` +
`<h4>Traps</h4><ul>${o.traps.map(x => `<li>${inline(x)}</li>`).join('')}</ul>` +
`<h4>Docs you may open in the exam</h4><ul>${o.docs.map(u => `<li><a href="${esc(u)}" target="_blank" rel="noopener">${esc(u.replace('https://', ''))}</a></li>`).join('')}</ul>`;
}
function continuePath() {
const n = S.path && S.path.next; if (!n) return;
if (n.objective) start('drill', null, false, false, null, n.objective);
else if (n.kind === 'sim') startSim(n.set);
else start('exam');
}
function paintPath() {
const P = S.path, has = !!P;
$('#optPath').classList.toggle('hidden', !has); $('#ladder').classList.toggle('hidden', !has);
$('#optObjective').classList.toggle('hidden', !has);
if (!has) return;
$('#objectiveDesc').textContent = `Pick any of the ${P.total} objectives: read its notes, then drill its tasks against the clock.`;
const best = v => v == null ? 'not sat yet' : `best ${v}%`, k = P.next.kind;
const kn = P.known || 0, left = P.total - kn, aside = kn ? ` · ${kn} marked known` : '';
const rows = [['drill', 'Against the clock', `${P.done}/${left} objectives${aside}`],
['mock', 'Mock exam', best(P.mock_best)]].concat(Object.keys(S.sets || {}).map(x => ['sim:' + x, S.sets[x].name, best(P.set_best[x])]));
$('#ladder').innerHTML = rows.map(([kind, name, where], i) => {
const here = kind === k || kind === 'sim:' + P.next.set && k === 'sim';
return `<span class="${here ? 'here' : 'muted'}">${here ? '→' : '&nbsp;'} ${i + 1}</span><span class="${here ? 'here' : ''}">${esc(name)}</span><span class="muted">${esc(where)}</span>`;
}).join('');
$('#pathTitle').textContent = 'Continue the path · ' + P.next.cmd.replace('./ksim ', '');
$('#pathDesc').textContent = P.next.why + '.';
const chip = t => `<span class="st-${t.state}" title="Q${t.id} ${esc(t.title)}${t.set ? ' (simulator set)' : ''}">${MARK[t.state]}${t.id}</span>`;
// pool tasks, then the simulator-set ones in one pair of brackets (they stay out of objective sessions)
const chips = o => o.tasks.filter(t => !t.set).map(chip).join('') +
(o.tasks.some(t => t.set) ? `<span>[</span>${o.tasks.filter(t => t.set).map(chip).join('').replace('<span', '<span style="margin-left:0"')}<span style="margin-left:0">]</span>` : '');
const doms = [...new Set(S.objectives.map(o => o.domain))];
$('#objSub').textContent = '✓ complete: every task passed against the clock. - started. » marked known, off the path. Open an objective for its notes.';
const focus = S.focus || []; // ./ksim focus: the domains the path walks first
const known = S.known || []; // ./ksim skip: what you said you know - the path leaves it out, nothing is recorded as passed
const knownTag = d => known.includes(d)
? ` <span class="st-known" title="./ksim skip ${d}: you said you are good with this domain, so the path leaves it out. Nothing is recorded as passed, and a mock exam still draws from it.">· known, off the path</span><a href="#" class="touch" data-touch="${d}" title="./ksim drill ${d}:3 — three of its tasks, each from another objective, against the clock: to keep a domain you know warm">3 of its questions</a><a href="#" class="known" data-known="${d}" data-on="0" title="./ksim unskip ${d}">put it back</a>`
: `<a href="#" class="known" data-known="${d}" data-on="1" title="./ksim skip ${d}: leave this domain out of the path. Nothing is recorded as passed, and a mock exam still draws from it.">I know this — skip it</a>`;
$('#objList').innerHTML = doms.map(d => `<h3>${esc(S.track.domains[d])}${focus.includes(d) ? ` <span class="st-focus" title="./ksim focus: the path walks this domain first">· focus ${focus.indexOf(d) + 1}</span>` : ''}${knownTag(d)}</h3>` + S.objectives.filter(o => o.domain === d).map(o =>
`<details class="obj"><summary>${mk(o.state)}<span class="oid">${o.id}</span><span class="otext">${esc(o.text)}</span><span class="chips">${chips(o)}</span></summary>
<div class="notes">${notesHtml(o)}</div>
<div class="row" style="margin:10px 0 6px"><button class="btn primary" data-drill="${o.id}">Against the clock</button></div>
</details>`).join('')).join('');
$('#objList').querySelectorAll('[data-drill]').forEach(b => b.onclick = () => start('drill', null, false, false, null, b.dataset.drill));
$('#objList').querySelectorAll('a.touch').forEach(a => a.onclick = e => {
e.preventDefault(); start('drill', null, false, false, null, null, [[a.dataset.touch, 3]]);
});
$('#objList').querySelectorAll('a.known').forEach(a => a.onclick = async e => {
e.preventDefault();
try { await api('/api/known', {mark: a.dataset.known, known: a.dataset.on === '1'}); await load(); }
catch (err) { alert(err.message === 'not found' ? 'This ./ksim web was started before "skip" existed. Stop it (Ctrl-C) and start it again.' : err.message); }
});
}
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.mode === 'drill' ? 'Drill' : 'Exam') + (sess.hard ? ' · HARD' : '')
+ (sess.objective ? ' · objective ' + sess.objective : '') : '';
$('#btnEnd').classList.toggle('hidden', !live);
$('#btnPause').classList.toggle('hidden', !live);
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);
// the host a task is solved on, as the exam prints it above every task
const live2 = sess && S.questions.some(x => x.id === q.id);
$('#qHost').classList.toggle('hidden', !live2);
if (live2) $('#qHost').innerHTML = `Solve this question on: <code title="click to copy">ssh ${esc(q.host)}</code><span class="n">${q.nodes}-node cluster · task files in ${esc(S.workspace)} on that host</span>`;
// a click copies to the remote desktop's clipboard (paste there with Ctrl+Shift+V) and to yours
document.querySelectorAll('#qBody code, #qHost code').forEach(c => c.onclick = () => {
const done = () => { c.classList.add('copied'); setTimeout(() => c.classList.remove('copied'), 700); };
api('/api/clip', {text: c.textContent}).then(done, done);
if (navigator.clipboard) navigator.clipboard.writeText(c.textContent).catch(() => {});
});
$('#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;
// the objective this task practises: the session's own if the task belongs to it, else its first
const oids = q.objectives || [], oid = oids.includes(sess && sess.objective) ? sess.objective : oids[0];
const obj = oid && (S.objectives || []).find(o => o.id === oid);
$('#objNotes').classList.toggle('hidden', !obj);
if (obj) { $('#objNotesSum').textContent = `Objective ${obj.id} · ${obj.text}`; $('#objNotesBody').innerHTML = notesHtml(obj); }
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 || '';
// the remote desktop
const tw = $('#termwrap');
if (!tw.dataset.ready) {
tw.dataset.ready = 1;
tw.innerHTML = S.desktop ? `<iframe id="term" src="${S.desktop}" title="remote desktop" allow="clipboard-read; clipboard-write"></iframe>` :
`<div id="noterm">The remote desktop is not running.<br><br>${esc(S.desktop_error || '')}<br><br>
Build the images once with <code>./ksim build</code>, then restart <code>./ksim web</code>.<br><br>
Until then, <code>./ksim ssh N</code> opens a shell on the host of task N from your own terminal.</div>`;
}
tickClock();
}
function tickClock() {
clearInterval(clockT);
const sess = S.session, el = $('#clock');
if (!sess || sess.ended_at) { el.textContent = sess ? 'ended' : '--:--:--'; el.className = ''; 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 || {}, skipped = S.known || [];
const undone = Object.keys(S.track.domains).filter(d => !drilled[d] && !skipped.includes(d));
$('#coverage').innerHTML = Object.keys(S.track.domains).map(d =>
`<span class="pill pick ${drilled[d] ? 'done' : skipped.includes(d) ? 'known' : ''}" data-domain="${d}" title="Drill ${S.track.domains[d]} — 5 tasks${skipped.includes(d) ? ' (you marked this domain as known)' : ''}">${d} ${drilled[d] ? '✓' : skipped.includes(d) ? '»' : '·'}</span>`).join('');
$('#coverage').querySelectorAll('[data-domain]').forEach(el => el.onclick = () => start('drill', null, false, false, el.dataset.domain));
const openDoms = Object.keys(S.track.domains).filter(d => !skipped.includes(d));
const nxt = (openDoms.length ? openDoms : 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;
paintPath();
$('#hardDesc').textContent = ' — killer.sh-style: more tasks in the same time, no hints, traps, exact-output tasks, compound failures, collateral checks.';
// each track's hosts run 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 = 'ksim ' + (S.version || '?') + (S.pending ? ` · ${S.pending} task(s) held back` : '');
$('#lobbySub').textContent = sess && sess.ended_at ? `Last session ended with ${sess.score.pct}%. Start another, or review it under History.` : 'Choose a session. Every task gets its own cluster: a drill is ready in about a minute, a full exam in three or four.';
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; $('#termwrap').dataset.ready = '';
$('#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, objective, touch) {
$('#lobbyOpts').classList.add('hidden'); $('#drillPick').classList.add('hidden'); $('#objPick').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,
objective: objective || null, touch: touch || null, 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; $('#termwrap').dataset.ready = '';
$('#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 ./ksim 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('');
$('#resPath').innerHTML = !S.path ? '' : (s.milestones || []).map(m => `<div class="st-done">${esc(m)}</div>`).join('') +
`<div style="margin-top:6px"><b>Next on the path:</b> <code>${esc(S.path.next.cmd)}</code> <span style="color:var(--muted)">${esc(S.path.next.why)}</span></div>`;
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');
paintPath(); // the lobby behind the results now shows where this session left the path
}
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);
$('#optObjective').onclick = () => {
$('#lobbyOpts').classList.add('hidden'); $('#gate').classList.add('hidden'); $('#objPick').classList.remove('hidden');
};
$('#optPath').onclick = continuePath;
$('#btnBackObj').onclick = () => { $('#objPick').classList.add('hidden'); $('#lobbyOpts').classList.remove('hidden'); load(); };
$('#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(); document.body.classList.add('dragging');
const mv = ev => { main.style.gridTemplateColumns = `56px ${Math.max(320, ev.clientX - 56)}px 6px 1fr`; };
const up = () => { document.body.classList.remove('dragging'); removeEventListener('mousemove', mv); removeEventListener('mouseup', up); };
addEventListener('mousemove', mv); addEventListener('mouseup', up); };
load();
})();
</script>
</body>
</html>