diff --git a/CHANGELOG.md b/CHANGELOG.md index 52057e1..20a00bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ 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. +## Unreleased + +### 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`. diff --git a/README.md b/README.md index 9c7449f..ebbe76a 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,8 @@ 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 @@ -158,10 +160,20 @@ 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`. **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`. + +**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 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 diff --git a/cka/lessons.py b/cka/lessons.py index 1e6ec74..2ab35ef 100644 --- a/cka/lessons.py +++ b/cka/lessons.py @@ -5,6 +5,7 @@ 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 diff --git a/ckad/lessons.py b/ckad/lessons.py index 18bc8ab..bd823ba 100644 --- a/ckad/lessons.py +++ b/ckad/lessons.py @@ -5,6 +5,7 @@ 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 diff --git a/ksim b/ksim index 0d9f09a..81280fc 100755 --- a/ksim +++ b/ksim @@ -930,21 +930,50 @@ 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-.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 lesson_of(st): - """The lesson a session is, or 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 None - return next((l for l in LESSONS if l["id"] in st.get("ids", [])), None) + 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) 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: @@ -955,13 +984,14 @@ def check_step(l, params, i): def open_step(st, l): """The first step not checked yet, or None when every one is.""" - done = st.get("steps", {}) + done = steps_of(st, l) 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. + 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. Returns (result, None) or (None, why not).""" l = lesson_of(st) if not l or st.get("ended_at"): @@ -972,12 +1002,28 @@ 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", {}) + steps = st.setdefault("steps", {}).setdefault(str(l["id"]), {}) 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.""" @@ -1481,7 +1527,8 @@ 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): +def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg, hard=False, other_ok=None, objective=None, + domain=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.") @@ -1522,7 +1569,9 @@ 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"] = {} # step index -> its latest check (a pass sticks) + 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 save_state(st) log("") log(bold("=" * 64)) @@ -1530,7 +1579,9 @@ 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: + 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: 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)") @@ -1542,23 +1593,26 @@ 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") - + 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" + + (" ./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" Files go under {WS_DISPLAY}/ on the host.") return st def finish_lesson(st): - """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) + """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.""" st["ended_at"] = time.time() - 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: - 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 = [f"Lesson {l['objective']} finished with {done}/{n} steps checked - take it again any time: ./ksim lesson {l['objective']}"] + d, lines = st.get("domain"), [] + for l in lessons_of(st): + n, done = len(l["steps"]), steps_checked(st, l) + 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']}")) + 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}") st["milestones"] = lines save_state(st) return lines @@ -1687,24 +1741,45 @@ def cli_md(text): def print_step(l, params, i, st=None): s = l["steps"][i] - done = bool(st and st.get("steps", {}).get(str(i), {}).get("pass")) + done = bool(st and steps_of(st, l).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): +def print_outro(l, params, st): print(bold("Every step is checked.")); print("-" * 64) print(cli_md(render(l["outro"], params, l))); print("-" * 64) - print(dim(" ./ksim end finishes the lesson")) + 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) def cmd_lesson(args): """Lessons (/lessons.py): guided labs on a host of your own, with no clock. - ./ksim lesson | lesson OBJ | lesson step|check|hint|show [N] | lesson audit [OBJ..]""" + ./ksim lesson | lesson OBJ | lesson DOMAIN [--all] | lesson next | 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.") @@ -1712,7 +1787,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) + return print_outro(live, params, st) if not 0 <= i < len(live["steps"]): die(f"The lesson has steps 1 to {len(live['steps'])}.") s = live["steps"][i] @@ -1733,28 +1808,37 @@ 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() - nxt = open_step(load_state(), live) - return print_outro(live, params) if nxt is None else print_step(live, params, nxt) + 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]) 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"]) - 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 + return print_lesson(st, l) if not LESSONS: print(f"No lessons for {TRACK_ID.upper()} yet.") return - done = lessons_done() + done, by_domain = lessons_done(), {} print(bold(f"{TRACK['name']} - lessons") + dim(" guided labs on a host of your own, with no clock")) for l in LESSONS: - print(f" {green('✓') if l['objective'] in done else dim('·')} {l['objective']:<5} {l['title']}" + 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']}" + 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 @@ -1922,7 +2006,8 @@ 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) - return green("lesson - no clock") + dim(f" [{done}/{n} steps checked]") + 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]") r = remaining(st) if st.get("paused_at"): return yellow(f"⏸ PAUSED - {fmt(r)} remaining") + dim(" ./ksim resume") @@ -1991,7 +2076,8 @@ 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 host stays up until the next session.")) + 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.")) return down = hosts_down(st) if down: @@ -2150,6 +2236,11 @@ 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": @@ -2198,6 +2289,11 @@ 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": @@ -2264,8 +2360,15 @@ class Api(http.server.BaseHTTPRequestHandler): "domains": self.domain_scores(st), "server_time": time.time()} l = lesson_of(st) if l: - payload["session"]["steps"] = st.get("steps", {}) + payload["session"]["steps"] = steps_of(st, l) # the steps of the lesson you are on 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]: @@ -2580,6 +2683,8 @@ 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] (current track: {TRACK_ID}, {len(QUESTIONS)} tasks) diff --git a/web/index.html b/web/index.html index a1ef6fb..86b6414 100644 --- a/web/index.html +++ b/web/index.html @@ -290,6 +290,7 @@ 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; } @@ -404,6 +405,11 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
+

