Compare commits
2 Commits
c8b07bee09
...
accb26159e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
accb26159e | ||
|
|
e852419130 |
19
CHANGELOG.md
19
CHANGELOG.md
@ -4,6 +4,25 @@ 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.
|
||||||
|
|
||||||
|
## 2026.10.26.2 — 2026-10-26
|
||||||
|
|
||||||
|
Everything below came in `e852419`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Teach a domain**: a domain's lessons you have not done, back to back in one session without
|
||||||
|
a clock - `./ksim lesson ARCH`, the lobby's *Teach a domain* card, or the *Teach a domain*
|
||||||
|
session in the Start exam dialog (`--all`, or a domain whose lessons are all done, brings every
|
||||||
|
one). Every lesson's host is made before the run starts, so there is no waiting between
|
||||||
|
lessons. A lesson's wrap-up leads to the next, the list at the top of a lesson goes to any lesson
|
||||||
|
of the run, and in a terminal `./ksim lesson next` and `./ksim lesson 4.3` do the same.
|
||||||
|
Finishing the run sends you to the domain's timed drill.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- A lesson counts as done the moment its last step is checked, not when you finish it, so
|
||||||
|
terminating a lesson or a run keeps the lessons it completed.
|
||||||
|
|
||||||
## 2026.10.26.1 — 2026-10-26
|
## 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
|
||||||
|
|
||||||
|
|||||||
185
ksim
185
ksim
@ -22,7 +22,7 @@ from collections.abc import Callable
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
VERSION = "2026.10.26.1"
|
VERSION = "2026.10.26.2"
|
||||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||||
sys.path.insert(0, ROOT)
|
sys.path.insert(0, ROOT)
|
||||||
from sim import desktop, envs # noqa: E402
|
from sim import desktop, envs # noqa: E402
|
||||||
@ -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