kubestronaut-sim/web/index.html
vumas3r bab85d14b7 Browser UI in the style of CK-X: landing page, exam screen, results
Rebuilt web/index.html after the layout of CK-X (itself modelled on the
Linux Foundation exam UI), written from scratch and loading nothing from
the network:

- landing page: track switch in the top bar, a Start exam dialog to pick
  the session (path, objective, domain, next domain, whole bank, picked
  tasks, simulator sets, full exam with its gate, hard mode), the path,
  a card per domain, every objective with its notes and skip links, and
  an overlay with the clusters' progress while a session is prepared
- exam screen: dark top bar with the clock and the Exam Interface /
  Exam Controls / Exam Information menus, a light question panel with
  < Question N >, "Solve this question on instance", Flag for review,
  Satisfied with answer, and Check this task in drills; the remote
  desktop with reconnect and fullscreen beside it
- results page: points, pass or fail, domains, every task's checks and
  reference solution, answers, drill what I missed, review on the hosts

A session started in the browser opens a dialog whose Start puts the
full time on the clock. New POST /api/terminate backs Terminate session
(an open session ends ungraded, as ./ksim abort) and Delete the hosts
(a finished session loses its hosts); both delete that session's hosts
only. The session payload says whether a session was aborted.

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

1026 lines
74 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>
/* The look of CK-X (github.com/sailor-sh/CK-X), itself modelled on the Linux Foundation exam UI:
an indigo landing page, an exam screen with a dark top bar and a light question panel, and a
plain results page. Written from scratch - no CK-X code is copied - and without anything from
the network: the page has to work offline. */
:root {
--indigo: #4f46e5; --indigo-dark: #3730a3; --indigo-light: #818cf8; --indigo-soft: #eef2ff;
--ink: #111827; --text: #212529; --muted: #6b7280; --bg: #f9fafb; --line: #dee2e6; --line-2: #ddd;
--blue: #0d6efd; --blue-hover: #0b5ed7; --divider: #1e88e5;
--green: #198754; --green-hover: #157347; --ok: #27ae60; --bad: #e74c3c;
--yellow: #ffc107; --red: #dc3545; --red-hover: #bb2d3b; --grey: #6c757d; --grey-hover: #5c636a;
--code: #d63384; --code-bg: rgba(0,0,0,.06);
--head: #333; --head-btn: #444;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif;
--mono: "JetBrains Mono", SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body { font: 15px/1.5 var(--sans); color: var(--text); background: var(--bg); }
button { font: inherit; }
a { color: var(--blue); }
code, pre { font-family: var(--mono); }
.hidden { display: none !important; }
.muted { color: var(--muted); }
.mb0 { margin-bottom: 0; }
/* ---- buttons and alerts, after Bootstrap's ---- */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: .375rem .75rem; border: 1px solid transparent;
border-radius: .375rem; background: transparent; color: var(--text); cursor: pointer; line-height: 1.5; text-decoration: none;
transition: background-color .15s, border-color .15s, color .15s; }
.btn:disabled { opacity: .55; cursor: default; }
.btn-sm { padding: .2rem .55rem; font-size: 13px; }
.btn-primary { background: var(--blue); border-color: var(--blue); color: #fff; }
.btn-primary:hover:not(:disabled) { background: var(--blue-hover); }
.btn-success { background: var(--green); border-color: var(--green); color: #fff; }
.btn-success:hover:not(:disabled) { background: var(--green-hover); }
.btn-danger { background: var(--red); border-color: var(--red); color: #fff; }
.btn-danger:hover:not(:disabled) { background: var(--red-hover); }
.btn-secondary { background: var(--grey); border-color: var(--grey); color: #fff; }
.btn-secondary:hover:not(:disabled) { background: var(--grey-hover); }
.btn-warning { background: var(--yellow); border-color: var(--yellow); color: #000; }
.btn-outline-warning { border-color: var(--yellow); color: #b58100; }
.btn-outline-warning:hover { background: var(--yellow); color: #000; }
.btn-outline-primary { border-color: var(--blue); color: var(--blue); }
.btn-outline-primary:hover:not(:disabled) { background: var(--blue); color: #fff; }
.alert { padding: .75rem 1rem; border: 1px solid transparent; border-radius: .375rem; margin: 0 0 1rem; }
.alert ul { margin: .25rem 0 0; padding-left: 1.2rem; }
.alert p { margin: 0 0 .4rem; }
.alert-info { background: #cff4fc; border-color: #b6effb; color: #055160; }
.alert-success { background: #d1e7dd; border-color: #badbcc; color: #0f5132; }
.alert-warning { background: #fff3cd; border-color: #ffecb5; color: #664d03; }
.alert-danger { background: #f8d7da; border-color: #f5c2c7; color: #842029; white-space: pre-wrap; }
/* a value in a task: pink, and a click copies it */
code.ic { background: var(--code-bg); border-radius: 3px; padding: 2px 4px; font-size: .86em; color: var(--code); cursor: copy; overflow-wrap: anywhere; }
code.ic:hover { background: rgba(0,0,0,.12); }
code.ic.copied { background: #d1e7dd; color: var(--green); }
.hardtag { font-size: 11px; background: var(--red); color: #fff; border-radius: 3px; padding: 1px 6px; margin-left: 6px; vertical-align: middle; }
/* ---- landing page ---- */
.navbar { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; gap: 14px; padding: 10px 24px;
background: rgba(17,24,39,.96); color: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
.navbar .brand { font-weight: 700; font-size: 18px; letter-spacing: -.01em; margin-right: 6px; }
.navbar .spacer { flex: 1; }
.navbar .navlink { background: none; border: 0; color: rgba(255,255,255,.88); padding: 6px 12px; border-radius: 8px; cursor: pointer; }
.navbar .navlink:hover { background: rgba(255,255,255,.1); color: #fff; }
.navbar .ver { color: rgba(255,255,255,.55); font-size: 12px; }
.tpills { display: flex; gap: 6px; }
.tpill { border: 1px solid rgba(255,255,255,.25); background: transparent; color: rgba(255,255,255,.85); border-radius: 50px; padding: 3px 12px; font-size: 13px; cursor: pointer; }
.tpill span { opacity: .65; margin-left: 4px; }
.tpill:hover { border-color: #fff; color: #fff; }
.tpill.sel { background: #fff; border-color: #fff; color: var(--indigo); font-weight: 600; }
.hero { position: relative; overflow: hidden; min-height: 60vh; display: flex; flex-direction: column; align-items: center; justify-content: center;
text-align: center; padding: 64px 24px 72px; color: #fff; background: linear-gradient(135deg, var(--indigo) 0%, var(--indigo-light) 100%); }
.hero::before { content: ""; position: absolute; inset: 0; opacity: .5;
background-image: radial-gradient(rgba(255,255,255,.18) 1.5px, transparent 1.5px), radial-gradient(rgba(255,255,255,.1) 1px, transparent 1px);
background-size: 48px 48px, 22px 22px; background-position: 0 0, 11px 11px; }
.hero > * { position: relative; }
.hero h1 { margin: 0 0 18px; font-size: clamp(30px, 4.6vw, 54px); font-weight: 800; letter-spacing: -.025em; line-height: 1.1; max-width: 900px;
background: linear-gradient(to right, #fff, #e0e7ff); -webkit-background-clip: text; background-clip: text; color: transparent; }
.hero .lead { font-size: 19px; max-width: 700px; margin: 0 0 34px; opacity: .95; line-height: 1.6; }
.btn-hero { background: #fff; color: var(--indigo); border: 0; border-radius: 50px; padding: 14px 44px; font-weight: 700; letter-spacing: .06em;
text-transform: uppercase; cursor: pointer; box-shadow: 0 4px 6px -1px rgba(0,0,0,.1), 0 2px 4px -1px rgba(0,0,0,.06); transition: transform .2s, box-shadow .2s; }
.btn-hero:hover { transform: translateY(-2px); box-shadow: 0 10px 15px -3px rgba(0,0,0,.15); }
.hardswitch { margin-top: 22px; display: inline-flex; align-items: center; gap: 8px; background: rgba(255,255,255,.12); border-radius: 50px;
padding: 7px 16px; font-size: 14px; cursor: pointer; }
.hardswitch input { width: 16px; height: 16px; accent-color: #fff; }
.lastline { margin-top: 18px; font-size: 14px; opacity: .92; }
.lastline a { color: #fff; font-weight: 600; }
.scrollhint { position: absolute; bottom: 52px; left: 0; right: 0; font-size: 11px; letter-spacing: .14em; opacity: .75; } /* above the first card, which overlaps the hero */
.wrap { max-width: 1040px; margin: -36px auto 0; padding: 0 20px 40px; position: relative; z-index: 2; }
.card { background: #fff; border-radius: 18px; box-shadow: 0 10px 30px rgba(0,0,0,.06); padding: 30px 34px; margin-bottom: 26px; }
.section-title { margin: 0 0 6px; font-size: 24px; font-weight: 800; color: var(--ink); letter-spacing: -.01em; }
.section-title::after { content: ""; display: block; width: 46px; height: 4px; border-radius: 2px; background: var(--indigo); margin-top: 10px; }
.section-sub { color: var(--muted); margin: 12px 0 18px; }
.ladder { display: grid; grid-template-columns: 34px 1fr auto; gap: 6px 14px; align-items: center; margin: 0 0 18px; }
.rung { display: contents; }
.rung .step { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 13px; font-weight: 700;
background: var(--indigo-soft); color: var(--indigo); }
.rung .where { color: var(--muted); font-size: 14px; }
.rung.here .step { background: var(--indigo); color: #fff; }
.rung.here > span:nth-child(2) { font-weight: 700; color: var(--indigo-dark); }
.pathgo { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; padding: 16px 18px; border-radius: 12px; background: var(--indigo-soft); }
.pathgo .why { flex: 1; min-width: 240px; color: #312e81; font-size: 14px; }
.dpills { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px; }
.dpill { text-align: left; border: 1px solid var(--line); background: #fff; border-radius: 12px; padding: 12px 14px; cursor: pointer;
transition: border-color .15s, transform .15s, box-shadow .15s; }
.dpill:hover { border-color: var(--indigo-light); transform: translateY(-2px); box-shadow: 0 6px 16px rgba(79,70,229,.12); }
.dpill b { font-family: var(--mono); color: var(--indigo); }
.dpill .mk { float: right; font-weight: 700; color: var(--muted); }
.dpill.done .mk { color: var(--green); } .dpill.known .mk { color: var(--indigo); }
.dpill span { display: block; color: var(--muted); font-size: 13px; margin-top: 4px; }
.gate { margin-top: 14px; font-size: 14px; color: #92400e; }
/* objectives */
.legend { color: var(--muted); font-size: 13px; margin: 0 0 8px; }
#objList h3 { margin: 22px 0 6px; font-size: 13px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 700; }
#objList h3 a { text-transform: none; letter-spacing: 0; font-weight: 400; margin-left: 10px; }
details.obj { border-top: 1px solid #edf0f3; padding: 9px 0; font-size: 14px; }
details.obj > summary { cursor: pointer; display: grid; grid-template-columns: 18px 34px 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(--indigo); }
details.obj[open] > summary { margin-bottom: 8px; }
details.obj .oid { font-family: var(--mono); color: var(--muted); }
.mk { font-family: var(--mono); font-weight: 700; text-align: center; }
.st-done, .st-passed { color: var(--green); } .st-started, .st-focus { color: #b58100; } .st-failed { color: var(--red); } .st-new { color: #9ca3af; } .st-known { color: var(--indigo); }
.chips { font: 12px var(--mono); white-space: nowrap; color: var(--muted); }
.chips span { margin-left: 6px; }
.objrow { margin: 10px 0 6px; }
.notes h4 { margin: 12px 0 4px; font-size: 11.5px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 700; }
.notes ul { margin: 0; padding-left: 18px; } .notes li { margin: 3px 0; }
.notes code { background: var(--code-bg); padding: 1px 5px; border-radius: 3px; color: var(--code); font-size: .9em; }
.notes pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12.5px; line-height: 1.55; background: #f6f8fa; border: 1px solid var(--line); padding: 10px 12px; border-radius: 6px; }
.notes a { overflow-wrap: anywhere; }
footer.foot { text-align: center; color: var(--muted); font-size: 13px; padding: 10px 20px 34px; }
/* ---- dialogs ---- */
.modal-back { position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center; padding: 16px; }
.modal { background: #fff; border-radius: .5rem; width: min(560px, 100%); max-height: 92vh; display: flex; flex-direction: column; box-shadow: 0 .5rem 1rem rgba(0,0,0,.15); color: var(--text); }
.modal.wide { width: min(720px, 100%); }
.modal .mh { display: flex; align-items: center; padding: 1rem 1rem; border-bottom: 1px solid var(--line); }
.modal .mh h5 { margin: 0; font-size: 1.25rem; font-weight: 500; flex: 1; }
.modal .x { border: 0; background: none; font-size: 24px; line-height: 1; color: #6c757d; cursor: pointer; padding: 0 4px; }
.modal .mb { padding: 1rem; overflow-y: auto; }
.modal .mf { display: flex; gap: 8px; justify-content: flex-end; padding: .75rem 1rem; border-top: 1px solid var(--line); }
.field { margin-bottom: 1rem; }
.field label.lbl { display: block; margin-bottom: .4rem; font-weight: 500; }
select, textarea { font: inherit; }
.select { width: 100%; padding: .375rem 2.25rem .375rem .75rem; border: 1px solid #ced4da; border-radius: .375rem; background: #fff; color: var(--text); }
.check { display: flex; gap: 8px; align-items: flex-start; }
.check input { margin-top: 4px; }
.picker { columns: 2; font-size: 13px; max-height: 38vh; overflow-y: auto; border: 1px solid var(--line); border-radius: .375rem; padding: 8px 10px; }
.picker label { display: block; padding: 2px 0; break-inside: avoid; }
/* preparing the environment */
.loading-card { background: #fff; border-radius: 16px; width: min(640px, 100%); padding: 30px 32px; box-shadow: 0 20px 50px rgba(0,0,0,.25); }
.loading-card h2 { margin: 0 0 18px; font-size: 22px; color: var(--ink); }
.progress { height: 10px; border-radius: 10px; background: #e5e7eb; overflow: hidden; }
.progress span { display: block; height: 100%; width: 40%; border-radius: 10px; background: linear-gradient(90deg, var(--indigo), var(--indigo-light));
animation: slide 1.6s ease-in-out infinite; }
@keyframes slide { 0% { transform: translateX(-100%); } 100% { transform: translateX(260%); } }
.loading-msg { margin: 14px 0 10px; font-family: var(--mono); font-size: 13px; color: var(--indigo-dark); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.loading-card details summary { cursor: pointer; color: var(--muted); font-size: 13px; }
pre.log { background: #f6f8fa; border: 1px solid var(--line); padding: 10px 12px; font-size: 12px; line-height: 1.5; max-height: 220px; overflow: auto; border-radius: 6px; color: #374151; margin: 8px 0 0; white-space: pre-wrap; }
.loading-card .note { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); color: var(--muted); font-size: 13px; text-align: center; }
.spinner { width: 2.4rem; height: 2.4rem; border: .25em solid #cbd5e1; border-right-color: var(--blue); border-radius: 50%; animation: spin .75s linear infinite; margin: 6px auto 14px; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ---- exam screen ---- */
#exam { position: fixed; inset: 0; display: flex; flex-direction: column; background: var(--head); color: #333; }
.xhead { flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: .5rem 1rem; background: var(--head);
color: #fff; border-bottom: 1px solid var(--head-btn); }
.xtitlebox { display: flex; align-items: center; gap: 15px; min-width: 0; }
.xtitle { font-size: 1.25rem; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.timer { padding: .25rem .5rem; background: rgba(255,255,255,.1); border-radius: .25rem; color: #fff; font-weight: 500; font-variant-numeric: tabular-nums; transition: background-color .3s, color .3s; }
.timer.warning { background: rgba(220,53,69,.25); color: var(--yellow); animation: pulse 1.4s ease-in-out infinite; }
.timer.paused { color: #bbb; }
@keyframes pulse { 0%, 100% { background: rgba(220,53,69,.25); } 50% { background: rgba(220,53,69,.5); } }
.xmenus { display: flex; align-items: center; flex-shrink: 0; }
.dd { position: relative; display: inline-block; }
.btn-custom { background: var(--head-btn); color: #fff; border: 0; padding: .35rem .75rem; margin-left: .5rem; border-radius: .25rem; cursor: pointer; }
.btn-custom:hover { background: #555; }
.dd-toggle::after { content: ""; display: inline-block; margin-left: .45em; vertical-align: .2em; border-top: .3em solid; border-right: .3em solid transparent; border-left: .3em solid transparent; }
.dd-menu { display: none; position: absolute; right: 0; top: calc(100% + 4px); z-index: 40; min-width: 220px; padding: .5rem 0; background: #fff;
border: 1px solid rgba(0,0,0,.15); border-radius: .375rem; box-shadow: 0 .5rem 1rem rgba(0,0,0,.15); }
.dd-menu.open { display: block; }
.dd-menu.left { left: 0; right: 0; max-height: 60vh; overflow-y: auto; }
.dd-item { display: block; width: 100%; text-align: left; padding: .3rem 1rem; border: 0; background: none; color: #212529; cursor: pointer; text-decoration: none; white-space: nowrap; }
.dd-menu.left .dd-item { white-space: normal; }
.dd-item:hover { background: #e9ecef; }
.dd-item.danger { color: var(--red); }
.dd-item .flag { color: var(--yellow); }
.dd-item.pass::before, .dd-item.partial::before, .dd-item.fail::before { content: "●"; margin-right: 6px; }
.dd-item.pass::before { color: var(--green); } .dd-item.partial::before { color: #e0a800; } .dd-item.fail::before { color: var(--red); }
.dd-sep { height: 0; margin: .5rem 0; border-top: 1px solid rgba(0,0,0,.15); }
.xmain { flex: 1; display: flex; min-height: 0; position: relative; overflow: hidden; }
.qpanel { width: 32%; min-width: 320px; max-width: 70%; background: #fff; display: flex; flex-direction: column; min-height: 0; }
.qnav { display: flex; padding: .5rem; background: #f5f5f5; border-bottom: 1px solid var(--line-2); flex-shrink: 0; }
.nav-arrow { width: 35px; height: 35px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; background: #f8f9fa;
border: 1px solid var(--line-2); border-radius: 4px; font-weight: 700; cursor: pointer; color: #333; }
.nav-arrow:disabled { opacity: .5; cursor: not-allowed; background: #e9ecef; }
.qselect { flex: 1; margin: 0 .5rem; }
.qselect .dd-toggle { width: 100%; height: 35px; border: 0; background: #f8f9fa; border-radius: .375rem; cursor: pointer; color: #212529; }
.qselect .dd-toggle:hover { background: #e9ecef; }
.qcontent { flex: 1; overflow-y: auto; padding: 1.5rem; }
.qhead .line { margin-bottom: .8rem; }
.qhead .sub { margin-top: -.55rem; color: var(--muted); font-size: 13px; }
.tp { color: var(--blue); }
.qcontent hr { border: 0; border-top: 1px solid #dee2e6; margin: 1rem 0; }
.qtitle { font-size: 18px; margin: 0 0 .6rem; color: #212529; }
.qbody p { margin: 0 0 12px; white-space: pre-wrap; }
.checkrow { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 16px; font-size: 13px; }
.checks { margin-top: 14px; background: #f8f9fa; border: 1px solid var(--line); border-radius: 6px; padding: 12px 14px; font-size: 14px; }
.checks div { padding: 3px 0; }
.checks .ok::before { content: "✓ "; color: var(--ok); font-weight: 700; } .checks .no::before { content: "✗ "; color: var(--bad); font-weight: 700; }
.checks .pts { color: var(--muted); }
details.fold { margin-top: 14px; border: 1px solid var(--line); border-radius: 6px; padding: 10px 14px; font-size: 14px; background: #fcfcfd; }
details.fold summary { cursor: pointer; font-weight: 600; }
details.fold pre { margin: 8px 0 0; white-space: pre-wrap; font-size: 12.5px; line-height: 1.55; background: #f6f8fa; border: 1px solid var(--line); padding: 12px; border-radius: 6px; }
.qactions { flex-shrink: 0; display: flex; gap: 8px; justify-content: space-between; align-items: stretch; padding: 10px 1rem; background: #fff; border-top: 1px solid var(--line-2); }
.qactions .btn { white-space: normal; text-align: center; } /* a narrow panel wraps the labels, as CK-X does, rather than the buttons */
.notepad { flex-shrink: 0; border-top: 1px solid var(--line-2); background: #fafafa; }
.notepad summary { cursor: pointer; padding: 8px 1.5rem; color: var(--muted); font-size: 13px; }
.notepad textarea { display: block; width: 100%; height: 120px; border: 0; border-top: 1px solid #eee; resize: vertical; padding: 10px 1.5rem; font: 13px/1.5 var(--mono); background: #fff; color: #212529; }
.divider { width: 8px; flex-shrink: 0; background: var(--line-2); cursor: col-resize; position: relative; transition: background .2s; }
.divider::after { content: ""; position: absolute; top: 50%; left: 2px; width: 4px; height: 30px; margin-top: -15px; background: #999; border-radius: 2px; }
.divider:hover, .divider.dragging { background: var(--divider); }
.divider:hover::after, .divider.dragging::after { background: #fff; }
.divider .hint { display: none; position: absolute; top: calc(50% + 24px); left: 50%; transform: translateX(-50%); background: rgba(0,0,0,.7); color: #fff;
padding: 4px 8px; border-radius: 4px; font-size: 12px; white-space: nowrap; pointer-events: none; z-index: 5; }
.divider:hover .hint { display: block; }
body.dragging, body.dragging * { cursor: col-resize !important; user-select: none; }
body.dragging #term { pointer-events: none; } /* an iframe swallows the mouse: let the divider keep it */
.vnc { flex: 1; min-width: 300px; background: #000; position: relative; display: flex; }
.vnc-controls { position: absolute; top: 10px; right: 10px; z-index: 3; display: flex; gap: 8px; opacity: .7; transition: opacity .2s; }
.vnc-controls:hover { opacity: 1; }
.vnc-controls button { width: 34px; height: 30px; display: grid; place-items: center; background: rgba(255,255,255,.2); border: 1px solid rgba(255,255,255,.3); color: #fff; border-radius: .25rem; cursor: pointer; }
#vncBody { flex: 1; display: flex; }
#term { flex: 1; width: 100%; height: 100%; border: 0; background: #000; }
#noterm { color: #bbb; padding: 32px; font-family: var(--mono); font-size: 13px; line-height: 1.7; max-width: 70ch; }
#noterm code { color: var(--yellow); }
/* ---- results page ---- */
#results { min-height: 100vh; background: #f4f6f9; padding: 24px 16px 40px; }
.res-actions { max-width: 1000px; margin: 0 auto 18px; display: flex; flex-wrap: wrap; gap: 10px; }
.res-wrap { max-width: 1000px; margin: 0 auto; background: #fff; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,.06); padding: 28px 30px; }
.res-header { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; border-bottom: 1px solid #eee; padding-bottom: 14px; margin-bottom: 20px; }
.res-header h2 { margin: 0; font-size: 26px; color: #2c3e50; }
.res-header .meta { color: #666; font-size: 14px; text-align: right; }
.summary { display: flex; align-items: center; justify-content: space-between; gap: 20px; flex-wrap: wrap; background: #f9f9f9; border-radius: 8px; padding: 18px 22px; margin-bottom: 20px; }
.summary h3 { margin: 0 0 8px; font-size: 16px; color: #333; font-weight: 600; }
.score-row { display: flex; align-items: center; gap: 16px; }
.score-box { background: #2c3e50; color: #fff; font-size: 22px; font-weight: 700; padding: 8px 18px; border-radius: 4px; font-variant-numeric: tabular-nums; }
.pct { font-size: 30px; font-weight: 800; color: #2c3e50; }
.rank-badge { padding: 8px 16px; border-radius: 4px; font-weight: 700; color: #fff; }
.rank-high { background: var(--ok); } .rank-low { background: var(--bad); }
.bars { margin: 0 0 18px; }
.bar { display: grid; grid-template-columns: 76px 1fr 64px; gap: 10px; align-items: center; margin: 6px 0; font-size: 14px; }
.bar .track { height: 8px; background: #e9ecef; border-radius: 5px; overflow: hidden; }
.bar .fill { height: 100%; background: var(--indigo); border-radius: 5px; }
.bar span:first-child { font-family: var(--mono); }
#resPath { font-size: 14px; margin-bottom: 20px; }
#resPath .milestone { color: var(--green); }
#resPath code { background: var(--code-bg); padding: 1px 5px; border-radius: 3px; color: var(--code); }
.qcard { background: #f2f4f8; border-radius: 8px; margin-bottom: 14px; overflow: hidden; }
.qcard-h { background: #e9ecef; padding: 11px 16px; display: flex; justify-content: space-between; gap: 12px; align-items: baseline; }
.qcard-h h3 { margin: 0; font-size: 16px; color: #2c3e50; }
.qcard-h .qs { font-weight: 700; white-space: nowrap; font-variant-numeric: tabular-nums; }
.qcard.t-pass .qs { color: var(--ok); } .qcard.t-partial .qs { color: #d68910; } .qcard.t-fail .qs { color: var(--bad); }
.vitems { list-style: none; margin: 0; padding: 0 16px; }
.vitem { display: flex; justify-content: space-between; gap: 12px; background: #fff; padding: 9px 12px; border-bottom: 1px solid #eef0f3; font-size: 14px; }
.vitems .vitem:first-child { margin-top: 12px; border-radius: 6px 6px 0 0; }
.vitems .vitem:last-child { border-radius: 0 0 6px 6px; border-bottom: 0; }
.vstat { font-weight: 700; } .vstat.ok { color: var(--ok); } .vstat.no { color: var(--bad); }
.qcard details.fold { margin: 12px 16px 16px; background: #fff; }
.qcard .vitems + .pad { height: 14px; }
/* history */
.hist { width: 100%; border-collapse: collapse; font-size: 14px; }
.hist th, .hist td { text-align: left; padding: 6px 8px; border-bottom: 1px solid #eee; vertical-align: top; }
.hist th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
.answers h4 { margin: 18px 0 6px; font-size: 15px; } .answers h4:first-child { margin-top: 0; }
.answers pre { margin: 0; white-space: pre-wrap; font-size: 12.5px; line-height: 1.55; background: #f6f8fa; border: 1px solid var(--line); padding: 12px; border-radius: 6px; }
@media (prefers-reduced-motion: reduce) { .progress span, .spinner, .timer.warning { animation: none; } }
@media (max-width: 900px) { .divider, .vnc { display: none; } .qpanel { width: 100% !important; max-width: none; } .xmenus .btn-custom { margin-left: .25rem; padding: .3rem .5rem; } }
@media (max-width: 640px) { .navbar { flex-wrap: wrap; padding: 10px 16px; } .card { padding: 22px 18px; } .picker { columns: 1; } .res-wrap { padding: 20px 16px; } }
</style>
</head>
<body>
<!-- ================= landing ================= -->
<div id="landing">
<nav class="navbar">
<span class="brand">kubestronaut-sim</span>
<span class="tpills" id="trackPick"></span>
<span class="spacer"></span>
<button class="navlink hidden" id="navResults">View results</button>
<button class="navlink" id="navHistory">History</button>
<span class="ver" id="ver"></span>
</nav>
<section class="hero">
<h1 id="heroTitle">Kubernetes exam simulator</h1>
<p class="lead" id="heroSub"></p>
<button class="btn-hero" id="btnStartExam">Start exam</button>
<label class="hardswitch" title="killer.sh-style: more tasks in the same time, no hints, traps, exact-output tasks, compound failures, collateral checks">
<input type="checkbox" id="hardHero"> <b>Hard mode</b> <span>· killer.sh-style</span></label>
<div class="lastline" id="lastLine"></div>
<div class="scrollhint">SCROLL FOR YOUR PATH AND OBJECTIVES</div>
</section>
<main class="wrap">
<div class="alert alert-danger hidden" id="lobbyErr"></div>
<div class="alert alert-info hidden" id="lobbyNote"></div>
<section class="card" id="pathCard">
<h2 class="section-title">Your path</h2>
<p class="section-sub">One objective at a time against the clock, then a mock exam, then the fixed simulator sets.</p>
<div class="ladder" id="ladder"></div>
<div class="pathgo"><button class="btn btn-primary" id="pathGo">Continue</button><span class="why" id="pathWhy"></span></div>
</section>
<section class="card" id="domainCard">
<h2 class="section-title">Drill a domain</h2>
<p class="section-sub">Five tasks of one domain, against the clock. ✓ drilled at least once, » marked known.</p>
<div class="dpills" id="coverage"></div>
<div class="gate hidden" id="gateNote"></div>
</section>
<section class="card" id="objCard">
<h2 class="section-title">Objectives</h2>
<p class="section-sub">The curriculum in its own words. Open an objective for what to know, the fastest commands, the traps and the docs you may open in the exam.</p>
<div class="legend" id="objLegend">✓ complete: every task passed against the clock. - started. » marked known, off the path.</div>
<div id="objList"></div>
</section>
</main>
<footer class="foot" id="foot"></footer>
</div>
<!-- ================= exam screen ================= -->
<div id="exam" class="hidden">
<header class="xhead">
<div class="xtitlebox"><span class="xtitle" id="xTitle"></span><span class="timer" id="timer">--:--</span></div>
<div class="xmenus">
<div class="dd">
<button class="btn-custom dd-toggle" aria-expanded="false">Exam Interface</button>
<div class="dd-menu">
<button class="dd-item" data-act="fullscreen">Toggle fullscreen</button>
<button class="dd-item" data-act="resetPanels">Reset panel sizes</button>
<div class="dd-sep"></div>
<button class="dd-item" data-act="reconnect">Reconnect the remote desktop</button>
</div>
</div>
<div class="dd">
<button class="btn-custom dd-toggle" aria-expanded="false">Exam Controls</button>
<div class="dd-menu" id="mnControls"></div>
</div>
<div class="dd">
<button class="btn-custom dd-toggle" aria-expanded="false">Exam Information</button>
<div class="dd-menu">
<a class="dd-item" href="https://docs.linuxfoundation.org/tc-docs/certification" target="_blank" rel="noopener">Exam instructions</a>
<a class="dd-item" href="https://kubernetes.io/docs/home/" target="_blank" rel="noopener">Kubernetes documentation</a>
<div class="dd-sep"></div>
<button class="dd-item" data-act="history">Attempt history</button>
</div>
</div>
</div>
</header>
<div class="xmain">
<aside class="qpanel" id="qpanel">
<div class="qnav">
<button class="nav-arrow" id="qPrev" aria-label="Previous question">&lt;</button>
<div class="dd qselect">
<button class="dd-toggle" id="qDrop" aria-expanded="false">Question 1</button>
<div class="dd-menu left" id="qMenu"></div>
</div>
<button class="nav-arrow" id="qNext" aria-label="Next question">&gt;</button>
</div>
<div class="qcontent" id="qContent"></div>
<div class="qactions">
<button class="btn btn-outline-warning" id="btnFlag"></button>
<button class="btn btn-success" id="btnSatisfied">Satisfied with answer <span aria-hidden="true">→</span></button>
</div>
<details class="notepad" id="notesWrap"><summary>Notepad - kept with the attempt, not graded</summary><textarea id="notes" placeholder="Your notes"></textarea></details>
</aside>
<div class="divider" id="divider" role="separator" aria-orientation="vertical"><span class="hint">⟷ drag to resize</span></div>
<section class="vnc" id="vnc">
<div class="vnc-controls">
<button data-act="reconnect" title="Reconnect to the remote desktop" aria-label="Reconnect">⟳</button>
<button data-act="desktopFullscreen" title="Remote desktop fullscreen" aria-label="Fullscreen">⛶</button>
</div>
<div id="vncBody"></div>
</section>
</div>
</div>
<!-- ================= results ================= -->
<div id="results" class="hidden">
<div class="res-actions">
<button class="btn btn-secondary" data-act="dashboard">Dashboard</button>
<button class="btn btn-primary" id="btnDrillMissed">Drill what I missed</button>
<button class="btn btn-success" data-act="answers">View answers</button>
<button class="btn btn-outline-primary" data-act="review">Review tasks</button>
<button class="btn btn-outline-primary" data-act="history">History</button>
<button class="btn btn-danger" data-act="terminate">Delete the hosts</button>
</div>
<div class="res-wrap">
<div class="res-header"><h2 id="resTitle">Results</h2><div class="meta" id="resMeta"></div></div>
<div class="summary">
<div><h3>Points scored</h3><div class="score-row"><span class="score-box"><span id="resEarned">0</span> / <span id="resTotal">0</span></span><span class="pct" id="resPct"></span></div></div>
<span class="rank-badge" id="resBadge"></span>
</div>
<div class="bars" id="resDomains"></div>
<div id="resPath"></div>
<div id="resQuestions"></div>
</div>
</div>
<!-- ================= dialogs ================= -->
<div class="modal-back hidden" id="mPicker">
<div class="modal">
<div class="mh"><h5>Select your session</h5><button class="x" data-close="#mPicker" aria-label="Close">×</button></div>
<div class="mb">
<div class="field"><label class="lbl" for="pkTrack">Certification</label><select class="select" id="pkTrack"></select></div>
<div class="field"><label class="lbl" for="pkSession">Session</label><select class="select" id="pkSession"></select></div>
<div class="field hidden" id="pkObjRow"><label class="lbl" for="pkObjective">Objective</label><select class="select" id="pkObjective"></select></div>
<div class="field hidden" id="pkDomRow"><label class="lbl" for="pkDomain">Domain</label><select class="select" id="pkDomain"></select></div>
<div class="field hidden" id="pkPickRow"><label class="lbl">Tasks</label><div class="picker" id="pkTasks"></div></div>
<label class="check field"><input type="checkbox" id="hardPicker"> <span><b>Hard mode</b> <span class="muted">- killer.sh-style: more tasks in the same time, no hints, traps, exact-output tasks, compound failures, collateral checks.</span></span></label>
<div class="alert alert-warning hidden" id="pkWarn"></div>
<div class="alert alert-info mb0" id="pkDesc"></div>
<div class="alert alert-danger hidden" id="pkErr" style="margin-top:1rem"></div>
</div>
<div class="mf"><button class="btn btn-secondary" data-close="#mPicker">Cancel</button><button class="btn btn-primary" id="pkStart">Start exam</button></div>
</div>
</div>
<div class="modal-back hidden" id="mPrep">
<div class="loading-card">
<h2>Preparing your exam environment</h2>
<div class="progress"><span></span></div>
<div class="loading-msg" id="prepMsg">starting…</div>
<details><summary>Show the full log</summary><pre class="log" id="prepLog"></pre></details>
<div class="note">Every task gets its own cluster: a drill is ready in about a minute, a full exam in three or four. The exam screen opens by itself when it is done.</div>
</div>
</div>
<div class="modal-back hidden" id="mStart">
<div class="modal">
<div class="mh"><h5 id="startTitle">Ready to begin your exam</h5></div>
<div class="mb" id="startBody"></div>
<div class="mf"><button class="btn btn-primary" id="btnBegin">Start</button></div>
</div>
</div>
<div class="modal-back hidden" id="mBusy">
<div class="modal">
<div class="mh"><h5 id="busyTitle">Grading</h5></div>
<div class="mb"><div class="spinner"></div><div id="busyBody"></div></div>
</div>
</div>
<div class="modal-back hidden" id="mAsk">
<div class="modal">
<div class="mh"><h5 id="askTitle"></h5><button class="x" id="askX" aria-label="Close">×</button></div>
<div class="mb" id="askBody"></div>
<div class="mf"><button class="btn btn-secondary" id="askCancel">Cancel</button><button class="btn btn-primary" id="askOk">OK</button></div>
</div>
</div>
<div class="modal-back hidden" id="mHistory">
<div class="modal wide">
<div class="mh"><h5>Attempt history</h5><button class="x" data-close="#mHistory" aria-label="Close">×</button></div>
<div class="mb"><div class="muted" id="histSub"></div><div id="histBody"></div></div>
<div class="mf"><button class="btn btn-secondary" data-close="#mHistory">Close</button></div>
</div>
</div>
<div class="modal-back hidden" id="mAnswers">
<div class="modal wide">
<div class="mh"><h5>Answers</h5><button class="x" data-close="#mAnswers" aria-label="Close">×</button></div>
<div class="mb answers" id="answersBody"></div>
<div class="mf"><button class="btn btn-secondary" data-close="#mAnswers">Close</button></div>
</div>
</div>
<script>
(() => {
const $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)];
let S = null, cur = null, skew = 0, clockT = null, autoSubmitted = false, view = 'landing', polling = false, hard = false;
try { hard = localStorage.getItem('ksim.hard') === '1'; } catch (e) { /* storage blocked: start without it */ }
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 sleep = ms => new Promise(r => setTimeout(r, ms));
const pad = n => String(Math.floor(n)).padStart(2, '0');
const fmtMS = s => { s = Math.max(0, Math.floor(s)); return `${pad(s / 60)}:${pad(s % 60)}`; }; // as the exam shows it: 120:00
const fmtHMS = s => { s = Math.max(0, Math.floor(s)); return [s / 3600, s % 3600 / 60, s % 60].map(pad).join(':'); };
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c]));
const oldServer = what => `This ./ksim web was started before "${what}" existed. Stop it (Ctrl-C) and start it again.`;
const openModal = id => $(id).classList.remove('hidden');
const closeModal = id => $(id).classList.add('hidden');
const live = () => !!(S && S.session && !S.session.ended_at);
// minimal markdown: paragraphs, **bold**, `code` (click to copy). Lists stay as literal lines.
const render = md => md.split(/\n\s*\n/).map(p => '<p>' + esc(p).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
.replace(/`([^`]+)`/g, (m, c) => `<code class="ic" title="click to copy">${c}</code>`) + '</p>').join('');
// a click copies to the remote desktop's clipboard (paste there with Ctrl+Shift+V) and to yours
function copyable(root) {
root.querySelectorAll('code.ic').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(() => {});
});
}
// ---- 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>`;
}
const undoneDomains = () => Object.keys(S.track.domains).filter(d => !(S.drilled || {})[d] && !(S.known || []).includes(d));
function nextDomain() {
const drilled = S.drilled || {}, skipped = S.known || [], all = Object.keys(S.track.domains);
const open = all.filter(d => !skipped.includes(d));
return (open.length ? open : all).sort((a, b) => (drilled[a] || 0) - (drilled[b] || 0))[0];
}
function setHard(v) {
hard = !!v; $('#hardHero').checked = hard; $('#hardPicker').checked = hard;
try { localStorage.setItem('ksim.hard', hard ? '1' : '0'); } catch (e) { /* not kept: fine */ }
if (!$('#mPicker').classList.contains('hidden')) pickerChanged();
}
// ---- which screen ----
async function load() {
S = await api('/api/session');
if (S.session) skew = Date.now() / 1000 - S.session.server_time;
route();
if (S.seeding && !polling) resumeSeeding(); // the page was reloaded while a session was being prepared
}
function route() {
if (live()) view = 'exam';
else if (view !== 'landing' && !(S.session && S.session.ended_at && !S.session.aborted)) view = 'landing';
$('#landing').classList.toggle('hidden', view !== 'landing');
$('#exam').classList.toggle('hidden', view !== 'exam');
$('#results').classList.toggle('hidden', view !== 'results');
if (view === 'landing') paintLanding();
else if (view === 'exam') paintExam();
else paintResults();
}
const go = v => { view = v; window.scrollTo(0, 0); route(); };
function lobbyError(msg) { $('#lobbyErr').textContent = msg; $('#lobbyErr').classList.remove('hidden'); $('#lobbyNote').classList.add('hidden'); go('landing'); $('#lobbyErr').scrollIntoView({block: 'center'}); }
function lobbyNote(msg) { $('#lobbyNote').textContent = msg; $('#lobbyNote').classList.remove('hidden'); }
// ================= landing =================
function paintLanding() {
const T = S.track, sess = S.session;
document.title = `${S.track_id.toUpperCase()} · Kubestronaut exam simulator`;
$('#trackPick').innerHTML = (S.tracks || []).map(t =>
`<button class="tpill ${t === S.track_id ? 'sel' : ''}" data-track="${t}" title="Kubernetes ${(S.track_k8s || {})[t] || '?'}">${t.toUpperCase()}<span>${(S.track_k8s || {})[t] || ''}</span></button>`).join('');
$$('#trackPick [data-track]').forEach(el => el.onclick = () => switchTrack(el.dataset.track));
$('#ver').textContent = 'ksim ' + (S.version || '?') + (S.pending ? ` · ${S.pending} task(s) held back` : '');
$('#foot').textContent = `ksim ${S.version || '?'} · Apache-2.0 · not affiliated with the CNCF or the Linux Foundation`;
$('#heroTitle').textContent = T.name;
$('#heroSub').textContent = `Kubernetes ${T.k8s}. Every task runs on its own cluster that you reach with ssh from a remote desktop, and every session runs against the clock.`;
$('#hardHero').checked = hard;
const ended = sess && sess.ended_at;
$('#navResults').classList.toggle('hidden', !(ended && !sess.aborted));
$('#lastLine').innerHTML = !ended ? 'Every task gets its own cluster: a drill is ready in about a minute, a full exam in three or four.'
: sess.aborted ? 'Your last session was terminated: nothing was graded or recorded.'
: `Your last ${sess.mode === 'drill' ? 'drill' : 'exam'} ended with ${sess.score.pct}%. <a href="#" id="lastResults">View its results</a>`;
const lr = $('#lastResults'); if (lr) lr.onclick = e => { e.preventDefault(); go('results'); };
paintPath(); paintDomains();
}
function paintPath() {
const P = S.path;
$('#pathCard').classList.toggle('hidden', !P); $('#objCard').classList.toggle('hidden', !P);
if (!P) return;
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 `<div class="rung${here ? ' here' : ''}"><span class="step">${here ? '→' : i + 1}</span><span>${esc(name)}</span><span class="where">${esc(where)}</span></div>`;
}).join('');
$('#pathGo').textContent = 'Continue · ' + P.next.cmd.replace('./ksim ', '');
$('#pathWhy').textContent = P.next.why + '.';
// the objective list: notes, tasks, and what you said you know
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))];
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="objrow"><button class="btn btn-primary btn-sm" data-drill="${o.id}">Against the clock</button></div>
</details>`).join('')).join('');
$$('#objList [data-drill]').forEach(b => b.onclick = () => start('drill', null, false, false, null, b.dataset.drill));
$$('#objList a.touch').forEach(a => a.onclick = e => { e.preventDefault(); start('drill', null, false, false, null, null, [[a.dataset.touch, 3]]); });
$$('#objList a.known').forEach(a => a.onclick = async e => {
e.preventDefault();
try { S = await api('/api/known', {mark: a.dataset.known, known: a.dataset.on === '1'}); route(); }
catch (err) { alert(err.message === 'not found' ? oldServer('skip') : err.message); }
});
}
function paintDomains() {
const drilled = S.drilled || {}, skipped = S.known || [];
$('#coverage').innerHTML = Object.keys(S.track.domains).map(d => {
const st = drilled[d] ? 'done' : skipped.includes(d) ? 'known' : '';
return `<button class="dpill ${st}" data-domain="${d}" title="Drill ${esc(S.track.domains[d])} — 5 tasks${skipped.includes(d) ? ' (you marked this domain as known)' : ''}"><b>${d}</b><span class="mk">${drilled[d] ? '✓' : skipped.includes(d) ? '»' : '·'}</span><span>${esc(S.track.domains[d])}</span></button>`;
}).join('');
$$('#coverage [data-domain]').forEach(el => el.onclick = () => start('drill', null, false, false, el.dataset.domain));
const u = undoneDomains();
$('#gateNote').classList.toggle('hidden', !u.length);
$('#gateNote').textContent = u.length ? `Drill every domain once before the full exam. Not yet drilled: ${u.join(', ')}.` : '';
}
async function switchTrack(t) {
if (t === S.track_id) return;
try { S = await api('/api/track', {track: t}); cur = null; $('#lobbyErr').classList.add('hidden'); $('#lobbyNote').classList.add('hidden'); route(); return true; }
catch (e) { alert(e.message); return false; }
}
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');
}
// ---- the session picker ----
function sessionOptions() {
const o = [];
if (S.path) o.push(['path', `Continue the path — ${S.path.next.cmd.replace('./ksim ', '')}`]);
if ((S.objectives || []).length) o.push(['objective', 'Drill an objective']);
o.push(['domain', 'Drill a domain'], ['next', `Drill the next domain — ${S.track.domains[nextDomain()]}`],
['all', 'Drill the whole bank'], ['pick', 'Pick the tasks to drill']);
Object.entries(S.sets || {}).forEach(([k, info]) => o.push(['sim:' + k, info.name]));
o.push(['exam', 'Full exam']);
return o;
}
function openPicker(keep) {
$('#pkTrack').innerHTML = (S.tracks || []).map(t => `<option value="${t}"${t === S.track_id ? ' selected' : ''}>${t.toUpperCase()} — Kubernetes ${(S.track_k8s || {})[t] || '?'}</option>`).join('');
const opts = sessionOptions();
$('#pkSession').innerHTML = opts.map(([v, l]) => `<option value="${v}">${esc(l)}</option>`).join('');
$('#pkSession').value = opts.some(([v]) => v === keep) ? keep : opts[0][0];
$('#pkObjective').innerHTML = (S.objectives || []).map(o => `<option value="${o.id}">${MARK[o.state] || '·'} ${o.id} ${esc(o.text)}</option>`).join('');
if (S.path && S.path.next.objective) $('#pkObjective').value = S.path.next.objective;
$('#pkDomain').innerHTML = Object.entries(S.track.domains).map(([d, n]) => `<option value="${d}">${d} — ${esc(n)}${(S.drilled || {})[d] ? ' ✓' : (S.known || []).includes(d) ? ' »' : ''}</option>`).join('');
$('#pkTasks').dataset.filled = '';
$('#hardPicker').checked = hard; $('#pkErr').classList.add('hidden');
openModal('#mPicker'); pickerChanged();
}
async function pickerChanged() {
const v = $('#pkSession').value, T = S.track, per = hard ? 5 : 6;
$('#pkObjRow').classList.toggle('hidden', v !== 'objective');
$('#pkDomRow').classList.toggle('hidden', v !== 'domain');
$('#pkPickRow').classList.toggle('hidden', v !== 'pick');
let desc = '', warn = '';
if (v === 'path') desc = S.path.next.why + '.';
else if (v === 'objective') desc = `Its tasks that have not passed against the clock yet, up to five in a sitting, ${per} minutes each. Feedback per task while you work; a task's solution once it is graded.`;
else if (v === 'domain') desc = `Five tasks of that domain, ${per} minutes each, the ones you have seen least recently first. Feedback per task while you work.`;
else if (v === 'next') desc = `${T.domains[nextDomain()]} — 5 tasks, ${per} min each. Drills rotate through all domains before the exam.`;
else if (v === 'all') {
const el = S.bank.filter(q => hard || q.difficulty !== 'hard'), seen = el.filter(q => (S.seen || []).includes(q.id)).length;
desc = `${seen}/${el.length} tasks attempted. 5 unseen tasks per session, round-robin across domains${hard ? ', hard first' : ''}; then whatever you failed.`;
} else if (v === 'pick') { desc = `Ticked: the tasks you are weak on and those never attempted. ${per} minutes per task, feedback per task while you work.`; fillTaskPicker(); }
else if (v.startsWith('sim:')) { const info = S.sets[v.slice(4)]; desc = `${info.count} fixed tasks, ${T.minutes} minutes, exam rules: no feedback until you submit.`; }
else if (v === 'exam') {
const qn = hard ? (T.quota_hard || {}) : (T.quota || {}), total = Object.values(qn).reduce((a, b) => a + b, 0) || S.bank.length;
desc = `${total} tasks sampled across the ${Object.keys(T.domains).length} domains, ${T.minutes} minutes, pass mark ${T.pass_pct}%. No feedback until you submit.`;
const u = undoneDomains();
if (u.length) warn = `Drill every domain once before the full exam - that is the point of the drills. Not yet drilled: ${u.join(', ')}. You can start anyway.`;
}
$('#pkDesc').textContent = desc;
$('#pkWarn').textContent = warn; $('#pkWarn').classList.toggle('hidden', !warn);
$('#pkStart').textContent = warn ? 'Start anyway' : 'Start exam';
}
async function fillTaskPicker() {
const box = $('#pkTasks'), want = hard ? 'hard' : 'normal';
if (box.dataset.filled === want) return;
box.dataset.filled = want; box.textContent = 'loading…';
let weak = [];
try { weak = (await api('/api/history')).weak; } catch (e) { /* no history: nothing preselected */ }
box.innerHTML = S.bank.filter(q => hard || q.difficulty !== 'hard').map(q =>
`<label><input type="checkbox" value="${q.id}" ${weak.includes(q.id) ? 'checked' : ''}> Q${q.id} ${q.domain} ${esc(q.title)}${q.difficulty === 'hard' ? '<span class="hardtag">hard</span>' : ''}</label>`).join('');
}
function pickerStart() {
const v = $('#pkSession').value;
if (v === 'path') return continuePath();
if (v === 'objective') return start('drill', null, false, false, null, $('#pkObjective').value);
if (v === 'domain') return start('drill', null, false, false, $('#pkDomain').value);
if (v === 'next') return start('drill', null, true);
if (v === 'all') return start('drill', null, false, true);
if (v === 'pick') {
const ids = $$('#pkTasks input:checked').map(i => +i.value);
if (!ids.length) { $('#pkErr').textContent = 'Tick at least one task.'; $('#pkErr').classList.remove('hidden'); return; }
return start('drill', ids);
}
if (v.startsWith('sim:')) return startSim(v.slice(4));
return start('exam');
}
// ---- starting a session: seeding runs in the background, its log streams into the overlay ----
async function waitSeeded() {
for (;;) {
await sleep(1200);
const s = await api('/api/session');
const lines = (s.setup_log || []).map(l => l.replace(/\x1b\[[0-9;]*m/g, ''));
$('#prepLog').textContent = lines.join('\n') || 'starting…';
$('#prepLog').scrollTop = $('#prepLog').scrollHeight;
$('#prepMsg').textContent = (lines.filter(l => l.trim()).slice(-1)[0] || 'starting…').trim();
if (!s.seeding) { if (s.seed_error) throw new Error(s.seed_error); S = s; return; }
}
}
async function launch(body) {
closeModal('#mPicker'); $('#lobbyErr').classList.add('hidden'); $('#lobbyNote').classList.add('hidden');
$('#prepMsg').textContent = 'starting…'; $('#prepLog').textContent = ''; openModal('#mPrep'); polling = true;
try { await api('/api/start', body); await waitSeeded(); ready(true); }
catch (e) { lobbyError(e.message === 'Failed to fetch' ? 'Lost contact with ./ksim web — is it still running? Restart it and reload this page.' : e.message); }
finally { closeModal('#mPrep'); polling = false; }
}
const start = (mode, ids, next, campaign, domain, objective, touch) =>
launch({mode, ids, next: !!next, campaign: !!campaign, domain: domain || null, objective: objective || null, touch: touch || null, hard});
const startSim = name => launch({mode: 'exam', sim: name, hard: true});
async function resumeSeeding() {
polling = true; openModal('#mPrep');
try { await waitSeeded(); if (live()) ready(false); else route(); }
catch (e) { lobbyError(e.message); }
finally { closeModal('#mPrep'); polling = false; }
}
function ready(fresh) {
skew = Date.now() / 1000 - S.session.server_time; autoSubmitted = false; $('#vnc').dataset.ready = '';
cur = S.questions[0] && S.questions[0].id; go('exam');
if (!fresh) return;
// as on the day: the clock starts when you say so, not when the last cluster came up
const sess = S.session, drill = sess.mode === 'drill';
$('#startTitle').textContent = drill ? 'Ready to begin your drill' : 'Ready to begin your exam';
$('#startBody').innerHTML = `<div class="alert alert-success"><b>Important:</b> treat this as the real exam - work accurately and against the clock.</div>
<p>When you click <b>Start</b>:</p>
<ul>
<li>the clock starts from ${fmtMS(sess.duration)};</li>
<li>the page goes fullscreen (Esc leaves it);</li>
<li>every question names its host: <code class="ic">ssh host</code> in the desktop's terminal, then <code class="ic">sudo -i</code> when you need root;</li>
<li>clicking a <code class="ic">highlighted</code> value copies it - paste in the desktop with Ctrl+Shift+V;</li>
${drill ? '<li><b>Check this task</b> grades a task per check, and its reference solution opens once it is graded.</li>'
: '<li>no feedback until you submit, as on the day.</li>'}
</ul><p class="mb0">${S.questions.length} task(s). Are you ready to start?</p>`;
openModal('#mStart');
}
// ================= exam screen =================
const FLAG = '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2.5 1a.5.5 0 0 1 .5.5v.3c.9-.35 2.1-.8 3.5-.8 1.6 0 2.6.9 4 .9 1.1 0 2.1-.35 3.1-.75a.5.5 0 0 1 .7.46V8a.5.5 0 0 1-.31.46C12.9 8.9 11.8 9.4 10.5 9.4c-1.6 0-2.6-.9-4-.9-1.3 0-2.6.5-3.5.85V15a.5.5 0 0 1-1 0V1.5a.5.5 0 0 1 .5-.5z"/></svg>';
function resultClass(q) {
const sess = S.session, r = sess && sess.results[q.id];
if (!r || !(sess.mode === 'drill' || sess.ended_at)) return '';
return r.pass ? 'pass' : (r.earned ? 'partial' : 'fail');
}
function paintMenus() {
const sess = S.session;
$('#mnControls').innerHTML = live()
? `<button class="dd-item" data-act="submit">Submit ${sess.mode === 'drill' ? 'drill' : 'exam'}</button>
<button class="dd-item" data-act="pause">${sess.paused ? 'Resume the clock' : 'Pause the clock'}</button>
<button class="dd-item" data-act="restart">Restart clock</button>
<div class="dd-sep"></div>
<button class="dd-item danger" data-act="terminate">Terminate session</button>`
: `<button class="dd-item" data-act="results">Back to the results</button>
<button class="dd-item" data-act="dashboard">Back to the dashboard</button>
<div class="dd-sep"></div>
<button class="dd-item danger" data-act="terminate">Delete the hosts</button>`;
}
function paintExam() {
const sess = S.session, on = live();
$('#xTitle').textContent = `${S.track_id.toUpperCase()} ${on ? (sess.mode === 'drill' ? 'Drill' : 'Exam') : 'Review'}`
+ (sess.hard ? ' · HARD' : '') + (sess.objective ? ' · objective ' + sess.objective : '');
document.title = `${$('#xTitle').textContent} · Kubestronaut exam simulator`;
paintMenus();
if (!S.questions.find(q => q.id === cur)) cur = S.questions[0] && S.questions[0].id;
const q = S.questions.find(x => x.id === cur), i = S.questions.indexOf(q), n = S.questions.length;
if (!q) return;
// < Question N > and the list behind it
$('#qDrop').textContent = `Question ${i + 1} of ${n}`;
$('#qPrev').disabled = i <= 0; $('#qNext').disabled = i >= n - 1;
$('#qMenu').innerHTML = S.questions.map((x, j) => `<button class="dd-item ${resultClass(x)}" data-q="${x.id}">Question ${j + 1} · Q${x.id} ${esc(x.title)}${sess.flags.includes(x.id) ? ' <span class="flag">⚑</span>' : ''}</button>`).join('');
$$('#qMenu [data-q]').forEach(b => b.onclick = () => { cur = +b.dataset.q; closeMenus(); paintExam(); });
// the task, headed by its host as the exam prints it
const r = sess.results[q.id], show = r && (sess.mode === 'drill' || sess.ended_at);
const oids = q.objectives || [], oid = oids.includes(sess.objective) ? sess.objective : oids[0];
const obj = oid && (S.objectives || []).find(o => o.id === oid); // which objective a task belongs to is a hint: it follows the solution's rule
$('#qContent').innerHTML = `
<div class="qhead">
<div class="line"><b>Solve this question on instance:</b> <code class="ic" title="click to copy">ssh ${esc(q.host)}</code></div>
<div class="line sub">${q.nodes}-node cluster · task files in ${esc(S.workspace)} on that host</div>
<div class="line"><b>Domain:</b> <span class="tp">${esc(S.track.domains[q.domain])}</span></div>
<div class="line"><b>Points:</b> <span class="tp">${q.weight}</span>${q.difficulty === 'hard' ? '<span class="hardtag">hard</span>' : ''}</div>
${obj ? `<div class="line"><b>Objective:</b> <span class="tp">${obj.id} ${esc(obj.text)}</span></div>` : ''}
</div>
<hr>
<h2 class="qtitle">${esc(q.title)}</h2>
<div class="qbody">${render(q.task)}</div>
${on && sess.mode === 'drill' ? '<div class="checkrow"><button class="btn btn-outline-primary btn-sm" id="btnCheck">Check this task</button><span class="muted">grades it per check; its reference solution opens once it is graded</span></div>' : ''}
${show ? `<div class="checks"><b>${r.earned}/${r.total} points</b>${r.checks.map(c => `<div class="${c.pass ? 'ok' : 'no'}">${esc(c.name)} <span class="pts">(${c.pts})</span></div>`).join('')}</div>` : ''}
${q.solution ? `<details class="fold"><summary>Reference solution</summary><pre>${esc(q.solution)}</pre></details>` : ''}
${obj ? `<details class="fold"><summary>Objective ${obj.id} · ${esc(obj.text)}</summary><div class="notes">${notesHtml(obj)}</div></details>` : ''}`;
copyable($('#qContent'));
const bc = $('#btnCheck'); if (bc) bc.onclick = checkTask;
const fl = sess.flags.includes(q.id);
$('#btnFlag').className = 'btn ' + (fl ? 'btn-warning' : 'btn-outline-warning') + (on ? '' : ' hidden');
$('#btnFlag').innerHTML = `${FLAG} ${fl ? 'Flagged' : 'Flag for review'}`;
$('#btnSatisfied').disabled = i >= n - 1;
if (document.activeElement !== $('#notes')) $('#notes').value = sess.notes || '';
// the remote desktop
const vnc = $('#vnc');
if (!vnc.dataset.ready) {
vnc.dataset.ready = 1;
$('#vncBody').innerHTML = S.desktop ? `<iframe id="term" src="${esc(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();
}
async function checkTask() {
const b = $('#btnCheck'); b.disabled = true; b.textContent = 'Checking…';
try { S = await api('/api/grade', {id: cur}); paintExam(); }
catch (e) { alert(e.message); b.disabled = false; b.textContent = 'Check this task'; }
}
function step(d) { const i = S.questions.findIndex(q => q.id === cur), j = i + d; if (j >= 0 && j < S.questions.length) { cur = S.questions[j].id; paintExam(); $('#qContent').scrollTop = 0; } }
function tickClock() {
clearInterval(clockT);
const sess = S.session, el = $('#timer');
if (!sess || sess.ended_at) { el.textContent = sess ? 'ended' : '--:--'; el.className = 'timer'; return; }
const tick = () => {
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 ? '⏸ ' : '') + fmtMS(left);
el.className = 'timer' + (sess.paused ? ' paused' : left < 5 * 60 ? ' warning' : '');
if (sess.paused) return;
if (left <= 0 && !autoSubmitted) { autoSubmitted = true; clearInterval(clockT); submit(true); }
};
tick(); clockT = setInterval(tick, 1000);
}
async function submit(timeUp) {
closeMenus(); closeModal('#mAsk');
$('#busyTitle').textContent = timeUp ? 'Exam time complete' : 'Grading your exam';
$('#busyBody').innerHTML = timeUp
? `<div class="alert alert-info"><b>Time's up! ⏳ Your ${S.session.mode === 'drill' ? 'drill' : 'exam'} has ended.</b></div><p>Every task is now graded on its own host.</p><p class="mb0">The hosts stay up for review until the next session starts.</p>`
: '<p class="mb0">Every task is graded on its own host as it is right now…</p>';
openModal('#mBusy');
try { S = await api('/api/end', {}); }
catch (e) { closeModal('#mBusy'); alert(e.message); return; } // e.g. the hosts are down - nothing was graded or recorded
closeModal('#mBusy'); go('results');
}
async function confirmSubmit() {
const f = S.session.flags, open = S.questions.filter(q => !S.session.results[q.id]).length, kind = S.session.mode === 'drill' ? 'drill' : 'exam';
const ok = await ask({title: `End ${kind} confirmation`, ok: `End ${kind}`, okClass: 'btn-danger',
body: `<p>Are you sure you want to end the ${kind}? This cannot be undone.</p>
<div class="alert alert-success"><p><b>What happens next:</b></p><ul><li>Every task is graded on its own host as it is right now.</li><li>You see the results page.</li><li>The hosts stay up for review until the next session starts.</li></ul></div>
${f.length ? `<div class="alert alert-warning mb0">Flagged for review: Q${f.join(', Q')}</div>` : ''}
${S.session.mode === 'drill' && open ? `<p class="muted mb0" style="margin-top:.6rem">${open} task(s) not checked yet - they are graded now all the same.</p>` : ''}`});
if (ok) submit(false);
}
async function togglePause() {
S = await api('/api/pause', {paused: !S.session.paused}); skew = Date.now() / 1000 - S.session.server_time; route();
}
async function restartClock() {
const ok = await ask({title: 'Restart the clock', ok: 'Restart clock', body: `<p class="mb0">The clock goes back to ${fmtMS(S.session.duration)} and starts running. The hosts and your work stay as they are; like Pause, it is recorded nowhere.</p>`});
if (!ok) return;
try { S = await api('/api/restart', {}); skew = Date.now() / 1000 - S.session.server_time; autoSubmitted = false; route(); }
catch (e) { alert(e.message === 'not found' ? oldServer('restart') : e.message); }
}
async function terminate() {
const on = live();
const ok = await ask(on
? {title: 'Terminate session', ok: 'Terminate session', okClass: 'btn-danger',
body: `<div class="alert alert-warning"><p><b>Warning!</b> Terminating this session will:</p><ul><li>delete every host of this session right away;</li><li>end it without grading it - nothing is written to your history.</li></ul></div><p class="mb0">This cannot be undone. Terminate the session?</p>`}
: {title: 'Delete the hosts', ok: 'Delete the hosts', okClass: 'btn-danger',
body: '<p class="mb0">This deletes every host of the session you finished, to free the memory they hold. Your results and your history stay.</p>'});
if (!ok) return;
$('#busyTitle').textContent = on ? 'Terminating the session' : 'Deleting the hosts';
$('#busyBody').innerHTML = '<p class="mb0">Deleting every host of the session…</p>';
openModal('#mBusy');
try { S = await api('/api/terminate', {}); }
catch (e) { alert(e.message === 'not found' ? oldServer('Terminate session') : e.message); return; }
finally { closeModal('#mBusy'); }
if (on) { go('landing'); lobbyNote('Session terminated: nothing was graded or recorded, and its hosts are deleted.'); }
else { route(); alert('The hosts are deleted. Your results and your history stay.'); }
}
function toggleFullscreen() {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
else document.documentElement.requestFullscreen().catch(() => {});
}
function reconnect() { const f = $('#term'); if (f) f.src = S.desktop; }
// ================= results =================
function paintResults() {
const s = S.session, sc = s.score, pass = sc.pct >= sc.pass_pct;
document.title = `Results · Kubestronaut exam simulator`;
$('#resTitle').textContent = `${s.mode === 'drill' ? 'Drill' : 'Exam'} results`;
$('#resMeta').innerHTML = `${esc(S.track.name)}${s.hard ? ' · hard mode' : ''}${s.objective ? ' · objective ' + esc(s.objective) : ''}<br>`
+ `Completed ${new Date(s.ended_at * 1000).toLocaleString()}<br>Time used ${fmtHMS(s.ended_at - s.started_at - s.paused_total)}${s.over_time ? ' (over time)' : ''}`;
$('#resEarned').textContent = sc.earned; $('#resTotal').textContent = sc.total; $('#resPct').textContent = sc.pct + '%';
$('#resBadge').textContent = `${pass ? 'Pass' : 'Fail'} · pass mark ${sc.pass_pct}%`;
$('#resBadge').className = 'rank-badge ' + (pass ? 'rank-high' : 'rank-low');
$('#resDomains').innerHTML = Object.entries(s.domains).map(([d, x]) =>
`<div class="bar" title="${esc(S.track.domains[d] || d)}"><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('');
$('#resPath').innerHTML = !S.path ? '' : (s.milestones || []).map(m => `<div class="milestone">${esc(m)}</div>`).join('') +
`<div style="margin-top:6px"><b>Next on the path:</b> <code>${esc(S.path.next.cmd)}</code> <span class="muted">${esc(S.path.next.why)}</span></div>`;
$('#resQuestions').innerHTML = S.questions.map((q, i) => {
const r = s.results[q.id]; if (!r) return '';
const cls = r.pass ? 't-pass' : (r.earned ? 't-partial' : 't-fail');
return `<div class="qcard ${cls}"><div class="qcard-h"><h3>Question ${i + 1} · Q${q.id} ${esc(q.title)}</h3><span class="qs">${r.earned} of ${r.total}</span></div>
<ul class="vitems">${r.checks.map(c => `<li class="vitem"><span>${esc(c.name)} <span class="muted">(${c.pts})</span></span><span class="vstat ${c.pass ? 'ok' : 'no'}">${c.pass ? '✓' : '✗'}</span></li>`).join('')}</ul>
${q.solution ? `<details class="fold"><summary>Reference solution</summary><pre>${esc(q.solution)}</pre></details>` : '<div class="pad"></div>'}</div>`;
}).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 = () => start('drill', missed);
}
function showAnswers() {
const s = S.session;
$('#answersBody').innerHTML = S.questions.filter(q => q.solution).map((q, i) =>
`<h4>Question ${S.questions.indexOf(q) + 1} · Q${q.id} ${esc(q.title)}${s && s.results[q.id] ? ` <span class="muted">(${s.results[q.id].earned}/${s.results[q.id].total})</span>` : ''}</h4><pre>${esc(q.solution)}</pre>`).join('')
|| '<p class="muted">No reference solutions are open yet.</p>';
openModal('#mAnswers');
}
// ---- history ----
async function showHistory() {
const h = await api('/api/history');
$('#histSub').textContent = h.history.length ? `${h.history.length} attempt(s) on this track · * = hard mode` : 'No attempts yet.';
$('#histBody').innerHTML = (h.history.length ? `<table class="hist"><thead><tr><th>Session</th><th>Score</th><th>When</th><th>Time used</th><th>Tasks</th></tr></thead><tbody>` +
h.history.slice().reverse().map(e => `<tr><td>${esc(e.mode)}${e.hard ? '*' : ''}</td><td><b>${e.score_pct}%</b></td><td>${esc(e.at.replace('T', ' '))}</td><td>${fmtHMS(e.time_used)}</td><td>Q${e.ids.join(', Q')}</td></tr>`).join('') +
'</tbody></table>' : '') +
(h.weak.length ? `<p class="muted" style="margin-top:12px">Weak or unattempted: Q${h.weak.join(', Q')}</p>` : '');
openModal('#mHistory');
}
// ---- dialogs and menus ----
function ask({title, body, ok = 'OK', okClass = 'btn-primary', cancel = 'Cancel'}) {
return new Promise(res => {
$('#askTitle').textContent = title; $('#askBody').innerHTML = body;
const okb = $('#askOk'), cb = $('#askCancel');
okb.textContent = ok; okb.className = 'btn ' + okClass; cb.textContent = cancel;
const done = v => { closeModal('#mAsk'); okb.onclick = cb.onclick = $('#askX').onclick = null; res(v); };
okb.onclick = () => done(true); cb.onclick = () => done(false); $('#askX').onclick = () => done(false);
openModal('#mAsk'); okb.focus();
});
}
function closeMenus() { $$('.dd-menu.open').forEach(m => { m.classList.remove('open'); m.previousElementSibling.setAttribute('aria-expanded', 'false'); }); }
const ACT = {
submit: confirmSubmit, pause: togglePause, restart: restartClock, terminate, fullscreen: toggleFullscreen, reconnect,
resetPanels: () => { $('#qpanel').style.width = ''; try { localStorage.removeItem('ksim.split'); } catch (e) { /* fine */ } },
desktopFullscreen: () => { const f = $('#term'); if (f && f.requestFullscreen) f.requestFullscreen().catch(() => {}); },
history: showHistory, answers: showAnswers, results: () => go('results'), dashboard: () => go('landing'), review: () => go('exam'),
};
document.addEventListener('click', e => {
const t = e.target.closest('.dd-toggle');
if (t) { const m = t.nextElementSibling, open = !m.classList.contains('open'); closeMenus(); if (open) { m.classList.add('open'); t.setAttribute('aria-expanded', 'true'); } return; }
const a = e.target.closest('[data-act]');
if (a) { closeMenus(); Promise.resolve(ACT[a.dataset.act]()).catch(err => alert(err.message)); return; }
const c = e.target.closest('[data-close]');
if (c) { closeModal(c.dataset.close); return; }
if (!e.target.closest('.dd-menu')) closeMenus();
});
window.addEventListener('blur', closeMenus); // a click into the remote desktop
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
closeMenus();
if (!$('#mAsk').classList.contains('hidden')) $('#askCancel').click();
else ['#mPicker', '#mHistory', '#mAnswers'].forEach(closeModal);
});
// ---- wiring ----
$('#btnStartExam').onclick = () => openPicker();
$('#pathGo').onclick = continuePath;
$('#navResults').onclick = () => go('results');
$('#navHistory').onclick = () => showHistory().catch(e => alert(e.message));
$('#hardHero').onchange = e => setHard(e.target.checked);
$('#hardPicker').onchange = e => setHard(e.target.checked);
$('#pkSession').onchange = pickerChanged;
$('#pkTrack').onchange = async e => { const keep = $('#pkSession').value; if (await switchTrack(e.target.value)) openPicker(keep); else e.target.value = S.track_id; };
$('#pkStart').onclick = pickerStart;
$('#btnBegin').onclick = async () => {
closeModal('#mStart'); toggleFullscreen();
try { S = await api('/api/restart', {}); skew = Date.now() / 1000 - S.session.server_time; route(); }
catch (e) { /* a ./ksim web from before restart existed: the clock keeps the time it has */ }
};
$('#qPrev').onclick = () => step(-1);
$('#qNext').onclick = () => step(1);
$('#btnSatisfied').onclick = () => step(1);
$('#btnFlag').onclick = async () => { S = await api('/api/flag', {id: cur}); paintExam(); };
let nt; $('#notes').oninput = () => { clearTimeout(nt); nt = setTimeout(() => api('/api/notes', {notes: $('#notes').value}).catch(() => {}), 600); };
// the divider between the task and the desktop
const divider = $('#divider'), qp = $('#qpanel');
try { const w = localStorage.getItem('ksim.split'); if (w) qp.style.width = w; } catch (e) { /* default split */ }
divider.onmousedown = e => {
e.preventDefault(); document.body.classList.add('dragging'); divider.classList.add('dragging');
const mv = ev => { qp.style.width = Math.min(Math.max(320, ev.clientX), window.innerWidth * 0.7) + 'px'; };
const up = () => {
document.body.classList.remove('dragging'); divider.classList.remove('dragging');
removeEventListener('mousemove', mv); removeEventListener('mouseup', up);
try { localStorage.setItem('ksim.split', qp.style.width); } catch (err) { /* not kept: fine */ }
};
addEventListener('mousemove', mv); addEventListener('mouseup', up);
};
load().catch(e => lobbyError(e.message === 'Failed to fetch' ? 'Cannot reach ./ksim web - is it running?' : e.message));
})();
</script>
</body>
</html>