Drill a domain

Five tasks of one domain, against the clock. ✓ drilled at least once, » marked known.

@@ -515,6 +521,7 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
+ @@ -682,13 +689,14 @@ 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. View its summary`; return `Your last lesson, ${esc(S.lesson.objective)}, ended with ${c}/${n} steps checked. View its summary`; }; $('#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}%. View its results`; const lr = $('#lastResults'); if (lr) lr.onclick = e => { e.preventDefault(); go('results'); }; - paintPath(); paintDomains(); + paintPath(); paintTeach(); paintDomains(); } function paintPath() { @@ -736,6 +744,23 @@ 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 ``; + }).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 => { @@ -765,7 +790,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']); + if ((S.objectives || []).some(x => x.lesson)) o.push(['lesson', 'Take a lesson — a guided lab, no clock'], ['teach', 'Teach a domain — its lessons back to back, no clock']); if ((S.objectives || []).length) o.push(['objective', 'Drill an objective']); o.push(['domain', 'Drill a domain'], ['next', `Drill the next domain — ${S.track.domains[nextDomain()]}`], ['all', 'Drill the whole bank'], ['pick', 'Pick the tasks to drill']); @@ -783,6 +808,9 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === ' const withLesson = (S.objectives || []).filter(o => o.lesson); $('#pkLesson').innerHTML = withLesson.map(o => ``).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 ``; }).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]) => ``).join(''); $('#pkTasks').dataset.filled = ''; $('#hardPicker').checked = hard; $('#pkErr').classList.add('hidden'); @@ -792,6 +820,7 @@ 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 = ''; @@ -800,6 +829,10 @@ 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.`; @@ -816,7 +849,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' : 'Start exam'; + $('#pkStart').textContent = warn ? 'Start anyway' : v === 'lesson' ? 'Start lesson' : v === 'teach' ? 'Start the lessons' : 'Start exam'; } async function fillTaskPicker() { const box = $('#pkTasks'), want = hard ? 'hard' : 'normal'; @@ -831,6 +864,7 @@ 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); @@ -867,6 +901,7 @@ 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'); @@ -878,6 +913,20 @@ 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 = `
No clock. Take your time: the lessons teach, the drills test.
+ `; + 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}`; @@ -917,14 +966,15 @@ 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() - ? ` + ? `
- ` + ` : `
- `; + `; return; } $('#mnControls').innerHTML = live() @@ -1001,7 +1051,11 @@ 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()) { lpage = Math.max(0, Math.min(lpage + d, S.lesson.steps.length + 1)); paintExam(); $('#qContent').scrollTop = 0; return; } + if (isLesson()) { + const nx = nextLesson(); + if (d > 0 && lpage === S.lesson.steps.length + 1 && nx) return goLesson(nx.id); + lpage = Math.max(0, Math.min(lpage + d, S.lesson.steps.length + 1)); paintExam(); $('#qContent').scrollTop = 0; return; + } const i = S.questions.findIndex(q => q.id === cur), j = i + d; if (j >= 0 && j < S.questions.length) { cur = S.questions[j].id; paintExam(); $('#qContent').scrollTop = 0; } } @@ -1013,11 +1067,21 @@ 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; + const L = S.lesson, sess = S.session, on = live(), n = L.steps.length, C = S.course, nx = nextLesson(); if (lpage === null) lpage = lessonResume(); lpage = Math.max(0, Math.min(lpage, n + 1)); - $('#xTitle').textContent = `${S.track_id.toUpperCase()} Lesson · ${L.objective} ${L.title}`; + $('#xTitle').textContent = `${S.track_id.toUpperCase()} Lesson${C ? ` · ${C.domain} ${courseIdx() + 1}/${C.lessons.length}` : ''} · ${L.objective} ${L.title}`; document.title = `Lesson ${L.objective} · Kubestronaut exam simulator`; paintMenus(); const checked = [...Array(n).keys()].filter(k => stepDone(k)).length, firstOpen = [...Array(n).keys()].find(k => !stepDone(k)); @@ -1027,13 +1091,18 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === ' $('#qMenu').innerHTML = [...Array(n + 2).keys()].map(p => `').join(''); $$('#qMenu [data-lp]').forEach(b => b.onclick = () => { lpage = +b.dataset.lp; closeMenus(); paintExam(); $('#qContent').scrollTop = 0; }); - let body = `
This lesson runs on: ${esc('ssh ' + L.host)}
+ const pick = !C ? '' : `
Lesson ${courseIdx() + 1} of ${C.lessons.length} in ${esc(C.name)}:
'; + let body = `
${pick}
This lesson runs on: ${esc('ssh ' + L.host)}
${L.nodes}-node cluster · files in ${esc(S.workspace)} on that host · no clock

