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