105 of the 147 CKA reference solutions had you type what nobody types in the exam: heredoc manifests, sed, JSON patches, kubectl wait, loops, command substitution, jsonpath into files. Each is rewritten as the lessons are: a generator and the lines to add in vim, a manifest pasted from the docs page it names, kubectl edit, ssh NODE and sudo -i, or a value read off the screen and echoed. The other 42 were commands typed as they stand already. A solution can now be parts (sim/bank.py cmd, hand) as a lesson step's show is. The page and ./ksim solution N render it as Markdown, each thing to type a block of its own that a click copies, with the edit beneath it. A solution in parts is its own audit script: load_track runs its parts, and for each edit by hand the script beside it, so the solution you read is the one proven. Their separate scripts in cka/solve.py are dropped, and a task that has both is refused. ./ksim solution lint [N|DOMAIN..] reads what the solutions have you type against TYPING_TRAPS: the CKA bank is clean. Q49's params gain the plural stem and shortName the solution spells out (no new draws); Q103, ported from CKAD Q23, takes a solution of its own. Audited from a copy with renamed hosts: Q3-Q67 (52 tasks) all solved by their parts, after two waits were added to the audit scripts (Q43's DNS record, Q53's Pods); Q68-Q147 still running. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BNSZLLsGXTMBsw17kDunkQ
1428 lines
107 KiB
HTML
1428 lines
107 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Kubestronaut exam simulator</title>
|
||
<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-warning hidden" id="staleNote"></div>
|
||
<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"><</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">></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 => ({'&': '&', '<': '<', '>': '>', '"': '"'}[c]));
|
||
const oldServer = what => `This ./ksim web was started before "${what}" existed. Stop it (Ctrl-C) and start it again.`;
|
||
// a server that sends no "stale" predates the check, so it is older than this page as well
|
||
const STALE = 'The simulator was updated after this ./ksim web started, and the server still runs the old code: it would misread what this page asks for (an old one starts a full exam instead of a domain\'s lessons). Stop it (Ctrl-C), start it again, and reload this page.';
|
||
const stale = () => S && S.stale !== false;
|
||
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.
|
||
// `code` first, so that the stars of a cron schedule in it are not read as **bold** or *italics*
|
||
const inlineMd = s => s.split(/(`[^`]+`)/).map((p, i) => i % 2
|
||
? `<code class="ic" title="click to copy">${esc(p.slice(1, -1))}</code>`
|
||
: esc(p).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>').replace(/(^|[^\w*])\*(?!\s)([^*]+?)\*(?![\w*])/g, '$1<i>$2</i>')).join('');
|
||
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`;
|
||
$('#staleNote').textContent = STALE; $('#staleNote').classList.toggle('hidden', !stale());
|
||
$('#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) {
|
||
if (stale()) { closeModal('#mPicker'); return lobbyError(STALE); }
|
||
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 <b>Show me how</b>: the exam way, 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 <b>Show me how</b>: the exam way, 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 = brokenHost(q.id) + `
|
||
<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><div class="qbody" style="margin-top:10px">${render(q.solution)}</div></details>` : ''}
|
||
${obj ? `<details class="fold"><summary>Objective ${obj.id} · ${esc(obj.text)}</summary><div class="notes">${notesHtml(obj)}</div></details>` : ''}`;
|
||
copyable($('#qContent'));
|
||
wireRebuild();
|
||
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();
|
||
}
|
||
// a host whose setup failed when the session started: say so where it is used, and offer to rebuild it
|
||
const brokenHost = id => live() && (S.session.broken || []).includes(id)
|
||
? `<div class="alert alert-danger"><b>This host's setup failed</b> when the session started, so it is not as the text describes.
|
||
<div style="margin-top:.5rem"><button class="btn btn-danger btn-sm" id="btnRebuild" data-id="${id}">Rebuild this host</button>
|
||
<span class="muted"> about a minute - or <code class="ic">./ksim reset ${id}</code> in a terminal</span></div></div>` : '';
|
||
function wireRebuild() {
|
||
const b = $('#btnRebuild'); if (!b) return;
|
||
b.onclick = async () => {
|
||
$('#busyTitle').textContent = 'Rebuilding the host';
|
||
$('#busyBody').innerHTML = '<p class="mb0">A new cluster, and its setup run again - about a minute.</p>';
|
||
openModal('#mBusy');
|
||
try { S = await api('/api/reset', {id: +b.dataset.id}); }
|
||
catch (e) { alert(e.message); }
|
||
finally { closeModal('#mBusy'); }
|
||
if (isLesson()) lpage = null;
|
||
paintExam();
|
||
};
|
||
}
|
||
// 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 = brokenHost(L.id) + `<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 how</summary><div class="qbody" style="margin-top:10px">${render(s.show)}</div></details>`;
|
||
}
|
||
$('#qContent').innerHTML = body;
|
||
copyable($('#qContent'));
|
||
wireRebuild();
|
||
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><div class="qbody" style="margin-top:10px">${render(q.solution)}</div></details>` : '<div class="pad"></div>'}</div>`;
|
||
}).join('');
|
||
copyable($('#resQuestions'));
|
||
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><div class="qbody">${render(q.solution)}</div>`).join('')
|
||
|| '<p class="muted">No reference solutions are open yet.</p>';
|
||
copyable($('#answersBody'));
|
||
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>
|