Puts the session's full time back on the clock and starts it running, keeping the hosts, the work on them, results, flags and notes. Like pause it is recorded nowhere: the attempt's time used counts from the restart. Refused once a session has been submitted. POST /api/restart backs the button, which asks before it resets; a ./ksim web started before this says so instead of failing silently. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JZPd3GGWyxTgAkL6a4gAJz
620 lines
45 KiB
HTML
620 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="btnRestart" title="Back to the session's full time - the hosts and your work stay as they are">Restart clock</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 => ({'&':'&','<':'<','>':'>'}[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 ? '→' : ' '} ${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);
|
|
$('#btnRestart').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(); };
|
|
$('#btnRestart').onclick = async () => {
|
|
if (!confirm(`Restart the clock at ${fmt(S.session.duration)}? The hosts and your work stay as they are.`)) return;
|
|
try { S = await api('/api/restart', {}); skew = Date.now()/1000 - S.session.server_time; autoSubmitted = false; paint(); }
|
|
catch (e) { alert(e.message === 'not found' ? 'This ./ksim web was started before "restart" existed. Stop it (Ctrl-C) and start it again.' : e.message); }
|
|
};
|
|
$('#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>
|