kubestronaut-sim/web/index.html
vumas3r e852419130 feat: teach a domain, its lessons back to back
Add runs of a domain's lessons: ./ksim lesson ARCH, the lobby's Teach
a domain card, or the Teach a domain session in the Start exam
dialog. A run is one lesson session over the domain's lessons not
done yet (--all: every one), each on its own host, all made before it
starts, so there is no waiting between lessons. The session keeps
the lesson you are on and each lesson's checks. A lesson's wrap-up
leads to the next, the list at the top of a lesson goes to any lesson
of the run, and ./ksim lesson next and ./ksim lesson OBJ do the same
in a terminal. Finishing the run sends you to the domain's drill.

Stamp a lesson done the moment its last step passes, not when the
session ends, so terminating a lesson or a run keeps what it taught.

Tested on a real STOR run in a copy of the repo: every host up before
step 1; lesson 1.1 done step by step through the CLI and the web API,
and stamped done as its last step passed; next, jump, a lesson
outside the run refused, and the finish report as expected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:14:08 -06:00

1398 lines
105 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>
<script>/* the theme you picked, before anything is drawn: no flash of the other one */
try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t; } catch (e) { /* storage blocked: follow the system */ }</script>
<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 - plus a dark theme of its own. Written from scratch - no CK-X code is
copied - and without anything from the network: the page has to work offline.
Colours are tokens: light below, dark for data-theme="dark" and for Auto on a dark system. */
:root {
color-scheme: light;
--indigo: #4f46e5; --indigo-dark: #3730a3; --indigo-light: #818cf8;
--accent-text: #4f46e5; --accent-strong: #3730a3; --accent-soft: #eef2ff; --accent-soft-text: #312e81;
--hero-a: #4f46e5; --hero-b: #818cf8;
--bg: #f9fafb; --surface: #fff; --surface-2: #f8f9fa; --surface-3: #f5f5f5; --hover: #e9ecef;
--pre-bg: #f6f8fa; --results-bg: #f4f6f9;
--ink: #111827; --text: #212529; --muted: #6b7280; --faint: #9ca3af;
--line: #dee2e6; --line-2: #ddd; --line-soft: #edf0f3; --input-line: #ced4da;
--link: #0d6efd; --blue: #0d6efd; --blue-hover: #0b5ed7; --divider: #1e88e5;
--green: #198754; --green-hover: #157347; --ok: #27ae60; --bad: #e74c3c; --partial: #d68910;
--ok-text: #198754; --warn-text: #b58100; --bad-text: #dc3545;
--yellow: #ffc107; --red: #dc3545; --red-hover: #bb2d3b; --grey: #6c757d; --grey-hover: #5c636a;
--code: #d63384; --code-bg: rgba(0,0,0,.06); --code-bg-hover: rgba(0,0,0,.12); --copied-bg: #d1e7dd;
--head: #333; --head-btn: #444; --head-btn-hover: #555; --head-line: #444;
--menu-bg: #fff; --menu-line: rgba(0,0,0,.15); --backdrop: rgba(0,0,0,.5); --shadow: 0 .5rem 1rem rgba(0,0,0,.15);
--card-shadow: 0 10px 30px rgba(0,0,0,.06);
--info-bg: #cff4fc; --info-line: #b6effb; --info-text: #055160;
--success-bg: #d1e7dd; --success-line: #badbcc; --success-text: #0f5132;
--warning-bg: #fff3cd; --warning-line: #ffecb5; --warning-text: #664d03;
--danger-bg: #f8d7da; --danger-line: #f5c2c7; --danger-text: #842029;
--res-head: #2c3e50; --score-bg: #2c3e50; --qcard: #f2f4f8; --qcard-h: #e9ecef; --track: #e9ecef;
--gate: #92400e; --progress-track: #e5e7eb; --log-text: #374151; --spinner-track: #cbd5e1; --divider-grip: #999;
--scroll-thumb: #c1c7cf;
--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;
}
:root[data-theme="dark"] {
color-scheme: dark;
--accent-text: #a5b4fc; --accent-strong: #c7d2fe; --accent-soft: rgba(129,140,248,.14); --accent-soft-text: #c7d2fe;
--hero-a: #312e81; --hero-b: #4f46e5;
--bg: #0f1115; --surface: #171a21; --surface-2: #1d2129; --surface-3: #1a1e25; --hover: #2a303b;
--pre-bg: #12151b; --results-bg: #0f1115;
--ink: #f3f4f6; --text: #e3e6ea; --muted: #9aa3b2; --faint: #6b7280;
--line: #2c323d; --line-2: #2a2f39; --line-soft: #242933; --input-line: #3a414d;
--link: #6ea8fe; --ok-text: #4ade80; --warn-text: #fbbf24; --bad-text: #f87171;
--code: #f472b6; --code-bg: rgba(255,255,255,.08); --code-bg-hover: rgba(255,255,255,.14); --copied-bg: rgba(74,222,128,.18);
--head: #1f232b; --head-btn: #2e333d; --head-btn-hover: #3a404c; --head-line: #2e333d;
--menu-bg: #1d2129; --menu-line: rgba(255,255,255,.12); --backdrop: rgba(0,0,0,.65); --shadow: 0 .5rem 1.5rem rgba(0,0,0,.5);
--card-shadow: 0 10px 30px rgba(0,0,0,.35);
--info-bg: #032830; --info-line: #087990; --info-text: #6edff6;
--success-bg: #051b11; --success-line: #0f5132; --success-text: #75b798;
--warning-bg: #332701; --warning-line: #997404; --warning-text: #ffda6a;
--danger-bg: #2c0b0e; --danger-line: #842029; --danger-text: #ea868f;
--res-head: #e3e6ea; --score-bg: #334155; --qcard: #1a1e26; --qcard-h: #232833; --track: #2a303b;
--gate: #fbbf24; --progress-track: #2a303b; --log-text: #c9d1d9; --spinner-track: #3a414d; --divider-grip: #5a6170; --scroll-thumb: #3a414d;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--accent-text: #a5b4fc; --accent-strong: #c7d2fe; --accent-soft: rgba(129,140,248,.14); --accent-soft-text: #c7d2fe;
--hero-a: #312e81; --hero-b: #4f46e5;
--bg: #0f1115; --surface: #171a21; --surface-2: #1d2129; --surface-3: #1a1e25; --hover: #2a303b;
--pre-bg: #12151b; --results-bg: #0f1115;
--ink: #f3f4f6; --text: #e3e6ea; --muted: #9aa3b2; --faint: #6b7280;
--line: #2c323d; --line-2: #2a2f39; --line-soft: #242933; --input-line: #3a414d;
--link: #6ea8fe; --ok-text: #4ade80; --warn-text: #fbbf24; --bad-text: #f87171;
--code: #f472b6; --code-bg: rgba(255,255,255,.08); --code-bg-hover: rgba(255,255,255,.14); --copied-bg: rgba(74,222,128,.18);
--head: #1f232b; --head-btn: #2e333d; --head-btn-hover: #3a404c; --head-line: #2e333d;
--menu-bg: #1d2129; --menu-line: rgba(255,255,255,.12); --backdrop: rgba(0,0,0,.65); --shadow: 0 .5rem 1.5rem rgba(0,0,0,.5);
--card-shadow: 0 10px 30px rgba(0,0,0,.35);
--info-bg: #032830; --info-line: #087990; --info-text: #6edff6;
--success-bg: #051b11; --success-line: #0f5132; --success-text: #75b798;
--warning-bg: #332701; --warning-line: #997404; --warning-text: #ffda6a;
--danger-bg: #2c0b0e; --danger-line: #842029; --danger-text: #ea868f;
--res-head: #e3e6ea; --score-bg: #334155; --qcard: #1a1e26; --qcard-h: #232833; --track: #2a303b;
--gate: #fbbf24; --progress-track: #2a303b; --log-text: #c9d1d9; --spinner-track: #3a414d; --divider-grip: #5a6170; --scroll-thumb: #3a414d;
}
}
* { box-sizing: border-box; scrollbar-color: var(--scroll-thumb) transparent; }
html, body { margin: 0; }
body { font: 15px/1.5 var(--sans); color: var(--text); background: var(--bg); }
button { font: inherit; }
a { color: var(--link); }
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: var(--warn-text); }
.btn-outline-warning:hover { background: var(--yellow); color: #000; }
.btn-outline-primary { border-color: var(--link); color: var(--link); }
.btn-outline-primary:hover:not(:disabled) { background: var(--blue); border-color: 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: var(--info-bg); border-color: var(--info-line); color: var(--info-text); }
.alert-success { background: var(--success-bg); border-color: var(--success-line); color: var(--success-text); }
.alert-warning { background: var(--warning-bg); border-color: var(--warning-line); color: var(--warning-text); }
.alert-danger { background: var(--danger-bg); border-color: var(--danger-line); color: var(--danger-text); 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: var(--code-bg-hover); }
code.ic.copied { background: var(--copied-bg); color: var(--ok-text); }
.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(--hero-a) 0%, var(--hero-b) 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: var(--surface); border-radius: 18px; box-shadow: var(--card-shadow); 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(--accent-soft); color: var(--accent-text); }
.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(--accent-strong); }
.pathgo { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; padding: 16px 18px; border-radius: 12px; background: var(--accent-soft); }
.pathgo .why { flex: 1; min-width: 240px; color: var(--accent-soft-text); 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: var(--surface); color: var(--text); 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(--accent-text); }
.dpill .mk { float: right; font-weight: 700; color: var(--muted); }
.dpill.done .mk { color: var(--ok-text); } .dpill.known .mk { color: var(--accent-text); }
.dpill span { display: block; color: var(--muted); font-size: 13px; margin-top: 4px; }
.gate { margin-top: 14px; font-size: 14px; color: var(--gate); }
/* 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 var(--line-soft); 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(--accent-text); }
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(--ok-text); } .st-started, .st-focus { color: var(--warn-text); } .st-failed { color: var(--bad-text); } .st-new { color: var(--faint); } .st-known { color: var(--accent-text); }
.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: var(--pre-bg); border: 1px solid var(--line); padding: 10px 12px; border-radius: 6px; color: var(--text); }
.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: var(--backdrop); display: flex; align-items: center; justify-content: center; padding: 16px; }
.modal { background: var(--surface); border-radius: .5rem; width: min(560px, 100%); max-height: 92vh; display: flex; flex-direction: column; box-shadow: var(--shadow); 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: var(--muted); 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 var(--input-line); border-radius: .375rem; background: var(--surface); 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: var(--surface); color: var(--text); 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: var(--progress-track); 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(--accent-strong); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.loading-card details summary { cursor: pointer; color: var(--muted); font-size: 13px; }
pre.log { background: var(--pre-bg); border: 1px solid var(--line); padding: 10px 12px; font-size: 12px; line-height: 1.5; max-height: 220px; overflow: auto; border-radius: 6px; color: var(--log-text); 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 var(--spinner-track); 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: var(--text); }
.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-line); }
.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;
white-space: nowrap; flex-shrink: 0; }
.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: var(--head-btn-hover); }
.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: var(--menu-bg);
border: 1px solid var(--menu-line); border-radius: .375rem; box-shadow: var(--shadow); }
.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: var(--text); cursor: pointer; text-decoration: none; white-space: nowrap; }
.dd-menu.left .dd-item { white-space: normal; }
.dd-item:hover { background: var(--hover); }
.dd-item.danger { color: var(--bad-text); }
.dd-item.on { font-weight: 600; }
.dd-item.on::after { content: " ✓"; color: var(--ok-text); }
.dd-head { padding: .25rem 1rem; font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
.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 var(--menu-line); }
.xmain { flex: 1; display: flex; min-height: 0; position: relative; overflow: hidden; }
.qpanel { width: 32%; min-width: 320px; max-width: 70%; background: var(--surface); display: flex; flex-direction: column; min-height: 0; }
.qnav { display: flex; padding: .5rem; background: var(--surface-3); 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: var(--surface-2);
border: 1px solid var(--line-2); border-radius: 4px; font-weight: 700; cursor: pointer; color: var(--text); }
.nav-arrow:disabled { opacity: .5; cursor: not-allowed; background: var(--hover); }
.qselect { flex: 1; margin: 0 .5rem; }
.qselect .dd-toggle { width: 100%; height: 35px; border: 0; background: var(--surface-2); border-radius: .375rem; cursor: pointer; color: var(--text); }
.qselect .dd-toggle:hover { background: var(--hover); }
.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(--link); }
.qcontent hr { border: 0; border-top: 1px solid var(--line); margin: 1rem 0; }
.qtitle { font-size: 18px; margin: 0 0 .6rem; color: var(--ink); }
.qbody p { margin: 0 0 12px; white-space: pre-wrap; }
.qbody ul, .notes-md ul { margin: 0 0 12px; padding-left: 20px; } .qbody li { margin: 3px 0; }
/* a fenced block in a task or a lesson: kept as written, and a click copies it */
pre.code { margin: 0 0 12px; padding: 10px 12px; font-size: 12.5px; line-height: 1.5; background: var(--pre-bg); border: 1px solid var(--line);
border-radius: 6px; color: var(--text); overflow-x: auto; white-space: pre; cursor: copy; }
pre.code:hover { border-color: var(--link); }
pre.code.copied { border-color: var(--ok-text); }
/* lessons */
.lstate { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 16px 0 4px; }
.lstate .ok { color: var(--ok-text); font-weight: 600; } .lstate .no { color: var(--bad-text); font-weight: 600; }
.lstate .lock { color: var(--muted); font-size: 13px; }
.lmark { font: 600 11px var(--sans); text-transform: uppercase; letter-spacing: .04em; padding: 1px 7px; border-radius: 50px;
border: 1px solid var(--line); color: var(--muted); margin-right: 6px; white-space: nowrap; }
.lmark.done { border-color: var(--ok-text); color: var(--ok-text); }
.qhead select.select { width: auto; max-width: 100%; padding: .15rem .5rem; font-size: 13px; margin-left: 4px; }
.stepsum { margin: 0 0 14px; color: var(--muted); font-size: 13px; text-transform: uppercase; letter-spacing: .05em; }
.checkrow { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 16px; font-size: 13px; }
.checks { margin-top: 14px; background: var(--surface-2); 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: var(--surface-2); }
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: var(--pre-bg); border: 1px solid var(--line); padding: 12px; border-radius: 6px; color: var(--text); }
.qactions { flex-shrink: 0; display: flex; gap: 8px; justify-content: space-between; align-items: stretch; padding: 10px 1rem; background: var(--surface); 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: var(--surface-2); }
.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 var(--line); resize: vertical; padding: 10px 1.5rem; font: 13px/1.5 var(--mono); background: var(--surface); color: var(--text); }
.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: var(--divider-grip); 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: var(--results-bg); padding: 24px 16px 40px; }
.res-actions { max-width: 1000px; margin: 0 auto 18px; display: flex; flex-wrap: wrap; gap: 10px; }
.res-actions .dd { margin-left: auto; }
.res-wrap { max-width: 1000px; margin: 0 auto; background: var(--surface); border-radius: 10px; box-shadow: var(--card-shadow); padding: 28px 30px; }
.res-header { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; border-bottom: 1px solid var(--line); padding-bottom: 14px; margin-bottom: 20px; }
.res-header h2 { margin: 0; font-size: 26px; color: var(--res-head); }
.res-header .meta { color: var(--muted); font-size: 14px; text-align: right; }
.summary { display: flex; align-items: center; justify-content: space-between; gap: 20px; flex-wrap: wrap; background: var(--surface-2); border-radius: 8px; padding: 18px 22px; margin-bottom: 20px; }
.summary h3 { margin: 0 0 8px; font-size: 16px; color: var(--text); font-weight: 600; }
.score-row { display: flex; align-items: center; gap: 16px; }
.score-box { background: var(--score-bg); 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: var(--res-head); }
.rank-badge { padding: 8px 16px; border-radius: 4px; font-weight: 700; color: #fff; }
.rank-high { background: var(--ok); } .rank-low { background: var(--bad); } .rank-mid { background: var(--grey); }
.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: var(--track); 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(--ok-text); }
#resPath code { background: var(--code-bg); padding: 1px 5px; border-radius: 3px; color: var(--code); }
.qcard { background: var(--qcard); border-radius: 8px; margin-bottom: 14px; overflow: hidden; }
.qcard-h { background: var(--qcard-h); padding: 11px 16px; display: flex; justify-content: space-between; gap: 12px; align-items: baseline; }
.qcard-h h3 { margin: 0; font-size: 16px; color: var(--res-head); }
.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: var(--partial); } .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: var(--surface); padding: 9px 12px; border-bottom: 1px solid var(--line-soft); 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: var(--surface); }
.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 var(--line); 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: var(--pre-bg); border: 1px solid var(--line); padding: 12px; border-radius: 6px; color: var(--text); }
@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>
<div class="dd">
<button class="navlink dd-toggle" id="themeBtn" aria-expanded="false" title="Light, dark, or follow your system">◐ Auto</button>
<div class="dd-menu"><div class="dd-head">Theme</div>
<button class="dd-item" data-theme-set="auto">Auto - follow the system</button><button class="dd-item" data-theme-set="light">Light</button><button class="dd-item" data-theme-set="dark">Dark</button></div>
</div>
<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><button class="btn btn-success hidden" id="pathLesson">Lesson first</button><span class="why" id="pathWhy"></span></div>
</section>
<section class="card hidden" id="teachCard">
<h2 class="section-title">Teach a domain</h2>
<p class="section-sub">A domain's lessons you have not done, back to back and with no clock. Every lesson's host is made before you start, and a lesson counts as done the moment its last step is checked.</p>
<div class="dpills" id="teachPills"></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 class="dd-sep"></div>
<div class="dd-head">Theme</div>
<button class="dd-item" data-theme-set="auto">Auto - follow the system</button><button class="dd-item" data-theme-set="light">Light</button><button class="dd-item" data-theme-set="dark">Dark</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-secondary hidden" id="btnBack"><span aria-hidden="true">←</span> Back</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" id="btnAnswers" data-act="answers">View answers</button>
<button class="btn btn-outline-primary" id="btnReview" data-act="review">Review tasks</button>
<button class="btn btn-outline-primary" data-act="history">History</button>
<button class="btn btn-danger" id="btnDelete" data-act="terminate">Delete the hosts</button>
<div class="dd">
<button class="btn btn-outline-primary dd-toggle" aria-expanded="false">Theme</button>
<div class="dd-menu"><button class="dd-item" data-theme-set="auto">Auto - follow the system</button><button class="dd-item" data-theme-set="light">Light</button><button class="dd-item" data-theme-set="dark">Dark</button></div>
</div>
</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 id="resLabel">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="pkLessonRow"><label class="lbl" for="pkLesson">Lesson</label><select class="select" id="pkLesson"></select></div>
<div class="field hidden" id="pkTeachRow"><label class="lbl" for="pkTeach">Domain</label><select class="select" id="pkTeach"></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, lpage = null;
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), ```fenced blocks``` (kept as
// written, click to copy), and a paragraph made only of "- " lines as a list. Other lists stay as
// literal lines.
const inlineMd = s => esc(s).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
.replace(/`([^`]+)`/g, (m, c) => `<code class="ic" title="click to copy">${c}</code>`);
const render = md => md.split(/^```[^\n]*\n([\s\S]*?)\n?^```[ \t]*$/m).map((part, i) => i % 2
? `<pre class="code" title="click to copy">${esc(part)}</pre>`
: part.split(/\n\s*\n/).filter(p => p.trim()).map(p => /^(- .*(\n|$))+$/.test(p.trim() + '\n')
? '<ul>' + p.trim().split('\n').map(l => `<li>${inlineMd(l.slice(2))}</li>`).join('') + '</ul>'
: `<p>${inlineMd(p.replace(/^\n+|\n+$/g, ''))}</p>`).join('')).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, pre.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(() => {});
});
}
// ---- 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];
}
// ---- theme: auto (the system's), light or dark - kept in this browser ----
const THEME = {auto: '◐ Auto', light: '☀ Light', dark: '☾ Dark'};
function setTheme(t) {
if (t === 'auto') delete document.documentElement.dataset.theme; else document.documentElement.dataset.theme = t;
try { if (t === 'auto') localStorage.removeItem('ksim.theme'); else localStorage.setItem('ksim.theme', t); } catch (e) { /* this visit only */ }
paintTheme();
}
function paintTheme() {
const t = document.documentElement.dataset.theme || 'auto';
$$('[data-theme-set]').forEach(b => b.classList.toggle('on', b.dataset.themeSet === t));
$('#themeBtn').textContent = THEME[t];
}
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));
const lessonLine = () => { const n = S.lesson.steps.length, c = Object.values(sess.steps || {}).filter(r => r.pass).length;
if (S.course) return `Your last run of lessons, ${esc(S.course.domain)}, ended with ${courseDone()}/${S.course.lessons.length} lessons done. <a href="#" id="lastResults">View its summary</a>`;
return `Your last lesson, ${esc(S.lesson.objective)}, ended with ${c}/${n} steps checked. <a href="#" id="lastResults">View its summary</a>`; };
$('#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.'
: sess.mode === 'lesson' && S.lesson ? lessonLine()
: `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(); paintTeach(); 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 next objective has a lesson you have not done: offer it before its drill
const no = P.next.objective && S.objectives.find(o => o.id === P.next.objective), lessonFirst = !!(no && no.lesson && !no.lesson.done);
$('#pathLesson').classList.toggle('hidden', !lessonFirst);
if (lessonFirst) { $('#pathLesson').textContent = `Lesson first · ${no.id}`; $('#pathLesson').title = no.lesson.title; $('#pathLesson').onclick = () => startLesson(no.id); }
// 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>`;
const lmark = o => !o.lesson ? '' : `<span class="lmark${o.lesson.done ? ' done' : ''}" title="${o.lesson.done ? 'Lesson done' : 'A lesson teaches this objective'}: ${esc(o.lesson.title)}">lesson${o.lesson.done ? ' ✓' : ''}</span>`;
$('#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">${lmark(o)}${chips(o)}</span></summary>
<div class="notes">${notesHtml(o)}</div>
<div class="objrow">${o.lesson ? `<button class="btn btn-success btn-sm" data-lesson="${o.id}" title="${esc(o.lesson.title)}">Lesson · ${o.lesson.steps} steps, no clock</button> ` : ''}<button class="btn btn-primary btn-sm" data-drill="${o.id}">Against the clock</button></div>
</details>`).join('')).join('');
$$('#objList [data-lesson]').forEach(b => b.onclick = () => startLesson(b.dataset.lesson));
$$('#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); }
});
}
// a domain's lessons, back to back: what each domain has left
function teachPlan(d) {
const ls = (S.objectives || []).filter(o => o.lesson && o.domain === d), left = ls.filter(o => !o.lesson.done), go = left.length ? left : ls;
return {all: ls, left, steps: go.reduce((a, o) => a + o.lesson.steps, 0)};
}
const teachDomains = () => [...new Set((S.objectives || []).filter(o => o.lesson).map(o => o.domain))];
function paintTeach() {
const doms = teachDomains();
$('#teachCard').classList.toggle('hidden', !doms.length);
$('#teachPills').innerHTML = doms.map(d => {
const p = teachPlan(d), n = p.all.length;
return `<button class="dpill ${p.left.length ? '' : 'done'}" data-teach="${d}" title="./ksim lesson ${d}: ${p.left.length ? 'the lessons you have not done' : 'every lesson again'}, back to back"><b>${d}</b><span class="mk">${n - p.left.length}/${n}</span><span>${esc(S.track.domains[d])}</span>`
+ `<span>${p.left.length ? `${p.left.length} lesson${p.left.length > 1 ? 's' : ''} to go · ${p.steps} steps` : `all done · take the ${n} again`}</span></button>`;
}).join('');
$$('#teachPills [data-teach]').forEach(el => el.onclick = () => startTeach(el.dataset.teach));
}
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 || []).some(x => x.lesson)) o.push(['lesson', 'Take a lesson — a guided lab, no clock'], ['teach', 'Teach a domain — its lessons back to back, no clock']);
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;
const withLesson = (S.objectives || []).filter(o => o.lesson);
$('#pkLesson').innerHTML = withLesson.map(o => `<option value="${o.id}">${o.lesson.done ? '✓' : '·'} ${o.id} ${esc(o.lesson.title)}</option>`).join('');
const firstOpen = withLesson.find(o => !o.lesson.done); if (firstOpen) $('#pkLesson').value = firstOpen.id;
$('#pkTeach').innerHTML = teachDomains().map(d => { const p = teachPlan(d);
return `<option value="${d}">${d} — ${esc(S.track.domains[d])} · ${p.all.length - p.left.length}/${p.all.length} lessons done</option>`; }).join('');
const teachOpen = teachDomains().find(d => teachPlan(d).left.length); if (teachOpen) $('#pkTeach').value = teachOpen;
$('#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');
$('#pkLessonRow').classList.toggle('hidden', v !== 'lesson');
$('#pkTeachRow').classList.toggle('hidden', v !== 'teach');
$('#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 === 'lesson') {
const o = (S.objectives || []).find(x => x.id === $('#pkLesson').value);
desc = `A guided lab on a host of your own: an introduction, then ${o ? o.lesson.steps : 'a few'} steps, each with a Check button, a hint and the commands. No clock, and nothing goes into your history or scores. It ends by sending you to the objective's timed drill.`;
}
else if (v === 'teach') {
const d = $('#pkTeach').value, p = teachPlan(d), k = (p.left.length || p.all.length);
desc = `${p.left.length ? `The ${k} lesson${k > 1 ? 's' : ''} of ${S.track.domains[d]} you have not done` : `All ${k} lessons of ${S.track.domains[d]} again`} (${p.steps} steps), one after another. Every lesson has a host of its own, all made before you start, so there is no waiting between them. No clock, nothing goes into your history or scores, and a lesson counts as done the moment its last step is checked. It ends by sending you to the domain's timed drill.`;
}
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' : v === 'lesson' ? 'Start lesson' : v === 'teach' ? 'Start the lessons' : '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 === 'lesson') return startLesson($('#pkLesson').value);
if (v === 'teach') return startTeach($('#pkTeach').value);
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});
const startLesson = obj => launch({lesson: obj});
const startTeach = d => launch({teach: d});
const isLesson = () => !!(S && S.lesson && S.session && S.session.mode === 'lesson');
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; lpage = fresh ? 0 : null; go('exam');
if (!fresh) return;
if (isLesson() && S.course) { // a domain's run: how it goes
const C = S.course, n = C.lessons.length;
$('#startTitle').textContent = `${C.name}: ${n} lesson${n > 1 ? 's' : ''}`;
$('#startBody').innerHTML = `<div class="alert alert-info"><b>No clock.</b> Take your time: the lessons teach, the drills test.</div>
<ul>
<li>every lesson has a host of its own, and all ${n} are up already: the top of each lesson says which to <code class="ic">ssh</code> to;</li>
<li>a lesson is an introduction, then steps - each with a <b>Check step</b> button, a hint, and the commands if you are stuck; steps count in order;</li>
<li>a lesson's wrap-up leads to the next one, and the list at the top goes to any lesson of the run;</li>
<li>a lesson counts as done the moment its last step is checked; nothing goes into your history or scores;</li>
<li>finish the run from <b>Controls</b>, then drill ${esc(C.domain)} against the clock.</li>
</ul>`;
openModal('#mStart');
return;
}
if (isLesson()) { // no clock to start: the dialog only says how a lesson goes
const L = S.lesson;
$('#startTitle').textContent = `Lesson ${L.objective}: ${L.title}`;
$('#startBody').innerHTML = `<div class="alert alert-info"><b>No clock.</b> Take your time: a lesson teaches, the drills test.</div>
<ul>
<li>it runs on a host of its own: <code class="ic">${esc('ssh ' + L.host)}</code> in the desktop's terminal;</li>
<li>read the introduction, then do the ${L.steps.length} steps - each has a <b>Check step</b> button, a hint, and the commands if you are stuck;</li>
<li>steps count in order, and nothing goes into your history or scores;</li>
<li>the wrap-up sends you to the objective's timed drill.</li>
</ul>`;
openModal('#mStart');
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;
if (isLesson()) {
const what = S.course ? 'the run' : 'lesson';
$('#mnControls').innerHTML = live()
? `<button class="dd-item" data-act="finishLesson">Finish ${what}</button>
<div class="dd-sep"></div>
<button class="dd-item danger" data-act="terminate">Terminate ${what}</button>`
: `<button class="dd-item" data-act="results">Back to the summary</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 host${S.course ? 's' : ''}</button>`;
return;
}
$('#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() {
if (isLesson()) return paintLesson();
const sess = S.session, on = live();
$('#btnBack').classList.add('hidden');
$('#btnSatisfied').innerHTML = 'Satisfied with answer <span aria-hidden="true">→</span>';
$('#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;
paintDesk();
}
// what the exam and a lesson share: the notepad, the remote desktop, the clock
function paintDesk() {
if (document.activeElement !== $('#notes')) $('#notes').value = S.session.notes || '';
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) {
if (isLesson()) {
const nx = nextLesson();
if (d > 0 && lpage === S.lesson.steps.length + 1 && nx) return goLesson(nx.id);
lpage = Math.max(0, Math.min(lpage + d, S.lesson.steps.length + 1)); paintExam(); $('#qContent').scrollTop = 0; return;
}
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; }
}
// ================= lesson screen =================
// Pages: 0 the introduction, 1..n the steps, n+1 the wrap-up. Same frame as the exam screen.
const stepDone = (k, sess = S.session) => !!((sess.steps || {})[k] && sess.steps[k].pass);
function lessonResume() { // where a reloaded lesson picks up: the first step not checked yet
const n = S.lesson.steps.length, k = [...Array(n).keys()].find(i => !stepDone(i));
return k === undefined ? n + 1 : k + 1;
}
// a domain's run: the lessons in it, the one after the current, how many are done
const courseIdx = () => S.course ? S.course.lessons.findIndex(x => x.id === S.course.at) : -1;
const nextLesson = () => S.course ? S.course.lessons[courseIdx() + 1] || null : null;
const courseDone = () => S.course.lessons.filter(x => x.steps.every(s => s.pass)).length;
async function goLesson(id) {
try { S = await api('/api/lesson/at', {lesson: String(id)}); }
catch (e) { alert(e.message === 'not found' ? oldServer('Teach a domain') : e.message); return; }
lpage = Object.values(S.session.steps || {}).some(r => r.pass) ? null : 0; // a lesson not started opens on its introduction
paintExam(); $('#qContent').scrollTop = 0;
}
function paintLesson() {
const L = S.lesson, sess = S.session, on = live(), n = L.steps.length, C = S.course, nx = nextLesson();
if (lpage === null) lpage = lessonResume();
lpage = Math.max(0, Math.min(lpage, n + 1));
$('#xTitle').textContent = `${S.track_id.toUpperCase()} Lesson${C ? ` · ${C.domain} ${courseIdx() + 1}/${C.lessons.length}` : ''} · ${L.objective} ${L.title}`;
document.title = `Lesson ${L.objective} · Kubestronaut exam simulator`;
paintMenus();
const checked = [...Array(n).keys()].filter(k => stepDone(k)).length, firstOpen = [...Array(n).keys()].find(k => !stepDone(k));
const name = p => p === 0 ? 'Introduction' : p === n + 1 ? 'Wrap-up' : `Step ${p} of ${n}`;
$('#qDrop').textContent = name(lpage);
$('#qPrev').disabled = lpage <= 0; $('#qNext').disabled = lpage >= n + 1;
$('#qMenu').innerHTML = [...Array(n + 2).keys()].map(p => `<button class="dd-item ${p >= 1 && p <= n && stepDone(p - 1) ? 'pass' : ''}" data-lp="${p}">`
+ (p === 0 ? 'Introduction' : p === n + 1 ? `Wrap-up · ${checked}/${n} steps checked` : `Step ${p} · ${esc(L.steps[p - 1].title)}`) + '</button>').join('');
$$('#qMenu [data-lp]').forEach(b => b.onclick = () => { lpage = +b.dataset.lp; closeMenus(); paintExam(); $('#qContent').scrollTop = 0; });
const pick = !C ? '' : `<div class="line"><b>Lesson ${courseIdx() + 1} of ${C.lessons.length}</b> in ${esc(C.name)}:<select class="select" id="lessonPick">`
+ C.lessons.map(x => { const c = x.steps.filter(s => s.pass).length;
return `<option value="${x.id}"${x.id === C.at ? ' selected' : ''}>${c === x.steps.length ? '✓' : '·'} ${esc(x.objective)} ${esc(x.title)} (${c}/${x.steps.length})</option>`; }).join('')
+ '</select></div>';
let body = `<div class="qhead">${pick}<div class="line"><b>This lesson runs on:</b> <code class="ic" title="click to copy">${esc('ssh ' + L.host)}</code></div>
<div class="line sub">${L.nodes}-node cluster · files in ${esc(S.workspace)} on that host · no clock</div></div><hr>`;
if (lpage === 0) {
body += `<div class="stepsum">Lesson ${esc(L.objective)} · ${n} steps</div><h2 class="qtitle">${esc(L.title)}</h2><div class="qbody">${render(L.intro)}</div>`;
} else if (lpage === n + 1) {
body += `<div class="stepsum">Wrap-up · ${checked} of ${n} steps checked</div><h2 class="qtitle">${esc(L.title)}</h2><div class="qbody">${render(L.outro)}</div>`
+ (on ? `<div class="lstate">${nx ? `<button class="btn btn-primary" id="btnNextLesson">Next lesson · ${esc(nx.objective)} ${esc(nx.title)}</button>` : ''}`
+ `<button class="btn ${nx ? 'btn-outline-primary' : 'btn-primary'}" id="btnFinish">Finish ${C ? 'the run' : 'lesson'}</button>`
+ (checked < n ? `<span class="lock">${n - checked} step(s) not checked yet - the lesson counts as done only when every step is.</span>` : '') + '</div>' : '');
} else {
const k = lpage - 1, s = L.steps[k], r = (sess.steps || {})[k], locked = firstOpen !== undefined && k > firstOpen;
body += `<div class="stepsum">Step ${lpage} of ${n}</div><h2 class="qtitle">${esc(s.title)}</h2><div class="qbody">${render(s.text)}</div>`;
if (on) {
body += `<div class="lstate"><button class="btn btn-outline-primary" id="btnStep"${locked ? ' disabled' : ''}>Check step</button>`
+ (r && r.pass ? '<span class="ok">✓ Done</span>' : r ? '<span class="no">✗ Not done yet</span>' : '')
+ (locked ? `<span class="lock">Check step ${firstOpen + 1} first: steps count in order.</span>` : '') + '</div>'
+ (r && !r.pass && r.out ? `<details class="fold"><summary>What the check saw</summary><pre>${esc(r.out)}</pre></details>` : '');
} else if (r && r.pass) body += '<div class="lstate"><span class="ok">✓ Done</span></div>';
if (s.hint) body += `<details class="fold"><summary>Hint</summary><div class="qbody" style="margin-top:10px">${render(s.hint)}</div></details>`;
if (s.show) body += `<details class="fold"><summary>Show me the commands</summary><pre class="code" title="click to copy" style="margin:10px 0 0">${esc(s.show)}</pre></details>`;
}
$('#qContent').innerHTML = body;
copyable($('#qContent'));
const bs = $('#btnStep'); if (bs) bs.onclick = () => checkStep(lpage - 1);
const bf = $('#btnFinish'); if (bf) bf.onclick = finishLesson;
const bn = $('#btnNextLesson'); if (bn) bn.onclick = () => goLesson(nx.id);
const lp = $('#lessonPick'); if (lp) lp.onchange = () => goLesson(lp.value);
$('#btnFlag').className = 'btn btn-outline-warning hidden';
$('#btnBack').classList.remove('hidden'); $('#btnBack').disabled = lpage <= 0;
$('#btnSatisfied').innerHTML = (lpage === 0 ? 'Start: step 1' : lpage === n ? 'Wrap-up' : lpage === n + 1 ? (nx ? 'Next lesson' : 'Wrap-up') : 'Next step') + ' <span aria-hidden="true">→</span>';
$('#btnSatisfied').disabled = lpage >= n + 1 && !nx;
paintDesk();
}
async function checkStep(k) {
const b = $('#btnStep'); b.disabled = true; b.textContent = 'Checking…';
try { S = await api('/api/lesson/check', {step: k}); }
catch (e) { alert(e.message === 'not found' ? oldServer('lessons') : e.message); }
paintExam();
}
async function finishLesson() {
const n = S.lesson.steps.length, c = [...Array(n).keys()].filter(k => stepDone(k)).length, C = S.course;
if (C ? courseDone() < C.lessons.length && !(await ask({title: 'Finish the run?', ok: 'Finish the run',
body: `<p class="mb0">${courseDone()} of ${C.lessons.length} lessons have every step checked. The others count as done only when every step is - you can take them again any time.</p>`}))
: c < n && !(await ask({title: 'Finish the lesson?', ok: 'Finish lesson',
body: `<p class="mb0">${c} of ${n} steps are checked. The lesson counts as done only when every step is - you can take it again any time.</p>`}))) return;
$('#busyTitle').textContent = C ? 'Finishing the run' : 'Finishing the lesson';
$('#busyBody').innerHTML = '<p class="mb0">Nothing is graded: the steps were checked as you went.</p>';
openModal('#mBusy');
try { S = await api('/api/end', {}); }
catch (e) { closeModal('#mBusy'); alert(e.message); return; }
closeModal('#mBusy'); go('results');
}
function tickClock() {
clearInterval(clockT);
const sess = S.session, el = $('#timer');
if (!sess || sess.ended_at) { el.textContent = sess ? 'ended' : '--:--'; el.className = 'timer'; return; }
if (sess.mode === 'lesson') { el.textContent = 'no clock'; el.className = 'timer paused'; 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(), what = isLesson() ? 'lesson' : 'session';
const ok = await ask(on && isLesson() && S.course
? {title: 'Terminate the run', ok: 'Terminate the run', okClass: 'btn-danger',
body: `<p class="mb0">This deletes the run's ${S.course.lessons.length} hosts right away and ends it. Lessons whose every step was checked stay done; nothing else is recorded.</p>`}
: on && isLesson()
? {title: 'Terminate the lesson', ok: 'Terminate lesson', okClass: 'btn-danger',
body: '<p class="mb0">This deletes the lesson\'s host right away and ends the lesson. Nothing goes into your history or scores; you can take it again any time.</p>'}
: 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(`${S.course ? 'Run of lessons' : what === 'lesson' ? 'Lesson' : 'Session'} terminated: nothing was graded, 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 resultButtons(lesson) {
$('#btnAnswers').dataset.act = lesson ? 'retake' : 'answers'; $('#btnAnswers').textContent = lesson ? 'Take it again' : 'View answers';
$('#btnReview').textContent = lesson ? 'Review the lesson' : 'Review tasks';
$('#btnDelete').textContent = lesson ? 'Delete the host' : 'Delete the hosts';
$('#resLabel').textContent = lesson ? 'Steps checked' : 'Points scored';
}
function paintCourseResults() {
const C = S.course, s = S.session, n = C.lessons.length, c = courseDone(), all = c === n;
document.title = `Lessons ${C.domain} · Kubestronaut exam simulator`;
resultButtons(true);
$('#btnDelete').textContent = 'Delete the hosts'; $('#resLabel').textContent = 'Lessons done';
$('#resTitle').textContent = `${C.name}: lessons`;
$('#resMeta').innerHTML = `${n} lesson${n > 1 ? 's' : ''}, ${C.lessons.reduce((a, x) => a + x.steps.length, 0)} steps<br>Finished ${new Date(s.ended_at * 1000).toLocaleString()}<br>No clock - nothing went into your history`;
$('#resEarned').textContent = c; $('#resTotal').textContent = n; $('#resPct').textContent = all ? 'done' : '';
$('#resBadge').textContent = all ? 'Every lesson done ✓' : `${n - c} lesson(s) to go`;
$('#resBadge').className = 'rank-badge ' + (all ? 'rank-high' : 'rank-mid');
$('#resDomains').innerHTML = '';
$('#resPath').innerHTML = (s.milestones || []).map(m => `<div class="${m.startsWith('✓') ? 'milestone' : ''}">${esc(m)}</div>`).join('');
$('#resQuestions').innerHTML = C.lessons.map(x => { const k = x.steps.filter(t => t.pass).length;
return `<div class="qcard ${k === x.steps.length ? 't-pass' : 't-partial'}"><div class="qcard-h"><h3>${esc(x.objective)} · ${esc(x.title)}</h3><span class="qs">${k} of ${x.steps.length}</span></div>
<ul class="vitems">${x.steps.map((t, j) => `<li class="vitem"><span>Step ${j + 1} · ${esc(t.title)}</span><span class="vstat ${t.pass ? 'ok' : 'no'}">${t.pass ? '✓' : '✗'}</span></li>`).join('')}</ul><div class="pad"></div></div>`; }).join('');
$('#btnDrillMissed').disabled = false;
$('#btnDrillMissed').textContent = `Drill ${C.domain} against the clock`;
$('#btnDrillMissed').onclick = () => start('drill', null, false, false, C.domain);
}
function paintLessonResults() {
if (S.course) return paintCourseResults();
const L = S.lesson, s = S.session, n = L.steps.length, c = [...Array(n).keys()].filter(k => stepDone(k)).length, all = c === n;
document.title = `Lesson ${L.objective} · Kubestronaut exam simulator`;
resultButtons(true);
$('#resTitle').textContent = `Lesson ${L.objective}`;
$('#resMeta').innerHTML = `${esc(L.title)}<br>Finished ${new Date(s.ended_at * 1000).toLocaleString()}<br>No clock - nothing went into your history`;
$('#resEarned').textContent = c; $('#resTotal').textContent = n; $('#resPct').textContent = all ? 'done' : '';
$('#resBadge').textContent = all ? 'Lesson done ✓' : `${n - c} step(s) to go`;
$('#resBadge').className = 'rank-badge ' + (all ? 'rank-high' : 'rank-mid');
$('#resDomains').innerHTML = '';
$('#resPath').innerHTML = (s.milestones || []).map(m => `<div class="${m.startsWith('✓') ? 'milestone' : ''}">${esc(m)}</div>`).join('');
$('#resQuestions').innerHTML = `<div class="qcard ${all ? 't-pass' : 't-partial'}"><div class="qcard-h"><h3>${esc(L.title)}</h3><span class="qs">${c} of ${n}</span></div>
<ul class="vitems">${L.steps.map((st, k) => `<li class="vitem"><span>Step ${k + 1} · ${esc(st.title)}</span><span class="vstat ${stepDone(k) ? 'ok' : 'no'}">${stepDone(k) ? '✓' : '✗'}</span></li>`).join('')}</ul><div class="pad"></div></div>`;
$('#btnDrillMissed').disabled = false;
$('#btnDrillMissed').textContent = `Drill ${L.objective} against the clock`;
$('#btnDrillMissed').onclick = () => start('drill', null, false, false, null, L.objective);
}
function paintResults() {
if (isLesson()) return paintLessonResults();
resultButtons(false);
$('#btnDrillMissed').textContent = 'Drill what I missed';
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: () => { lpage = null; go('exam'); },
finishLesson, retake: () => S.course ? startTeach(S.course.domain) : startLesson(S.lesson.objective),
};
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 th = e.target.closest('[data-theme-set]');
if (th) { closeMenus(); setTheme(th.dataset.themeSet); 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;
$('#pkLesson').onchange = pickerChanged;
$('#pkTeach').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');
if (isLesson()) return; // a lesson has no clock to start
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);
$('#btnBack').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);
};
paintTheme();
load().catch(e => lobbyError(e.message === 'Failed to fetch' ? 'Cannot reach ./ksim web - is it running?' : e.message));
})();
</script>
</body>
</html>