Compare commits

..

No commits in common. "accb26159ef5b40ccbe00b0035d5d68902d08a91" and "c8b07bee09b6f2a954a43c7600698d4d5e514b17" have entirely different histories.

6 changed files with 65 additions and 300 deletions

View File

@ -4,25 +4,6 @@ Notable changes to kubestronaut-sim, newest first. A version is the `VERSION` st
(year.month.day.n); `./ksim help` and the top bar of the web page show the one you are running.
Every entry names the commit it came in.
## 2026.10.26.2 — 2026-10-26
Everything below came in `e852419`.
### Added
- **Teach a domain**: a domain's lessons you have not done, back to back in one session without
a clock - `./ksim lesson ARCH`, the lobby's *Teach a domain* card, or the *Teach a domain*
session in the Start exam dialog (`--all`, or a domain whose lessons are all done, brings every
one). Every lesson's host is made before the run starts, so there is no waiting between
lessons. A lesson's wrap-up leads to the next, the list at the top of a lesson goes to any lesson
of the run, and in a terminal `./ksim lesson next` and `./ksim lesson 4.3` do the same.
Finishing the run sends you to the domain's timed drill.
### Changed
- A lesson counts as done the moment its last step is checked, not when you finish it, so
terminating a lesson or a run keeps the lessons it completed.
## 2026.10.26.1 — 2026-10-26
Everything below came in `cf4f5be`.

View File

@ -136,8 +136,6 @@ host name such as `ckad0013` only resolves in the desktop's terminal: from your
./ksim objectives | next | focus # the curriculum as a path, one objective at a time against the clock
./ksim notes 3.2 # an objective's notes: what to know, fastest commands, traps, docs
./ksim lesson [OBJ] # the track's lessons, or start one: a guided lab, no clock (CKA: all 27 · CKAD: 2.1-2.4)
./ksim lesson ARCH [--all] # teach a domain: its lessons not done yet (--all: every one), back to back
./ksim lesson next | lesson 4.3 # in a domain's run: the next lesson, or any lesson of the run
./ksim lesson step | check | hint | show [N] # inside a lesson; ./ksim end finishes it
./ksim skip DESIGN | unskip DESIGN # "I am good with this domain (or objective 2.3)": off the path, and back
./ksim drill OBS:3 | next OBS:3 # three tasks of a domain, each from another objective - alone, or added
@ -160,20 +158,10 @@ untimed part of the simulator — they teach, the drills test — and they write
history, the statistics or the weak list; a lesson whose every step was checked is marked done on
its objective (`lesson ✓` in the objective list). Steps count in order. Start one from an
objective's **Lesson** button, **Lesson first** on the path card, the *Take a lesson* session in the
Start exam dialog, or `./ksim lesson 2.1`.
**Teach a domain** runs a domain's lessons back to back: the ones you have not done, or every
one again once they all are (`--all` forces that). It is one session without a clock. Every
lesson's host is made before it starts, as many at a time as for any session (`KSIM_PARALLEL`),
so you never wait between lessons. Each lesson's wrap-up leads to the next, the list at the top
of a lesson goes to any lesson of the run, and a lesson counts as done the moment its last step
is checked, whether or not the run is finished. Finishing the run sends you to the domain's
timed drill. Start one from the **Teach a domain** card in the lobby, the *Teach a domain*
session in the Start exam dialog, or `./ksim lesson ARCH`.
**CKA has a lesson for each of its 27 objectives** (`cka/lessons_*.py`, 135 steps), which also
cover what a full CKA course teaches under them — checked against the outline of KubeDojo's CKA
course and written from scratch: projected volumes, volume expansion, snapshots and restores, eviction thresholds, debug containers, kube-proxy's
Start exam dialog, or `./ksim lesson 2.1`. **CKA has a lesson for each of its 27 objectives**
(`cka/lessons_*.py`, 135 steps), which also cover what a full CKA course teaches under them —
checked against the outline of KubeDojo's CKA course and written from scratch: projected volumes,
volume expansion, snapshots and restores, eviction thresholds, debug containers, kube-proxy's
rules, encryption at rest, in-place resize, PodDisruptionBudgets, QoS classes, ServiceAccount
tokens, a real upgrade from v1.36 to v1.37, EndpointSlices, an operator. Tasks 132–147 test those
topics against the clock. The CKAD pilot

