Every changed task was seeded, graded untouched, solved and re-graded on a separate kind cluster. The four sim sets were rehearsed as full sittings (real baseline, seed everything, solve in order, grade at the end): A 150/150, B 176/176, C 163/163, D 122/122. Every random-pool task was either passed in a real session or cycled the same way. Graders that could never pass, or passed for free: Q306 (local-path cannot resize; now graded as an in-place resize), Q314 (GNU grep prefixed file names), Q305 (plain `get events` has no SOURCE column and events expire; now managedFields), Q410 (kindnet DOES enforce NetworkPolicy; the expected answer is now derived from the CNI), Q35, Q212 (missing `]`), Q101 (`can-i create pods/exec` asks about a pod NAMED exec; multi-type get), Q109 (an emptyDir over nginx's /var/log crash-loops it), Q40 (TTL was measured at grading time), Q121, Q106, Q104, Q41, Q26, Q403, Q409, Q417, Q113, Q22, Q24/Q108 (flaky right after a rollout), and free points on untouched Q24/Q102/Q108/Q119/Q122. Reference solutions: S[43], S[107], S[121], S[106] rewritten; S[116] (a Completed Job pod still pins its PVC), S[119] (a pods quota equal to replicas deadlocks the rollout); DNS checks use FQDNs; the explainer and exam guide are corrected (etcd restore is etcdutl's job on 3.6+). Cross-session leaks: the pre-session baseline now also repairs Q114 (the kube-system NetworkPolicy and CoreDNS replicas), Q117/Q39 kubelet CA paths, Q317, kubelet drop-in binary paths, a moved scheduler manifest, leftover static pods and the default StorageClass. Q115 releases its fixed PVs from claims left by earlier sessions. Conflicts: `breaks` may be a list and `solo` keeps Q102's etcd restore out of mixed sessions. Q118, Q5/Q103, Q26 vs the W1 taints, set A 203/204/208/212 and set B 305/309 no longer sabotage each other. Q317 now disables only CSR signing (the old break killed the whole controller-manager). Q110/Q311 no longer compare against the whole cluster at submit time. Two delete/recreate races in setups are fixed. Harness: the weak list clears after two clean passes in a row, weak entries show the latest attempt's failures, domain scores use the exam's own tasks, history entries can be marked synthetic (entry 11 is), the web UI lists every sim set, `./exam grade` refuses mid-exam (--force), preflight checks W1. Set D (`./exam sim d`): 17 original killer.sh-style tasks, ids 501-517, 122 points - control-plane inventory, event forensics with crictl, a broken kubelet unit, kubelet certificates, manual scheduling with the scheduler stopped, Helm housekeeping with a stuck release, an enforced AND-vs-OR NetworkPolicy, dnsPolicy and access-mode troubleshooting, and more. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
488 lines
33 KiB
HTML
488 lines
33 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 style="color:var(--muted)"> — killer.sh-style: more tasks in the same time, no hints, traps, exact-output tasks, compound failures, collateral checks, exam contexts.</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 => ({'&':'&','<':'<','>':'>'}[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;
|
|
$('#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 the cluster.
|
|
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 5 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 (Set C and later were unreachable from here when
|
|
// the buttons were hard-coded to A and B)
|
|
$('#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;
|
|
$('#trackPick').innerHTML = (S.tracks || []).map(t =>
|
|
`<span class="pill pick ${t === S.track_id ? 'sel' : ''}" data-track="${t}">${t.toUpperCase()}</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', {}); } 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>
|