feat: teach a domain, its lessons back to back
Add runs of a domain's lessons: ./ksim lesson ARCH, the lobby's Teach a domain card, or the Teach a domain session in the Start exam dialog. A run is one lesson session over the domain's lessons not done yet (--all: every one), each on its own host, all made before it starts, so there is no waiting between lessons. The session keeps the lesson you are on and each lesson's checks. A lesson's wrap-up leads to the next, the list at the top of a lesson goes to any lesson of the run, and ./ksim lesson next and ./ksim lesson OBJ do the same in a terminal. Finishing the run sends you to the domain's drill. Stamp a lesson done the moment its last step passes, not when the session ends, so terminating a lesson or a run keeps what it taught. Tested on a real STOR run in a copy of the repo: every host up before step 1; lesson 1.1 done step by step through the CLI and the web API, and stamped done as its last step passed; next, jump, a lesson outside the run refused, and the finish report as expected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c8b07bee09
commit
e852419130
17
CHANGELOG.md
17
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.
|
(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.
|
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
|
## 2026.10.26.1 — 2026-10-26
|
||||||
|
|
||||||
Everything below came in `cf4f5be`.
|
Everything below came in `cf4f5be`.
|
||||||
|
|||||||
20
README.md
20
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 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 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 [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 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 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
|
./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
|
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
|
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
|
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**
|
Start exam dialog, or `./ksim lesson 2.1`.
|
||||||
(`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,
|
**Teach a domain** runs a domain's lessons back to back: the ones you have not done, or every
|
||||||
volume expansion, snapshots and restores, eviction thresholds, debug containers, kube-proxy's
|
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
|
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
|
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
|
topics against the clock. The CKAD pilot
|
||||||
|
|||||||
@ -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 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 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 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
|
./ksim lesson audit [OBJ..] prove lessons: every step fails before its commands and passes after
|
||||||
|
|
||||||
|
|||||||
@ -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 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 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 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
|
./ksim lesson audit [OBJ..] prove lessons: every step fails before its commands and passes after
|
||||||
|
|
||||||
|
|||||||
183
ksim
183
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
|
# 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
|
# 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.
|
# 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):
|
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."""
|
"""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)
|
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)
|
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):
|
def lessons_of(st):
|
||||||
"""The lesson a session is, or None."""
|
"""The lessons of a lesson session, in order: one, or a domain's run."""
|
||||||
if not st or st.get("mode") != "lesson":
|
if not st or st.get("mode") != "lesson":
|
||||||
return None
|
return []
|
||||||
return next((l for l in LESSONS if l["id"] in st.get("ids", [])), None)
|
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():
|
def lessons_done():
|
||||||
"""objective id -> when its lesson was first done."""
|
"""objective id -> when its lesson was first done."""
|
||||||
return load_progress()["lessons"]
|
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):
|
def check_step(l, params, i):
|
||||||
"""Run the check of step i on the lesson's host."""
|
"""Run the check of step i on the lesson's host."""
|
||||||
try:
|
try:
|
||||||
@ -955,13 +984,14 @@ def check_step(l, params, i):
|
|||||||
|
|
||||||
def open_step(st, l):
|
def open_step(st, l):
|
||||||
"""The first step not checked yet, or None when every one is."""
|
"""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)
|
return next((i for i in range(len(l["steps"])) if not done.get(str(i), {}).get("pass")), None)
|
||||||
|
|
||||||
def lesson_check(st, i):
|
def lesson_check(st, i):
|
||||||
"""Check step i of the lesson in progress and keep the result. Steps count in order - a step
|
"""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
|
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)."""
|
Returns (result, None) or (None, why not)."""
|
||||||
l = lesson_of(st)
|
l = lesson_of(st)
|
||||||
if not l or st.get("ended_at"):
|
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:
|
if first is not None and i > first:
|
||||||
return None, f"Do step {first + 1} first: steps count in order."
|
return None, f"Do step {first + 1} first: steps count in order."
|
||||||
r = check_step(l, session_params(st, l), i)
|
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"):
|
if r["pass"] or not steps.get(str(i), {}).get("pass"):
|
||||||
steps[str(i)] = r
|
steps[str(i)] = r
|
||||||
save_state(st)
|
save_state(st)
|
||||||
|
if open_step(st, l) is None:
|
||||||
|
stamp_lesson(l)
|
||||||
return r, None
|
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):
|
def task_state(qid, agg):
|
||||||
"""passed = the latest attempt against the clock was a full pass; failed = it was not;
|
"""passed = the latest attempt against the clock was a full pass; failed = it was not;
|
||||||
new = never attempted."""
|
new = never attempted."""
|
||||||
@ -1481,7 +1527,8 @@ def open_sessions_elsewhere():
|
|||||||
pass
|
pass
|
||||||
return out
|
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()
|
st = load_state()
|
||||||
if st and not st.get("ended_at") and not force:
|
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.")
|
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
|
"objective": objective} # the curriculum objective a drill or a lesson was started for, if any
|
||||||
lesson = mode == "lesson"
|
lesson = mode == "lesson"
|
||||||
if 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)
|
save_state(st)
|
||||||
log("")
|
log("")
|
||||||
log(bold("=" * 64))
|
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")))
|
+ ("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 "")
|
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})")
|
+ 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")
|
log(f" Lesson : {objective} {qs[0]['title']} - {len(qs[0]['steps'])} steps")
|
||||||
else:
|
else:
|
||||||
log(f" Ends : {datetime.fromtimestamp(st['started_at'] + duration).strftime('%H:%M:%S')} ({duration // 60} min)")
|
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")
|
"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 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 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.")
|
+ f" Files go under {WS_DISPLAY}/ on the host.")
|
||||||
return st
|
return st
|
||||||
|
|
||||||
def finish_lesson(st):
|
def finish_lesson(st):
|
||||||
"""End a lesson: nothing is graded or recorded in the history; a lesson whose every step was
|
"""End a lesson session: nothing is graded or recorded in the history; each lesson whose every
|
||||||
checked is stamped done on its objective (the first time). Returns the lines to show."""
|
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()
|
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"))
|
d, lines = st.get("domain"), []
|
||||||
if done == n:
|
for l in lessons_of(st):
|
||||||
prog = load_progress()
|
n, done = len(l["steps"]), steps_checked(st, l)
|
||||||
prog["lessons"].setdefault(l["objective"], datetime.now().isoformat(timespec="minutes"))
|
if done == n:
|
||||||
save_progress(prog)
|
stamp_lesson(l)
|
||||||
lines = [f"✓ Lesson {l['objective']} done - {n}/{n} steps checked. Now prove it against the clock: ./ksim drill {l['objective']}"]
|
lines.append(f"✓ Lesson {l['objective']} done - {n}/{n} steps checked."
|
||||||
else:
|
+ ("" if d else f" Now prove it against the clock: ./ksim drill {l['objective']}"))
|
||||||
lines = [f"Lesson {l['objective']} finished with {done}/{n} steps checked - take it again any time: ./ksim lesson {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
|
st["milestones"] = lines
|
||||||
save_state(st)
|
save_state(st)
|
||||||
return lines
|
return lines
|
||||||
@ -1687,24 +1741,45 @@ def cli_md(text):
|
|||||||
|
|
||||||
def print_step(l, params, i, st=None):
|
def print_step(l, params, i, st=None):
|
||||||
s = l["steps"][i]
|
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(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(cli_md(render(s["text"], params, l)))
|
||||||
print(dim(" ./ksim lesson check when it is done ./ksim lesson hint / show if you are stuck"))
|
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(bold("Every step is checked.")); print("-" * 64)
|
||||||
print(cli_md(render(l["outro"], params, l))); 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):
|
def cmd_lesson(args):
|
||||||
"""Lessons (<track>/lessons.py): guided labs on a host of your own, with no clock.
|
"""Lessons (<track>/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 ""
|
sub = args[0] if args else ""
|
||||||
if sub == "audit":
|
if sub == "audit":
|
||||||
return lesson_audit(args[1:])
|
return lesson_audit(args[1:])
|
||||||
st = load_state()
|
st = load_state()
|
||||||
live = lesson_of(st) if st and not st.get("ended_at") else None
|
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 sub in ("check", "step", "hint", "show"):
|
||||||
if not live:
|
if not live:
|
||||||
die("No lesson in progress. ./ksim lesson lists them; ./ksim lesson 2.1 starts one.")
|
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()]
|
num = [a for a in args[1:] if a.isdigit()]
|
||||||
i = int(num[0]) - 1 if num else open_step(st, live)
|
i = int(num[0]) - 1 if num else open_step(st, live)
|
||||||
if i is None:
|
if i is None:
|
||||||
return print_outro(live, params)
|
return print_outro(live, params, st)
|
||||||
if not 0 <= i < len(live["steps"]):
|
if not 0 <= i < len(live["steps"]):
|
||||||
die(f"The lesson has steps 1 to {len(live['steps'])}.")
|
die(f"The lesson has steps 1 to {len(live['steps'])}.")
|
||||||
s = live["steps"][i]
|
s = live["steps"][i]
|
||||||
@ -1733,28 +1808,37 @@ def cmd_lesson(args):
|
|||||||
print(dim(" ./ksim lesson hint a hint ./ksim lesson show the commands"))
|
print(dim(" ./ksim lesson hint a hint ./ksim lesson show the commands"))
|
||||||
return
|
return
|
||||||
print(green(f"✓ Step {i + 1} done: {render(s['title'], params, live)}")); print()
|
print(green(f"✓ Step {i + 1} done: {render(s['title'], params, live)}")); print()
|
||||||
nxt = open_step(load_state(), live)
|
st = load_state(); nxt = open_step(st, live)
|
||||||
return print_outro(live, params) if nxt is None else print_step(live, params, nxt)
|
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:
|
if sub:
|
||||||
l = find_lesson(sub)
|
l = find_lesson(sub)
|
||||||
if not l:
|
if not l:
|
||||||
die(f"No lesson for '{sub}'. The {TRACK_ID.upper()} lessons: "
|
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)")
|
+ (", ".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"])
|
st = begin_session([l], "lesson", 0, "--force" in args, objective=l["objective"])
|
||||||
params = session_params(st, l)
|
return print_lesson(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:
|
if not LESSONS:
|
||||||
print(f"No lessons for {TRACK_ID.upper()} yet.")
|
print(f"No lessons for {TRACK_ID.upper()} yet.")
|
||||||
return
|
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"))
|
print(bold(f"{TRACK['name']} - lessons") + dim(" guided labs on a host of your own, with no clock"))
|
||||||
for l in LESSONS:
|
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'])}"))
|
+ 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"\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):
|
def lesson_audit(args):
|
||||||
"""Prove lessons on real clusters: each is seeded on a host of its own; then every step must
|
"""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)
|
l = lesson_of(st)
|
||||||
if l:
|
if l:
|
||||||
n = len(l["steps"]); done = n if open_step(st, l) is None else open_step(st, 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)
|
r = remaining(st)
|
||||||
if st.get("paused_at"):
|
if st.get("paused_at"):
|
||||||
return yellow(f"⏸ PAUSED - {fmt(r)} remaining") + dim(" ./ksim resume")
|
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
|
if st.get("mode") == "lesson": # nothing to grade: the steps were checked as you went
|
||||||
for line in finish_lesson(st):
|
for line in finish_lesson(st):
|
||||||
print(green(line) if line.startswith("✓") else line)
|
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
|
return
|
||||||
down = hosts_down(st)
|
down = hosts_down(st)
|
||||||
if down:
|
if down:
|
||||||
@ -2150,6 +2236,11 @@ class Api(http.server.BaseHTTPRequestHandler):
|
|||||||
if not l:
|
if not l:
|
||||||
die(f"No lesson for {body['lesson']}.")
|
die(f"No lesson for {body['lesson']}.")
|
||||||
begin_session([l], "lesson", 0, True, log=ulog, other_ok=False, objective=l["objective"])
|
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:
|
elif body.get("sim") in SETS:
|
||||||
begin_session(set_questions(body["sim"]), "exam", EXAM_SECONDS, True, log=ulog, hard=True, other_ok=False)
|
begin_session(set_questions(body["sim"]), "exam", EXAM_SECONDS, True, log=ulog, hard=True, other_ok=False)
|
||||||
elif mode == "exam":
|
elif mode == "exam":
|
||||||
@ -2198,6 +2289,11 @@ class Api(http.server.BaseHTTPRequestHandler):
|
|||||||
_, why = lesson_check(st, int(body.get("step", -1)))
|
_, why = lesson_check(st, int(body.get("step", -1)))
|
||||||
if why:
|
if why:
|
||||||
return self._json({"error": why}, 400)
|
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":
|
elif p.path == "/api/pause":
|
||||||
set_paused(st, bool(body.get("paused", True)))
|
set_paused(st, bool(body.get("paused", True)))
|
||||||
elif p.path == "/api/restart":
|
elif p.path == "/api/restart":
|
||||||
@ -2264,8 +2360,15 @@ class Api(http.server.BaseHTTPRequestHandler):
|
|||||||
"domains": self.domain_scores(st), "server_time": time.time()}
|
"domains": self.domain_scores(st), "server_time": time.time()}
|
||||||
l = lesson_of(st)
|
l = lesson_of(st)
|
||||||
if l:
|
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))
|
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
|
return payload
|
||||||
|
|
||||||
def lesson_payload(self, l: dict[str, Any], params: dict[str, Any]) -> dict[str, Any]:
|
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")
|
" drill OBJ an objective's tasks against the clock, those not yet passed first\n")
|
||||||
objective_help += "" if not LESSONS else (
|
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"
|
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 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")
|
" 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)
|
print(f"""usage: ./ksim [--track cka|ckad|cks] <command> (current track: {TRACK_ID}, {len(QUESTIONS)} tasks)
|
||||||
|
|||||||
139
web/index.html
139
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;
|
.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; }
|
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); }
|
.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; }
|
.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; }
|
.checkrow { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 16px; font-size: 13px; }
|
||||||
.checks { margin-top: 14px; background: var(--surface-2); border: 1px solid var(--line); border-radius: 6px; padding: 12px 14px; font-size: 14px; }
|
.checks { 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 === '
|
|||||||
<div class="ladder" id="ladder"></div>
|
<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>
|
<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>
|
||||||
|
<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">
|
<section class="card" id="domainCard">
|
||||||
<h2 class="section-title">Drill a domain</h2>
|
<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>
|
<p class="section-sub">Five tasks of one domain, against the clock. ✓ drilled at least once, » marked known.</p>
|
||||||
@ -515,6 +521,7 @@ 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="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"><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="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="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="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>
|
<div class="field hidden" id="pkPickRow"><label class="lbl">Tasks</label><div class="picker" id="pkTasks"></div></div>
|
||||||
@ -682,13 +689,14 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
|
|||||||
const ended = sess && sess.ended_at;
|
const ended = sess && sess.ended_at;
|
||||||
$('#navResults').classList.toggle('hidden', !(ended && !sess.aborted));
|
$('#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;
|
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>`; };
|
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.'
|
$('#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.aborted ? 'Your last session was terminated: nothing was graded or recorded.'
|
||||||
: sess.mode === 'lesson' && S.lesson ? lessonLine()
|
: 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>`;
|
: `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'); };
|
const lr = $('#lastResults'); if (lr) lr.onclick = e => { e.preventDefault(); go('results'); };
|
||||||
paintPath(); paintDomains();
|
paintPath(); paintTeach(); paintDomains();
|
||||||
}
|
}
|
||||||
|
|
||||||
function paintPath() {
|
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 `<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() {
|
function paintDomains() {
|
||||||
const drilled = S.drilled || {}, skipped = S.known || [];
|
const drilled = S.drilled || {}, skipped = S.known || [];
|
||||||
$('#coverage').innerHTML = Object.keys(S.track.domains).map(d => {
|
$('#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() {
|
function sessionOptions() {
|
||||||
const o = [];
|
const o = [];
|
||||||
if (S.path) o.push(['path', `Continue the path — ${S.path.next.cmd.replace('./ksim ', '')}`]);
|
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']);
|
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()]}`],
|
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']);
|
['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);
|
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('');
|
$('#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;
|
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('');
|
$('#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 = '';
|
$('#pkTasks').dataset.filled = '';
|
||||||
$('#hardPicker').checked = hard; $('#pkErr').classList.add('hidden');
|
$('#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;
|
const v = $('#pkSession').value, T = S.track, per = hard ? 5 : 6;
|
||||||
$('#pkObjRow').classList.toggle('hidden', v !== 'objective');
|
$('#pkObjRow').classList.toggle('hidden', v !== 'objective');
|
||||||
$('#pkLessonRow').classList.toggle('hidden', v !== 'lesson');
|
$('#pkLessonRow').classList.toggle('hidden', v !== 'lesson');
|
||||||
|
$('#pkTeachRow').classList.toggle('hidden', v !== 'teach');
|
||||||
$('#pkDomRow').classList.toggle('hidden', v !== 'domain');
|
$('#pkDomRow').classList.toggle('hidden', v !== 'domain');
|
||||||
$('#pkPickRow').classList.toggle('hidden', v !== 'pick');
|
$('#pkPickRow').classList.toggle('hidden', v !== 'pick');
|
||||||
let desc = '', warn = '';
|
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);
|
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.`;
|
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 === '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 === 'domain') desc = `Five tasks of that domain, ${per} minutes each, the ones you have seen least recently first. Feedback per task while you work.`;
|
||||||
else if (v === 'next') desc = `${T.domains[nextDomain()]} — 5 tasks, ${per} min each. Drills rotate through all domains before the exam.`;
|
else if (v === '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;
|
$('#pkDesc').textContent = desc;
|
||||||
$('#pkWarn').textContent = warn; $('#pkWarn').classList.toggle('hidden', !warn);
|
$('#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() {
|
async function fillTaskPicker() {
|
||||||
const box = $('#pkTasks'), want = hard ? 'hard' : 'normal';
|
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;
|
const v = $('#pkSession').value;
|
||||||
if (v === 'path') return continuePath();
|
if (v === 'path') return continuePath();
|
||||||
if (v === 'lesson') return startLesson($('#pkLesson').value);
|
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 === 'objective') return start('drill', null, false, false, null, $('#pkObjective').value);
|
||||||
if (v === 'domain') return start('drill', null, false, false, $('#pkDomain').value);
|
if (v === 'domain') return start('drill', null, false, false, $('#pkDomain').value);
|
||||||
if (v === 'next') return start('drill', null, true);
|
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});
|
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 startSim = name => launch({mode: 'exam', sim: name, hard: true});
|
||||||
const startLesson = obj => launch({lesson: obj});
|
const startLesson = obj => launch({lesson: obj});
|
||||||
|
const startTeach = d => launch({teach: d});
|
||||||
const isLesson = () => !!(S && S.lesson && S.session && S.session.mode === 'lesson');
|
const isLesson = () => !!(S && S.lesson && S.session && S.session.mode === 'lesson');
|
||||||
async function resumeSeeding() {
|
async function resumeSeeding() {
|
||||||
polling = true; openModal('#mPrep');
|
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 = '';
|
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');
|
cur = S.questions[0] && S.questions[0].id; lpage = fresh ? 0 : null; go('exam');
|
||||||
if (!fresh) return;
|
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
|
if (isLesson()) { // no clock to start: the dialog only says how a lesson goes
|
||||||
const L = S.lesson;
|
const L = S.lesson;
|
||||||
$('#startTitle').textContent = `Lesson ${L.objective}: ${L.title}`;
|
$('#startTitle').textContent = `Lesson ${L.objective}: ${L.title}`;
|
||||||
@ -917,14 +966,15 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
|
|||||||
function paintMenus() {
|
function paintMenus() {
|
||||||
const sess = S.session;
|
const sess = S.session;
|
||||||
if (isLesson()) {
|
if (isLesson()) {
|
||||||
|
const what = S.course ? 'the run' : 'lesson';
|
||||||
$('#mnControls').innerHTML = live()
|
$('#mnControls').innerHTML = live()
|
||||||
? `<button class="dd-item" data-act="finishLesson">Finish lesson</button>
|
? `<button class="dd-item" data-act="finishLesson">Finish ${what}</button>
|
||||||
<div class="dd-sep"></div>
|
<div class="dd-sep"></div>
|
||||||
<button class="dd-item danger" data-act="terminate">Terminate lesson</button>`
|
<button class="dd-item danger" data-act="terminate">Terminate ${what}</button>`
|
||||||
: `<button class="dd-item" data-act="results">Back to the summary</button>
|
: `<button class="dd-item" data-act="results">Back to the summary</button>
|
||||||
<button class="dd-item" data-act="dashboard">Back to the dashboard</button>
|
<button class="dd-item" data-act="dashboard">Back to the dashboard</button>
|
||||||
<div class="dd-sep"></div>
|
<div class="dd-sep"></div>
|
||||||
<button class="dd-item danger" data-act="terminate">Delete the host</button>`;
|
<button class="dd-item danger" data-act="terminate">Delete the host${S.course ? 's' : ''}</button>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$('#mnControls').innerHTML = live()
|
$('#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'; }
|
catch (e) { alert(e.message); b.disabled = false; b.textContent = 'Check this task'; }
|
||||||
}
|
}
|
||||||
function step(d) {
|
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;
|
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; }
|
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));
|
const n = S.lesson.steps.length, k = [...Array(n).keys()].find(i => !stepDone(i));
|
||||||
return k === undefined ? n + 1 : k + 1;
|
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() {
|
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();
|
if (lpage === null) lpage = lessonResume();
|
||||||
lpage = Math.max(0, Math.min(lpage, n + 1));
|
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`;
|
document.title = `Lesson ${L.objective} · Kubestronaut exam simulator`;
|
||||||
paintMenus();
|
paintMenus();
|
||||||
const checked = [...Array(n).keys()].filter(k => stepDone(k)).length, firstOpen = [...Array(n).keys()].find(k => !stepDone(k));
|
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 => `<button class="dd-item ${p >= 1 && p <= n && stepDone(p - 1) ? 'pass' : ''}" data-lp="${p}">`
|
$('#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('');
|
+ (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; });
|
$$('#qMenu [data-lp]').forEach(b => b.onclick = () => { lpage = +b.dataset.lp; closeMenus(); paintExam(); $('#qContent').scrollTop = 0; });
|
||||||
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>
|
const pick = !C ? '' : `<div class="line"><b>Lesson ${courseIdx() + 1} of ${C.lessons.length}</b> in ${esc(C.name)}:<select class="select" id="lessonPick">`
|
||||||
|
+ C.lessons.map(x => { const c = x.steps.filter(s => s.pass).length;
|
||||||
|
return `<option value="${x.id}"${x.id === C.at ? ' selected' : ''}>${c === x.steps.length ? '✓' : '·'} ${esc(x.objective)} ${esc(x.title)} (${c}/${x.steps.length})</option>`; }).join('')
|
||||||
|
+ '</select></div>';
|
||||||
|
let body = `<div class="qhead">${pick}<div class="line"><b>This lesson runs on:</b> <code class="ic" title="click to copy">${esc('ssh ' + L.host)}</code></div>
|
||||||
<div class="line sub">${L.nodes}-node cluster · files in ${esc(S.workspace)} on that host · no clock</div></div><hr>`;
|
<div class="line sub">${L.nodes}-node cluster · files in ${esc(S.workspace)} on that host · no clock</div></div><hr>`;
|
||||||
if (lpage === 0) {
|
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>`;
|
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) {
|
} 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>`
|
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"><button class="btn btn-primary" id="btnFinish">Finish lesson</button>`
|
+ (on ? `<div class="lstate">${nx ? `<button class="btn btn-primary" id="btnNextLesson">Next lesson · ${esc(nx.objective)} ${esc(nx.title)}</button>` : ''}`
|
||||||
|
+ `<button class="btn ${nx ? 'btn-outline-primary' : 'btn-primary'}" id="btnFinish">Finish ${C ? 'the run' : 'lesson'}</button>`
|
||||||
+ (checked < n ? `<span class="lock">${n - checked} step(s) not checked yet - the lesson counts as done only when every step is.</span>` : '') + '</div>' : '');
|
+ (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 {
|
} else {
|
||||||
const k = lpage - 1, s = L.steps[k], r = (sess.steps || {})[k], locked = firstOpen !== undefined && k > firstOpen;
|
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'));
|
copyable($('#qContent'));
|
||||||
const bs = $('#btnStep'); if (bs) bs.onclick = () => checkStep(lpage - 1);
|
const bs = $('#btnStep'); if (bs) bs.onclick = () => checkStep(lpage - 1);
|
||||||
const bf = $('#btnFinish'); if (bf) bf.onclick = finishLesson;
|
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';
|
$('#btnFlag').className = 'btn btn-outline-warning hidden';
|
||||||
$('#btnBack').classList.remove('hidden'); $('#btnBack').disabled = lpage <= 0;
|
$('#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') + ' <span aria-hidden="true">→</span>';
|
$('#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;
|
$('#btnSatisfied').disabled = lpage >= n + 1 && !nx;
|
||||||
paintDesk();
|
paintDesk();
|
||||||
}
|
}
|
||||||
async function checkStep(k) {
|
async function checkStep(k) {
|
||||||
@ -1064,10 +1135,12 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
|
|||||||
paintExam();
|
paintExam();
|
||||||
}
|
}
|
||||||
async function finishLesson() {
|
async function finishLesson() {
|
||||||
const n = S.lesson.steps.length, c = [...Array(n).keys()].filter(k => stepDone(k)).length;
|
const n = S.lesson.steps.length, c = [...Array(n).keys()].filter(k => stepDone(k)).length, C = S.course;
|
||||||
if (c < n && !(await ask({title: 'Finish the lesson?', ok: 'Finish lesson',
|
if (C ? courseDone() < C.lessons.length && !(await ask({title: 'Finish the run?', ok: 'Finish the run',
|
||||||
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;
|
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>`}))
|
||||||
$('#busyTitle').textContent = 'Finishing the lesson';
|
: c < n && !(await ask({title: 'Finish the lesson?', ok: 'Finish lesson',
|
||||||
|
body: `<p class="mb0">${c} of ${n} steps are checked. The lesson counts as done only when every step is - you can take it again any time.</p>`}))) return;
|
||||||
|
$('#busyTitle').textContent = C ? 'Finishing the run' : 'Finishing the lesson';
|
||||||
$('#busyBody').innerHTML = '<p class="mb0">Nothing is graded: the steps were checked as you went.</p>';
|
$('#busyBody').innerHTML = '<p class="mb0">Nothing is graded: the steps were checked as you went.</p>';
|
||||||
openModal('#mBusy');
|
openModal('#mBusy');
|
||||||
try { S = await api('/api/end', {}); }
|
try { S = await api('/api/end', {}); }
|
||||||
@ -1123,9 +1196,12 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
|
|||||||
}
|
}
|
||||||
async function terminate() {
|
async function terminate() {
|
||||||
const on = live(), what = isLesson() ? 'lesson' : 'session';
|
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: `<p class="mb0">This deletes the run's ${S.course.lessons.length} hosts right away and ends it. Lessons whose every step was checked stay done; nothing else is recorded.</p>`}
|
||||||
|
: on && isLesson()
|
||||||
? {title: 'Terminate the lesson', ok: 'Terminate lesson', okClass: 'btn-danger',
|
? {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 is recorded; 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 goes into your history or scores; you can take it again any time.</p>'}
|
||||||
: on
|
: on
|
||||||
? {title: 'Terminate session', ok: 'Terminate session', okClass: 'btn-danger',
|
? {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>`}
|
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>`}
|
||||||
@ -1138,7 +1214,7 @@ try { const t = localStorage.getItem('ksim.theme'); if (t === 'light' || t === '
|
|||||||
try { S = await api('/api/terminate', {}); }
|
try { S = await api('/api/terminate', {}); }
|
||||||
catch (e) { alert(e.message === 'not found' ? oldServer('Terminate session') : e.message); return; }
|
catch (e) { alert(e.message === 'not found' ? oldServer('Terminate session') : e.message); return; }
|
||||||
finally { closeModal('#mBusy'); }
|
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.'); }
|
else { route(); alert('The hosts are deleted. Your results and your history stay.'); }
|
||||||
}
|
}
|
||||||
function toggleFullscreen() {
|
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';
|
$('#btnDelete').textContent = lesson ? 'Delete the host' : 'Delete the hosts';
|
||||||
$('#resLabel').textContent = lesson ? 'Steps checked' : 'Points scored';
|
$('#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() {
|
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;
|
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`;
|
document.title = `Lesson ${L.objective} · Kubestronaut exam simulator`;
|
||||||
resultButtons(true);
|
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 */ } },
|
resetPanels: () => { $('#qpanel').style.width = ''; try { localStorage.removeItem('ksim.split'); } catch (e) { /* fine */ } },
|
||||||
desktopFullscreen: () => { const f = $('#term'); if (f && f.requestFullscreen) f.requestFullscreen().catch(() => {}); },
|
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'); },
|
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 => {
|
document.addEventListener('click', e => {
|
||||||
const t = e.target.closest('.dd-toggle');
|
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);
|
$('#hardPicker').onchange = e => setHard(e.target.checked);
|
||||||
$('#pkSession').onchange = pickerChanged;
|
$('#pkSession').onchange = pickerChanged;
|
||||||
$('#pkLesson').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; };
|
$('#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;
|
$('#pkStart').onclick = pickerStart;
|
||||||
$('#btnBegin').onclick = async () => {
|
$('#btnBegin').onclick = async () => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user