View File

@ -5,7 +5,6 @@ Lessons write nothing to the history or the scores - only a "lesson done" mark o
./ksim lesson the lessons of this track, and which you have done
./ksim lesson 1.1 start the lesson of objective 1.1 (id or key)
./ksim lesson STOR a domain's lessons not done yet, back to back; ./ksim lesson next moves on
./ksim lesson check check the step you are on; the next one follows
./ksim lesson audit [OBJ..] prove lessons: every step fails before its commands and passes after

View File

@ -5,7 +5,6 @@ Lessons write nothing to the history or the scores - only a "lesson done" mark o
./ksim lesson the lessons of this track, and which you have done
./ksim lesson 2.1 start the lesson of objective 2.1 (id or key)
./ksim lesson DEPLOY the domain's lessons not done yet, back to back; ./ksim lesson next moves on
./ksim lesson check check the step you are on; the next one follows
./ksim lesson audit [OBJ..] prove lessons: every step fails before its commands and passes after

181
ksim
View File

@ -22,7 +22,7 @@ from collections.abc import Callable
from datetime import datetime
from typing import Any
VERSION = "2026.10.26.2"
VERSION = "2026.10.26.1"
ROOT = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, ROOT)
from sim import desktop, envs # noqa: E402
@ -930,50 +930,21 @@ def known_task(q, known):
# sends you to the objective's timed drill. It runs as a session of mode "lesson": no clock, its
# steps checked one at a time and in order, nothing written to the history or the statistics; a
# lesson whose every step was checked is stamped done in study-<track>.json.
# A domain's run (./ksim lesson ARCH) is one such session over the domain's lessons, each on its
# host, all made before it starts: "at" is the lesson you are on, "steps" holds each one's checks.
def find_lesson(arg):
"""A lesson by its objective (2.1, or the objective's key) or by its id (9201); None if none fits."""
o = find_objective(arg)
return next((l for l in LESSONS if (o and l["objective"] == o["id"]) or str(l["id"]) == str(arg)), None)
def lessons_of(st):
"""The lessons of a lesson session, in order: one, or a domain's run."""
if not st or st.get("mode") != "lesson":
return []
by_id = {l["id"]: l for l in LESSONS}
return [by_id[i] for i in st.get("ids", []) if i in by_id]
def lesson_of(st):
"""The lesson a session is on, or None."""
ls = lessons_of(st)
return next((l for l in ls if l["id"] == st.get("at")), ls[0] if ls else None)
"""The lesson a session is, or None."""
if not st or st.get("mode") != "lesson":
return None
return next((l for l in LESSONS if l["id"] in st.get("ids", [])), None)
def lessons_done():
"""objective id -> when its lesson was first done."""
return load_progress()["lessons"]
def domain_lessons(d, everything=False):
"""A domain's lessons in curriculum order: those not done yet - all of them once every one is
done, or with `everything`."""
ls, done = [l for l in LESSONS if l["domain"] == d], lessons_done()
left = [l for l in ls if l["objective"] not in done]
return ls if everything or not left else left
def steps_of(st, l):
"""Step index -> its latest check, for lesson l of the session."""
return st.get("steps", {}).get(str(l["id"]), {})
def steps_checked(st, l):
return sum(1 for i in range(len(l["steps"])) if steps_of(st, l).get(str(i), {}).get("pass"))
def stamp_lesson(l):
"""Mark l's objective as having its lesson done, the first time."""
prog = load_progress()
if l["objective"] not in prog["lessons"]:
prog["lessons"][l["objective"]] = datetime.now().isoformat(timespec="minutes")
save_progress(prog)
def check_step(l, params, i):
"""Run the check of step i on the lesson's host."""
try:
@ -984,14 +955,13 @@ def check_step(l, params, i):
def open_step(st, l):
"""The first step not checked yet, or None when every one is."""
done = steps_of(st, l)
done = st.get("steps", {})
return next((i for i in range(len(l["steps"])) if not done.get(str(i), {}).get("pass")), None)
def lesson_check(st, i):
"""Check step i of the lesson in progress and keep the result. Steps count in order - a step
builds on the one before - so only the first open step or one already done can be checked, and
a pass stays a pass when a later step changes what an earlier check looked at. The check that
completes a lesson stamps it done, whatever then becomes of the session.
a pass stays a pass when a later step changes what an earlier check looked at.
Returns (result, None) or (None, why not)."""
l = lesson_of(st)
if not l or st.get("ended_at"):
@ -1002,28 +972,12 @@ def lesson_check(st, i):
if first is not None and i > first:
return None, f"Do step {first + 1} first: steps count in order."
r = check_step(l, session_params(st, l), i)
steps = st.setdefault("steps", {}).setdefault(str(l["id"]), {})
steps = st.setdefault("steps", {})
if r["pass"] or not steps.get(str(i), {}).get("pass"):
steps[str(i)] = r
save_state(st)
if open_step(st, l) is None:
stamp_lesson(l)
return r, None
def move_to_lesson(st, l):
"""Go to lesson l of a domain's run - or, once it has ended, review it. Returns why not, or None."""
if l["id"] not in st.get("ids", []):
return f"Lesson {l['objective']} is not part of this run."
st["at"] = l["id"]
save_state(st)
return None
def next_lesson(st):
"""The lesson after the current one in a domain's run, or None."""
ls, l = lessons_of(st), lesson_of(st)
i = ls.index(l) if l in ls else -1
return ls[i + 1] if 0 <= i < len(ls) - 1 else None
def task_state(qid, agg):
"""passed = the latest attempt against the clock was a full pass; failed = it was not;
new = never attempted."""
@ -1527,8 +1481,7 @@ def open_sessions_elsewhere():
pass
return out
def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg, hard=False, other_ok=None, objective=None,
domain=None):
def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg, hard=False, other_ok=None, objective=None):
st = load_state()
if st and not st.get("ended_at") and not force:
die("A session is already in progress. './ksim end' it first, or add --force.")
@ -1569,9 +1522,7 @@ def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg,
"objective": objective} # the curriculum objective a drill or a lesson was started for, if any
lesson = mode == "lesson"
if lesson:
st["steps"] = {} # lesson id -> step index -> its latest check (a pass sticks)
st["at"] = qs[0]["id"]
st["domain"] = domain # the domain a run of lessons teaches (./ksim lesson ARCH), if it is one
st["steps"] = {} # step index -> its latest check (a pass sticks)
save_state(st)
log("")
log(bold("=" * 64))
@ -1579,9 +1530,7 @@ def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg,
+ ("no clock: take your time" if lesson else "clock started")))
log(f" Hosts : {hosts[0]}" + (f" .. {len(hosts)} in all" if len(hosts) > 1 else "")
+ f" (Kubernetes {st['k8s']}, as the real {TRACK_ID.upper()} runs {K8S})")
if lesson and domain:
log(f" Lessons : {DOMAINS[domain]} - {', '.join(l['objective'] for l in qs)}, {sum(len(l['steps']) for l in qs)} steps")
elif lesson:
if lesson:
log(f" Lesson : {objective} {qs[0]['title']} - {len(qs[0]['steps'])} steps")
else:
log(f" Ends : {datetime.fromtimestamp(st['started_at'] + duration).strftime('%H:%M:%S')} ({duration // 60} min)")
@ -1593,26 +1542,23 @@ def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg,
"the exam in the browser: task pane, remote desktop, clock\n")
+ (" ./ksim lesson step / check / hint / show, ./ksim end terminal workflow\n" if lesson else
" ./ksim list / show N / grade N / timer / end terminal workflow\n")
+ (" ./ksim lesson next the run's next lesson; ./ksim lesson OBJ any lesson of the run\n" if lesson and domain else "")
+ f" {'The lesson runs on' if lesson and not domain else 'Every lesson names its host:' if lesson else 'Every task names its host:'} ssh {hosts[0]} from the desktop's terminal, then sudo -i when you need root.\n"
+ f" {'The lesson runs on' if lesson else 'Every task names its host:'} ssh {hosts[0]} from the desktop's terminal, then sudo -i when you need root.\n"
+ f" Files go under {WS_DISPLAY}/ on the host.")
return st
def finish_lesson(st):
"""End a lesson session: nothing is graded or recorded in the history; each lesson whose every
step was checked is stamped done on its objective (the first time). Returns the lines to show."""
"""End a lesson: nothing is graded or recorded in the history; a lesson whose every step was
checked is stamped done on its objective (the first time). Returns the lines to show."""
l = lesson_of(st)
st["ended_at"] = time.time()
d, lines = st.get("domain"), []
for l in lessons_of(st):
n, done = len(l["steps"]), steps_checked(st, l)
n, done = len(l["steps"]), sum(1 for i in range(len(l["steps"])) if st.get("steps", {}).get(str(i), {}).get("pass"))
if done == n:
stamp_lesson(l)
lines.append(f"✓ Lesson {l['objective']} done - {n}/{n} steps checked."
+ ("" if d else f" Now prove it against the clock: ./ksim drill {l['objective']}"))
prog = load_progress()
prog["lessons"].setdefault(l["objective"], datetime.now().isoformat(timespec="minutes"))
save_progress(prog)
lines = [f"✓ Lesson {l['objective']} done - {n}/{n} steps checked. Now prove it against the clock: ./ksim drill {l['objective']}"]
else:
lines.append(f"Lesson {l['objective']} finished with {done}/{n} steps checked - take it again any time: ./ksim lesson {l['objective']}")
if d:
lines.append(f"Now prove {DOMAINS[d]} against the clock: ./ksim drill {d}")
lines = [f"Lesson {l['objective']} finished with {done}/{n} steps checked - take it again any time: ./ksim lesson {l['objective']}"]
st["milestones"] = lines
save_state(st)
return lines
@ -1741,45 +1687,24 @@ def cli_md(text):
def print_step(l, params, i, st=None):
s = l["steps"][i]
done = bool(st and steps_of(st, l).get(str(i), {}).get("pass"))
done = bool(st and st.get("steps", {}).get(str(i), {}).get("pass"))
print(bold(f"Step {i + 1} of {len(l['steps'])}: {render(s['title'], params, l)}") + (green(" ✓ checked") if done else ""))
print(cli_md(render(s["text"], params, l)))
print(dim(" ./ksim lesson check when it is done ./ksim lesson hint / show if you are stuck"))
def print_outro(l, params, st):
def print_outro(l, params):
print(bold("Every step is checked.")); print("-" * 64)
print(cli_md(render(l["outro"], params, l))); print("-" * 64)
nxt = next_lesson(st) if st.get("domain") else None
if nxt:
print(dim(f" ./ksim lesson next lesson {nxt['objective']}: {nxt['title']}"))
print(dim(f" ./ksim end finishes the {'run' if st.get('domain') else 'lesson'}"))
def print_lesson(st, l):
"""A lesson as you arrive at it: its host, its introduction, then the step you are on."""
params, ls, i = session_params(st, l), lessons_of(st), open_step(st, l)
where = f" lesson {ls.index(l) + 1} of {len(ls)} in {DOMAINS[st['domain']]}" if st.get("domain") else ""
print(); print(bold(f"Lesson {l['objective']} - {l['title']}") + dim(where)); print("-" * 64)
print(f"It runs on: ssh {host_of(l)}\n")
print(cli_md(render(l["intro"], params, l))); print("-" * 64)
return print_outro(l, params, st) if i is None else print_step(l, params, i, st)
print(dim(" ./ksim end finishes the lesson"))
def cmd_lesson(args):
"""Lessons (<track>/lessons.py): guided labs on a host of your own, with no clock.
./ksim lesson | lesson OBJ | lesson DOMAIN [--all] | lesson next | lesson step|check|hint|show [N]
| lesson audit [OBJ..]"""
./ksim lesson | lesson OBJ | lesson step|check|hint|show [N] | lesson audit [OBJ..]"""
sub = args[0] if args else ""
if sub == "audit":
return lesson_audit(args[1:])
st = load_state()
live = lesson_of(st) if st and not st.get("ended_at") else None
if sub == "next":
if not live or not st.get("domain"):
die("No run of lessons in progress. ./ksim lesson DOMAIN starts one: a domain's lessons, back to back.")
nxt = next_lesson(st)
if not nxt:
die(f"Lesson {live['objective']} is the last of the run. ./ksim end finishes it")
move_to_lesson(st, nxt)
return print_lesson(st, nxt)
if sub in ("check", "step", "hint", "show"):
if not live:
die("No lesson in progress. ./ksim lesson lists them; ./ksim lesson 2.1 starts one.")
@ -1787,7 +1712,7 @@ def cmd_lesson(args):
num = [a for a in args[1:] if a.isdigit()]
i = int(num[0]) - 1 if num else open_step(st, live)
if i is None:
return print_outro(live, params, st)
return print_outro(live, params)
if not 0 <= i < len(live["steps"]):
die(f"The lesson has steps 1 to {len(live['steps'])}.")
s = live["steps"][i]
@ -1808,37 +1733,28 @@ def cmd_lesson(args):
print(dim(" ./ksim lesson hint a hint ./ksim lesson show the commands"))
return
print(green(f"✓ Step {i + 1} done: {render(s['title'], params, live)}")); print()
st = load_state(); nxt = open_step(st, live)
return print_outro(live, params, st) if nxt is None else print_step(live, params, nxt)
if sub.upper() in DOMAINS: # a domain's run: its lessons not done yet, every host made up front
d = sub.upper(); ls = domain_lessons(d, "--all" in args)
if not ls:
die(f"{DOMAINS[d]} has no lessons yet.")
st = begin_session(ls, "lesson", 0, "--force" in args, domain=d)
return print_lesson(st, ls[0])
nxt = open_step(load_state(), live)
return print_outro(live, params) if nxt is None else print_step(live, params, nxt)
if sub:
l = find_lesson(sub)
if not l:
die(f"No lesson for '{sub}'. The {TRACK_ID.upper()} lessons: "
+ (", ".join(f"{x['objective']} ({x['title']})" for x in LESSONS) or "none yet") + " (./ksim lesson lists them)")
if live and st.get("domain") and l["id"] in st["ids"]: # a lesson of the run in progress: go there
move_to_lesson(st, l)
return print_lesson(st, l)
st = begin_session([l], "lesson", 0, "--force" in args, objective=l["objective"])
return print_lesson(st, l)
params = session_params(st, l)
print(); print(bold(f"Lesson {l['objective']} - {l['title']}")); print("-" * 64)
print(cli_md(render(l["intro"], params, l))); print("-" * 64)
print_step(l, params, 0, st)
return
if not LESSONS:
print(f"No lessons for {TRACK_ID.upper()} yet.")
return
done, by_domain = lessons_done(), {}
done = lessons_done()
print(bold(f"{TRACK['name']} - lessons") + dim(" guided labs on a host of your own, with no clock"))
for l in LESSONS:
by_domain.setdefault(l["domain"], []).append(l)
here = live is not None and l["id"] == live["id"]
print(f" {cyan('→') if here else green('✓') if l['objective'] in done else dim('·')} {l['objective']:<5} {l['title']}"
print(f" {green('✓') if l['objective'] in done else dim('·')} {l['objective']:<5} {l['title']}"
+ dim(f" {len(l['steps'])} steps · {DOMAINS.get(l['domain'], l['domain'])}"))
runs = ", ".join(f"{d} {sum(x['objective'] in done for x in ls)}/{len(ls)}" for d, ls in by_domain.items())
print(dim(f"\n ./ksim lesson {LESSONS[0]['objective']} starts that lesson ./ksim lesson audit proves them on real clusters"))
print(dim(f" ./ksim lesson {LESSONS[0]['domain']} a domain's lessons not done yet, back to back ({runs} done)"))
def lesson_audit(args):
"""Prove lessons on real clusters: each is seeded on a host of its own; then every step must
@ -2006,8 +1922,7 @@ def status_line(st):
l = lesson_of(st)
if l:
n = len(l["steps"]); done = n if open_step(st, l) is None else open_step(st, l)
ls = lessons_of(st); run = f"{st['domain']} lesson {ls.index(l) + 1}/{len(ls)}: " if st.get("domain") else ""
return green("lesson - no clock") + dim(f" [{run}{l['objective']} {done}/{n} steps checked]")
return green("lesson - no clock") + dim(f" [{done}/{n} steps checked]")
r = remaining(st)
if st.get("paused_at"):
return yellow(f"⏸ PAUSED - {fmt(r)} remaining") + dim(" ./ksim resume")
@ -2076,8 +1991,7 @@ def cmd_end(args):
if st.get("mode") == "lesson": # nothing to grade: the steps were checked as you went
for line in finish_lesson(st):
print(green(line) if line.startswith("✓") else line)
print(dim("A lesson writes nothing to the history or the scores. " + ("Its hosts stay" if st.get("domain") else "Its host stays")
+ " up until the next session."))
print(dim("A lesson writes nothing to the history or the scores. Its host stays up until the next session."))
return
down = hosts_down(st)
if down:
@ -2236,11 +2150,6 @@ class Api(http.server.BaseHTTPRequestHandler):
if not l:
die(f"No lesson for {body['lesson']}.")
begin_session([l], "lesson", 0, True, log=ulog, other_ok=False, objective=l["objective"])
elif body.get("teach"): # a domain's run of lessons
d = str(body["teach"]).upper(); ls = domain_lessons(d, bool(body.get("all"))) if d in DOMAINS else []
if not ls:
die(f"No lessons for domain {body['teach']}.")
begin_session(ls, "lesson", 0, True, log=ulog, other_ok=False, domain=d)
elif body.get("sim") in SETS:
begin_session(set_questions(body["sim"]), "exam", EXAM_SECONDS, True, log=ulog, hard=True, other_ok=False)
elif mode == "exam":
@ -2289,11 +2198,6 @@ class Api(http.server.BaseHTTPRequestHandler):
_, why = lesson_check(st, int(body.get("step", -1)))
if why:
return self._json({"error": why}, 400)
elif p.path == "/api/lesson/at": # another lesson of the run in progress
l = find_lesson(str(body.get("lesson", "")))
why = move_to_lesson(st, l) if l else "No such lesson."
if why:
return self._json({"error": why}, 400)
elif p.path == "/api/pause":
set_paused(st, bool(body.get("paused", True)))
elif p.path == "/api/restart":
@ -2360,15 +2264,8 @@ class Api(http.server.BaseHTTPRequestHandler):
"domains": self.domain_scores(st), "server_time": time.time()}
l = lesson_of(st)
if l:
payload["session"]["steps"] = steps_of(st, l) # the steps of the lesson you are on
payload["session"]["steps"] = st.get("steps", {})
payload["lesson"] = self.lesson_payload(l, session_params(st, l))
if st.get("domain"): # a domain's run: every lesson of it, and how far each one is
payload["course"] = {"domain": st["domain"], "name": DOMAINS[st["domain"]], "at": l["id"],
"lessons": [{"id": x["id"], "objective": x["objective"], "title": x["title"], "host": host_of(x),
"steps": [{"title": render(s["title"], session_params(st, x), x).strip(),
"pass": bool(steps_of(st, x).get(str(i), {}).get("pass"))}
for i, s in enumerate(x["steps"])]}
for x in lessons_of(st)]}
return payload
def lesson_payload(self, l: dict[str, Any], params: dict[str, Any]) -> dict[str, Any]:
@ -2683,8 +2580,6 @@ def cmd_help(args):
" drill OBJ an objective's tasks against the clock, those not yet passed first\n")
objective_help += "" if not LESSONS else (
f" lesson [OBJ] the track's lessons ({len(LESSONS)}), or start one: a guided lab on a host of your own, no clock\n"
" lesson DOMAIN [--all] teach a domain: its lessons not done yet (--all: every one) back to back, hosts made up front\n"
" lesson next | OBJ in a domain's run: on to its next lesson, or to any lesson of it\n"
" lesson step|check|hint|show [N] the step you are on, check it, a hint, the commands; ./ksim end finishes\n"
" lesson audit [OBJ..] prove lessons: every step fails before its commands and passes after them\n")
print(f"""usage: ./ksim [--track cka|ckad|cks] <command> (current track: {TRACK_ID}, {len(QUESTIONS)} tasks)

View File

@ -290,7 +290,6 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
.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; }
@ -405,11 +404,6 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
<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>
@ -521,7 +515,6 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
<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>
@ -689,14 +682,13 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
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();
paintPath(); paintDomains();
}
function paintPath() {
@ -744,23 +736,6 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
});
}
// 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 => {
@ -790,7 +765,7 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
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 || []).some(x => x.lesson)) o.push(['lesson', 'Take a lesson — a guided lab, 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']);
@ -808,9 +783,6 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
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');
@ -820,7 +792,6 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
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 = '';
@ -829,10 +800,6 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
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.`;
@ -849,7 +816,7 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
}
$('#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';
$('#pkStart').textContent = warn ? 'Start anyway' : v === 'lesson' ? 'Start lesson' : 'Start exam';
}
async function fillTaskPicker() {
const box = $('#pkTasks'), want = hard ? 'hard' : 'normal';
@ -864,7 +831,6 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
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);
@ -901,7 +867,6 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
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');
@ -913,20 +878,6 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
skew = Date.now() / 1000 - S.session.server_time; autoSubmitted = false; $('#vnc').dataset.ready = '';
cur = S.questions[0] && S.questions[0].id; lpage = fresh ? 0 : null; go('exam');
if (!fresh) return;
if (isLesson() && S.course) { // a domain's run: how it goes
const C = S.course, n = C.lessons.length;
$('#startTitle').textContent = `${C.name}: ${n} lesson${n > 1 ? 's' : ''}`;
$('#startBody').innerHTML = `<div class="alert alert-info"><b>No clock.</b> Take your time: the lessons teach, the drills test.</div>
<ul>
<li>every lesson has a host of its own, and all ${n} are up already: the top of each lesson says which to <code class="ic">ssh</code> to;</li>
<li>a lesson is an introduction, then steps - each with a <b>Check step</b> button, a hint, and the commands if you are stuck; steps count in order;</li>
<li>a lesson's wrap-up leads to the next one, and the list at the top goes to any lesson of the run;</li>
<li>a lesson counts as done the moment its last step is checked; nothing goes into your history or scores;</li>
<li>finish the run from <b>Controls</b>, then drill ${esc(C.domain)} against the clock.</li>
</ul>`;
openModal('#mStart');
return;
}
if (isLesson()) { // no clock to start: the dialog only says how a lesson goes
const L = S.lesson;
$('#startTitle').textContent = `Lesson ${L.objective}: ${L.title}`;
@ -966,15 +917,14 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
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>
? `<button class="dd-item" data-act="finishLesson">Finish lesson</button>
<div class="dd-sep"></div>
<button class="dd-item danger" data-act="terminate">Terminate ${what}</button>`
<button class="dd-item danger" data-act="terminate">Terminate lesson</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>`;
<button class="dd-item danger" data-act="terminate">Delete the host</button>`;
return;
}
$('#mnControls').innerHTML = live()
@ -1051,11 +1001,7 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
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;
}
if (isLesson()) { 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; }
}
@ -1067,21 +1013,11 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
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();
const L = S.lesson, sess = S.session, on = live(), n = L.steps.length;
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}`;
$('#xTitle').textContent = `${S.track_id.toUpperCase()} Lesson · ${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));
@ -1091,18 +1027,13 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
$('#qMenu').innerHTML = [...Array(n + 2).keys()].map(p => `<button class="dd-item ${p >= 1 && p <= n && stepDone(p - 1) ? 'pass' : ''}" data-lp="${p}">`
+ (p === 0 ? 'Introduction' : p === n + 1 ? `Wrap-up · ${checked}/${n} steps checked` : `Step ${p} · ${esc(L.steps[p - 1].title)}`) + '</button>').join('');
$$('#qMenu [data-lp]').forEach(b => b.onclick = () => { lpage = +b.dataset.lp; closeMenus(); paintExam(); $('#qContent').scrollTop = 0; });
const pick = !C ? '' : `<div class="line"><b>Lesson ${courseIdx() + 1} of ${C.lessons.length}</b> in ${esc(C.name)}:<select class="select" id="lessonPick">`
+ C.lessons.map(x => { const c = x.steps.filter(s => s.pass).length;
return `<option value="${x.id}"${x.id === C.at ? ' selected' : ''}>${c === x.steps.length ? '✓' : '·'} ${esc(x.objective)} ${esc(x.title)} (${c}/${x.steps.length})</option>`; }).join('')
+ '</select></div>';
let body = `<div class="qhead">${pick}<div class="line"><b>This lesson runs on:</b> <code class="ic" title="click to copy">${esc('ssh ' + L.host)}</code></div>
let body = `<div class="qhead"><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>`
+ (on ? `<div class="lstate"><button class="btn btn-primary" id="btnFinish">Finish 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;
@ -1120,12 +1051,10 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
copyable($('#qContent'));
const bs = $('#btnStep'); if (bs) bs.onclick = () => checkStep(lpage - 1);
const bf = $('#btnFinish'); if (bf) bf.onclick = finishLesson;
const bn = $('#btnNextLesson'); if (bn) bn.onclick = () => goLesson(nx.id);
const lp = $('#lessonPick'); if (lp) lp.onchange = () => goLesson(lp.value);
$('#btnFlag').className = 'btn btn-outline-warning hidden';
$('#btnBack').classList.remove('hidden'); $('#btnBack').disabled = lpage <= 0;
$('#btnSatisfied').innerHTML = (lpage === 0 ? 'Start: step 1' : lpage === n ? 'Wrap-up' : lpage === n + 1 ? (nx ? 'Next lesson' : 'Wrap-up') : 'Next step') + ' <span aria-hidden="true">→</span>';
$('#btnSatisfied').disabled = lpage >= n + 1 && !nx;
$('#btnSatisfied').innerHTML = (lpage === 0 ? 'Start: step 1' : lpage === n ? 'Wrap-up' : lpage === n + 1 ? 'Wrap-up' : 'Next step') + ' <span aria-hidden="true">→</span>';
$('#btnSatisfied').disabled = lpage >= n + 1;
paintDesk();
}
async function checkStep(k) {
@ -1135,12 +1064,10 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
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',
const n = S.lesson.steps.length, c = [...Array(n).keys()].filter(k => stepDone(k)).length;
if (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';
$('#busyTitle').textContent = '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', {}); }
@ -1196,12 +1123,9 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
}
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()
const ok = await ask(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>'}
body: '<p class="mb0">This deletes the lesson\'s host right away and ends the lesson. Nothing is recorded; 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>`}
@ -1214,7 +1138,7 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
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.`); }
if (on) { go('landing'); lobbyNote(`${what === 'lesson' ? 'Lesson' : '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() {
@ -1230,27 +1154,7 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
$('#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);
@ -1330,7 +1234,7 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
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),
finishLesson, retake: () => startLesson(S.lesson.objective),
};
document.addEventListener('click', e => {
const t = e.target.closest('.dd-toggle');
@ -1360,7 +1264,6 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
$('#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 () => {