diff --git a/README.md b/README.md index 7a61def..5f2c926 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,8 @@ In the browser: the task on the left names its host; click `ssh cka0037` (or any value) to put it on the desktop's clipboard and paste in the terminal with **Ctrl+Shift+V**. Firefox in the desktop opens only the allowed documentation for the track (`KSIM_OPEN_WEB=1` lifts that). Submit grades every task on its own host; the hosts stay up for review until the next -session starts. +session starts. **Restart clock** in the top bar (`./ksim restart`) puts the session's full time +back on the clock without touching its hosts or your work; like Pause, it is recorded nowhere. Everything is also available from a terminal. A command is for the track named with `--track` or `KSIM_TRACK`; with neither, for the track whose session is under way, and for CKA when there is @@ -110,6 +111,7 @@ host name such as `ckad0013` only resolves in the desktop's terminal: from your ./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 list | show N | grade N | solution N | timer | end +./ksim pause | resume | restart # stop the clock, or put the session's full time back (hosts and work kept) ./ksim hosts [start] # the session's hosts; `start` brings them back after a reboot ./ksim ssh N # a shell on the host of task N from your own terminal ./ksim reset N # rebuild the host of task N from scratch diff --git a/ksim b/ksim index bb161d6..175ef46 100755 --- a/ksim +++ b/ksim @@ -22,7 +22,7 @@ from collections.abc import Callable from datetime import datetime from typing import Any -VERSION = "2026.10.15.1" +VERSION = "2026.10.15.2" ROOT = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, ROOT) from sim import desktop, envs # noqa: E402 @@ -299,6 +299,12 @@ def set_paused(st: dict[str, Any], on: bool) -> None: st["paused_total"] = float(st.get("paused_total", 0)) + (time.time() - float(st["paused_at"])); st["paused_at"] = None save_state(st) +def restart_clock(st: dict[str, Any]) -> None: + """The session's full time again, from now and running. Hosts, the work on them, results, + flags and notes stay as they are.""" + st["started_at"] = time.time(); st["paused_total"] = 0.0; st["paused_at"] = None + save_state(st) + def fmt(secs: float | int) -> str: secs_int = max(0, int(secs)) return f"{secs_int // 3600:02d}:{(secs_int % 3600) // 60:02d}:{secs_int % 60:02d}" @@ -1643,6 +1649,15 @@ def cmd_resume(args): st = require_started() set_paused(st, False); print(status_line(st)) +def cmd_restart(args): + """Start the clock again from the session's full time, keeping its hosts and your work. Like + pause, not recorded anywhere: the attempt's time used counts from the restart.""" + st = require_started() + if st.get("ended_at"): + die("Session already ended - nothing to restart. Start a new one: ./ksim drill, ./ksim start, ./ksim next") + restart_clock(st) + print(status_line(st) + dim(" clock restarted - the hosts and your work are as they were")) + def cmd_end(args): st = require_started() if st.get("ended_at"): @@ -1843,6 +1858,10 @@ class Api(http.server.BaseHTTPRequestHandler): flags.symmetric_difference_update({qid}); st["flags"] = sorted(flags); save_state(st) elif p.path == "/api/pause": set_paused(st, bool(body.get("paused", True))) + elif p.path == "/api/restart": + if st.get("ended_at"): + return self._json({"error": "This session has ended - there is no clock to restart."}, 400) + restart_clock(st) elif p.path == "/api/notes": st["notes"] = body.get("notes", ""); save_state(st) elif p.path == "/api/grade": @@ -2215,6 +2234,7 @@ def cmd_help(args): grade N|all grade one task (or all) per check - drills only (--force to peek in an exam) status / timer time remaining (timer = live countdown) pause / resume stop the clock (not recorded anywhere - but the real exam has no pause) + restart the clock from the top: the session's full time again, hosts and work kept (not recorded) end submit: grade per check, score, log attempt, update weak-spots weak what you keep getting wrong, with the failing checks history every attempt, scores, and per-domain trend @@ -2228,7 +2248,7 @@ def cmd_help(args): """) COMMANDS = {"start": cmd_start, "drill": cmd_drill, "list": cmd_list, "show": cmd_show, "grade": cmd_grade, - "status": cmd_status, "timer": cmd_timer, "pause": cmd_pause, "resume": cmd_resume, "end": cmd_end, "weak": cmd_weak, "history": cmd_history, + "status": cmd_status, "timer": cmd_timer, "pause": cmd_pause, "resume": cmd_resume, "restart": cmd_restart, "end": cmd_end, "weak": cmd_weak, "history": cmd_history, "reset": cmd_reset, "web": cmd_web, "abort": cmd_abort, "progress": cmd_progress, "solution": cmd_solution, "sim": cmd_sim, "version": cmd_version, "help": cmd_help, "build": cmd_build, "hosts": cmd_hosts, "ssh": cmd_ssh, "down": cmd_down, "audit": cmd_audit, "coverage": cmd_coverage, "objectives": cmd_objectives, "curriculum": cmd_objectives, "notes": cmd_notes, "next": cmd_next, "focus": cmd_focus, diff --git a/web/index.html b/web/index.html index bfd0795..60b87f0 100644 --- a/web/index.html +++ b/web/index.html @@ -170,6 +170,7 @@ --:--:-- + @@ -365,6 +366,7 @@ + (sess.objective ? ' ยท objective ' + sess.objective : '') : ''; $('#btnEnd').classList.toggle('hidden', !live); $('#btnPause').classList.toggle('hidden', !live); + $('#btnRestart').classList.toggle('hidden', !live); if (live) $('#btnPause').textContent = sess.paused ? 'Resume' : 'Pause'; $('#btnCheck').classList.toggle('hidden', !(live && sess.mode === 'drill')); $('#lobby').classList.toggle('hidden', !!live || (sess && sess.ended_at && !$('#results').classList.contains('hidden'))); @@ -598,6 +600,11 @@ $('#btnNewExam').onclick = () => { $('#results').classList.add('hidden'); $('#lobby').classList.remove('hidden'); $('#lobbyOpts').classList.remove('hidden'); }; $('#btnHistory').onclick = showHistory; $('#btnPause').onclick = async () => { S = await api('/api/pause', {paused: !S.session.paused}); skew = Date.now()/1000 - S.session.server_time; paint(); }; + $('#btnRestart').onclick = async () => { + if (!confirm(`Restart the clock at ${fmt(S.session.duration)}? The hosts and your work stay as they are.`)) return; + try { S = await api('/api/restart', {}); skew = Date.now()/1000 - S.session.server_time; autoSubmitted = false; paint(); } + catch (e) { alert(e.message === 'not found' ? 'This ./ksim web was started before "restart" existed. Stop it (Ctrl-C) and start it again.' : e.message); } + }; $('#btnCloseHist').onclick = () => $('#histOv').classList.add('hidden'); // draggable split const handle = $('#handle'), main = document.querySelector('main');