diff --git a/README.md b/README.md
index 5f2c926..9e07567 100644
--- a/README.md
+++ b/README.md
@@ -85,15 +85,34 @@ desktop; the VM host adds a 3.5 GB base disk and the 0.6 GB Ubuntu cloud image u
```
./ksim build all # once: the exam host image per Kubernetes minor, and the desktop image
./ksim build cks --vm # once, for CKS: also the VM host its Falco and AppArmor tasks need (~10 min)
-./ksim web # http://127.0.0.1:8080 — pick a track and a session in the lobby
+./ksim web # http://127.0.0.1:8080 — pick a track and a session on the landing page
```
-In the browser: the task on the left names its host; click `ssh cka0037` (or any highlighted
-value) to put it on the desktop's clipboard and paste in the terminal with **Ctrl+Shift+V**.
+The browser UI follows the layout of [CK-X](https://github.com/sailor-sh/CK-X) (itself modelled on
+the Linux Foundation's exam UI), written anew for this simulator:
+
+- **Landing page** — the track (CKA, CKAD, CKS) in the top bar; **Start exam** opens a dialog to
+ pick the session: the next step on the path, an objective, a domain, the next domain, the whole
+ bank, tasks of your choice, a simulator set or a full exam, with or without hard mode. Below it:
+ your path, a card per domain, and every objective with its notes and the "I know this — skip it"
+ links. While the clusters are built, an overlay shows their progress and the log.
+- **Exam screen** — a dark top bar with the clock and three menus: *Exam Interface* (fullscreen,
+ panel sizes, reconnect the desktop), *Exam Controls* (submit, pause, **Restart clock**,
+ **Terminate session**) and *Exam Information* (exam instructions, Kubernetes docs, your history).
+ On the left, `< Question N >` and each task headed by its host: click `ssh ckad0037` (or any
+ highlighted value) to put it on the desktop's clipboard and paste in the terminal with
+ **Ctrl+Shift+V**. **Flag for review** and **Satisfied with answer** sit under the task; a drill
+ adds **Check this task**, and a task's reference solution opens once it is graded. On the right,
+ the remote desktop. The clock starts when you click **Start** in the dialog that opens the session.
+- **Results page** — points and pass or fail, every task with its checks, the answers, what to drill
+ next, and a way back to the tasks to review them on their hosts.
+
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. **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.
+lifts that). Submitting grades every task on its own host; the hosts stay up for review until the
+next session starts, or until **Delete the hosts** on the results page. **Restart clock**
+(`./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. **Terminate session** ends a session without grading
+it and deletes its hosts, as `./ksim abort` does.
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
diff --git a/ksim b/ksim
index 175ef46..a37fe5e 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.2"
+VERSION = "2026.10.15.3"
ROOT = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, ROOT)
from sim import desktop, envs # noqa: E402
@@ -1340,6 +1340,15 @@ def clear_hosts(keep=()):
list(pool.map(envs.destroy, old))
return old
+def drop_hosts(st):
+ """Delete the hosts of one session's tasks and nothing else - the browser's Terminate session
+ and Delete the hosts, which must not reach another track's session."""
+ mine = {host_of(q) for q in session_questions(st)}
+ gone = [h for h in envs.existing() if h in mine]
+ with concurrent.futures.ThreadPoolExecutor(max_workers=PARALLEL) as pool:
+ list(pool.map(envs.destroy, gone))
+ return gone
+
def open_sessions_elsewhere():
"""Tracks other than this one with a session that has not ended. One session at a time: they
share the desktop, and a new session clears the hosts of the last one."""
@@ -1862,6 +1871,12 @@ class Api(http.server.BaseHTTPRequestHandler):
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/terminate": # an open session ends ungraded, as ./ksim abort; an ended one only loses its hosts
+ if Api.seeding:
+ return self._json({"error": "A session is being prepared - wait until it is ready, then terminate it."}, 409)
+ if not st.get("ended_at"):
+ st["ended_at"] = time.time(); st["aborted"] = True; save_state(st)
+ drop_hosts(st)
elif p.path == "/api/notes":
st["notes"] = body.get("notes", ""); save_state(st)
elif p.path == "/api/grade":
@@ -1911,7 +1926,7 @@ class Api(http.server.BaseHTTPRequestHandler):
"over_time": st.get("over_time", False), "flags": st.get("flags", []),
"paused": bool(st.get("paused_at")), "paused_total": paused_total(st),
"notes": st.get("notes", ""),
- "objective": st.get("objective"), "milestones": st.get("milestones", []),
+ "objective": st.get("objective"), "milestones": st.get("milestones", []), "aborted": bool(st.get("aborted")),
"results": st["results"], "score": {"earned": earned, "total": total, "pct": pct, "pass_pct": PASS_PCT},
"domains": self.domain_scores(st), "server_time": time.time()}
return payload
diff --git a/web/index.html b/web/index.html
index 60b87f0..7d3a6ba 100644
--- a/web/index.html
+++ b/web/index.html
@@ -5,293 +5,510 @@