`; if (lpage === 0) { body += `
Lesson ${esc(L.objective)} · ${n} steps

${esc(L.title)}

${render(L.intro)}
`; } else if (lpage === n + 1) { body += `
Wrap-up · ${checked} of ${n} steps checked

${esc(L.title)}

${render(L.outro)}
` - + (on ? `
` + + (on ? `
${nx ? `` : ''}` + + `` + (checked < n ? `${n - checked} step(s) not checked yet - the lesson counts as done only when every step is.` : '') + '
' : ''); } else { const k = lpage - 1, s = L.steps[k], r = (sess.steps || {})[k], locked = firstOpen !== undefined && k > firstOpen; @@ -1051,10 +1120,12 @@ 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 ? 'Wrap-up' : 'Next step') + ' '; - $('#btnSatisfied').disabled = lpage >= n + 1; + $('#btnSatisfied').innerHTML = (lpage === 0 ? 'Start: step 1' : lpage === n ? 'Wrap-up' : lpage === n + 1 ? (nx ? 'Next lesson' : 'Wrap-up') : 'Next step') + ' '; + $('#btnSatisfied').disabled = lpage >= n + 1 && !nx; paintDesk(); } async function checkStep(k) { @@ -1064,10 +1135,12 @@ 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; - if (c < n && !(await ask({title: 'Finish the lesson?', ok: 'Finish lesson', - body: `

${c} of ${n} steps are checked. The lesson counts as done only when every step is - you can take it again any time.

`}))) return; - $('#busyTitle').textContent = 'Finishing the lesson'; + 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: `

${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.

`})) + : c < n && !(await ask({title: 'Finish the lesson?', ok: 'Finish lesson', + body: `

${c} of ${n} steps are checked. The lesson counts as done only when every step is - you can take it again any time.

`}))) return; + $('#busyTitle').textContent = C ? 'Finishing the run' : 'Finishing the lesson'; $('#busyBody').innerHTML = '

Nothing is graded: the steps were checked as you went.

'; openModal('#mBusy'); try { S = await api('/api/end', {}); } @@ -1123,9 +1196,12 @@ 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() + const ok = await ask(on && isLesson() && S.course + ? {title: 'Terminate the run', ok: 'Terminate the run', okClass: 'btn-danger', + body: `

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.

`} + : on && isLesson() ? {title: 'Terminate the lesson', ok: 'Terminate lesson', okClass: 'btn-danger', - body: '

This deletes the lesson\'s host right away and ends the lesson. Nothing is recorded; you can take it again any time.

'} + body: '

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.

'} : on ? {title: 'Terminate session', ok: 'Terminate session', okClass: 'btn-danger', body: `

Warning! Terminating this session will:

  • delete every host of this session right away;
  • end it without grading it - nothing is written to your history.

This cannot be undone. Terminate the session?

`} @@ -1138,7 +1214,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(`${what === 'lesson' ? 'Lesson' : 'Session'} terminated: nothing was graded or recorded, and its hosts are deleted.`); } + if (on) { go('landing'); lobbyNote(`${S.course ? 'Run of lessons' : what === 'lesson' ? 'Lesson' : 'Session'} terminated: nothing was graded, and its hosts are deleted.`); } else { route(); alert('The hosts are deleted. Your results and your history stay.'); } } function toggleFullscreen() { @@ -1154,7 +1230,27 @@ 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
Finished ${new Date(s.ended_at * 1000).toLocaleString()}
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 => `
${esc(m)}
`).join(''); + $('#resQuestions').innerHTML = C.lessons.map(x => { const k = x.steps.filter(t => t.pass).length; + return `

${esc(x.objective)} · ${esc(x.title)}

${k} of ${x.steps.length}
+
    ${x.steps.map((t, j) => `
  • Step ${j + 1} · ${esc(t.title)}${t.pass ? '✓' : '✗'}
  • `).join('')}
`; }).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); @@ -1234,7 +1330,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: () => startLesson(S.lesson.objective), + finishLesson, retake: () => S.course ? startTeach(S.course.domain) : startLesson(S.lesson.objective), }; document.addEventListener('click', e => { const t = e.target.closest('.dd-toggle'); @@ -1264,6 +1360,7 @@ 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 () => {