diff --git a/README.md b/README.md index 83560f2..9fad42e 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,11 @@ The dashboard is a React app in `web/`, built into the binary. Building it needs 2. Build the binary with the dashboard embedded: `go build -tags webdist -o rootcause ./cmd/rootcause`. 3. Run `./rootcause serve` and open http://localhost:7300 in a browser on Windows. Stop it with Ctrl-C. -The server listens on 127.0.0.1 only. WSL2 forwards `localhost` from Windows to it, so no `.wslconfig` change is needed. Work in the sandbox with `./rootcause shell` in a WSL terminal next to the browser. The dashboard follows the CLI: hints, checks and results from either side show up in both. A binary built without `-tags webdist` still runs `serve`, but shows a page that says how to build the dashboard. +The server listens on 127.0.0.1 only. WSL2 forwards `localhost` from Windows to it, so no `.wslconfig` change is needed. Work in the sandbox with `./rootcause shell` in a WSL terminal next to the browser; in the cluster the terminal is part of the page. + +The home page is an inbox of tickets with the recommended one on top. Press Ctrl+K anywhere for the command palette, Ctrl+Enter to run the check (in the terminal too), and `?` for all shortcuts. In the terminal, Tab completes; press Esc, then Tab, to move on with the keyboard. + +`scripts/ui-check.sh ` checks the dashboard in Windows' Chrome on this laptop: accessibility (axe-core, downloaded once into `~/.cache/rootcause` and checked against a pinned SHA-256), layout shift, the keyboard rules, the Tab order, terminal latency and the CSP, with screenshots in both languages. Point it at a server on a test database: it runs a real check. The dashboard follows the CLI: hints, checks and results from either side show up in both. A binary built without `-tags webdist` still runs `serve`, but shows a page that says how to build the dashboard. Exercises that boot systemd (such as svc-02) need a host with cgroup v2. WSL2 uses cgroup v2 by default. Check with `docker info --format '{{.CgroupVersion}}'`, which must print `2`. diff --git a/docs/SPEC.md b/docs/SPEC.md index 87eeec9..3b287c9 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -346,23 +346,31 @@ Every dbt container is labelled with its database. Before a build, containers le | `rootcause serve [--port N] [--listen-all]` | Starts the local dashboard on 127.0.0.1, port 7300 by default, until Ctrl-C. Reaps expired sessions every minute while it runs. `--listen-all` is for the cluster deployment only and is refused unless `RC_RUNTIME=kubernetes` | | `rootcause lang en\|no` | Sets the interface language | -## 11. Dashboard (v1) +## 11. Dashboard -- Home: recommended next exercise, topic levels, review queue. -- Exercise view: ticket, hint buttons, check, reset, give up, result and walkthrough. Shows the `rootcause shell` command, or, when the runtime has a web terminal (v2), the terminal itself. The terminal closes when its session ends. -- Home shows when the statistics were last built. -- History: past sessions with stars and outcome. -- Exercise list with best stars per exercise. -- EN/NO toggle. It sets the profile language, the same one `rootcause lang` sets. -- Own visual identity, light and dark mode, desktop-first. +Rebuilt in Phase 10 as a calm workbench: flat panels divided by hairlines, nearly square corners, Inter for text and JetBrains Mono for ids, commands and the terminal, a teal accent, light and dark (following the system unless switched). + +- Sidebar: Inbox, Exercises, History, Stats and the session in progress; the five topic level rings; the EN/NO and theme switches; the keyboard shortcuts. It collapses to icons (remembered per browser), and becomes a top bar on a phone. +- Inbox (home): the tickets as a helpdesk inbox, one row each with title and sender, topic, difficulty, a status tag (next, due, new, tried, solved with best stars) and the exercise id. The recommended ticket is pinned first, with why it was picked: the rule, the last result and date, and what would change that. A session in progress is pinned above everything. A first visit shows one short tip, which can be hidden. +- Session start: the engine's real steps (build the image, start the machine, set up, plant the fault) tick off as they happen. Only the exercise id and the step reach the browser. +- Session: the ticket and hints on the left, the work on the right: a check bar (Run check, Show the next hint with the stars it leaves, Reset, an optional elapsed-time timer, and Give up behind a menu, each destructive action confirmed in place), the last check's result in one line, the terminal (v2) or the `rootcause shell` command (v1), and a check log. A solve plays the app's one expressive animation, under 1.2 s, skippable, never taking focus from the terminal. The terminal closes when its session ends. +- Exercises: the catalog by topic with difficulty, best stars and when it was last played. +- History: finished sessions with outcome, stars, hints, checks and time, filtered by topic and outcome. +- Stats: the topic rings with progress toward the next level, solves per day (one-axis column chart with a data table), and per exercise the sessions, solve rate, hints, time and calibration flag, with "rebuild now" where the server builds the marts itself. +- Keyboard: Ctrl+K opens a command palette; Ctrl+Enter runs the check, from the terminal too; `g` then `i`, `e`, `h` or `s` goes to a page, `[` folds the sidebar and `?` lists the shortcuts. The single-key ones can be switched off and never fire while typing in a field, a dialog or the terminal. The terminal keeps Tab for completion; Esc, then Tab, leaves it. +- EN/NO switch. It sets the profile language, the same one `rootcause lang` sets. +- Accessibility is a floor: WCAG 2.2 AA, every task by keyboard alone, reduced motion respected, layouts that reflow at 320 px and at 200% zoom. Implementation: - The app (Vite, React, TypeScript, plain CSS, hash routing) lives in `web/`. `scripts/build-web.sh` builds it into `web/dist` with `npm ci` from the committed lockfile. `go build -tags webdist` embeds it. Without the tag, `/` serves a translated page that says how to build it. - `internal/server` is a JSON API over the same engine, selector and store as the CLI. Both front ends call the shared steps in `internal/play` (next pick and seed, exercise texts, statistics rebuild). Exercise texts are filled server-side in the learner's language. UI strings come from `locales/*.json` through `/api/i18n/{lang}`, so there is one copy of every string. -- Endpoints: `GET /api/overview`, `/api/exercises`, `/api/history`, `/api/session` (the active session, 204 if none), `/api/sessions/{id}`, `/api/i18n/{lang}`; `POST /api/start` (`{"exercise": ""}` starts the recommended one), `/api/hint`, `/api/check`, `/api/reset`, `/api/giveup`; `PUT /api/lang`; `GET /api/terminal` (WebSocket, v2). Actions name the session the page shows and are refused if it is no longer the active one. +- Endpoints: `GET /api/overview` (with the start in progress and each topic's progress toward the next level), `/api/exercises` (with the ticket header and the latest result), `/api/history`, `/api/stats`, `/api/session` (the active session, 204 if none), `/api/sessions/{id}`, `/api/i18n/{lang}`; `POST /api/start` (`{"exercise": ""}` starts the recommended one), `/api/hint`, `/api/check`, `/api/reset`, `/api/giveup`, `/api/stats/rebuild` (one rebuild at a time, 409 while one runs, 404 where a CronJob builds the marts); `PUT /api/lang`; `GET /api/terminal` (WebSocket, v2). Actions name the session the page shows and are refused if it is no longer the active one. +- Before a session exists, the inbox and the catalog show each ticket's header filled with the first value of each variant, a fixed display choice. A session shows its own variant. - The dashboard follows the CLI by polling: the open session every 2 seconds, the overview every few seconds and on focus. The two front ends share only the database. - The session view never contains the seed, variant values or derived values. Hints appear once revealed, and the walkthrough once the session is solved or given up. +- Everything the dashboard needs is in the binary: the app, its fonts (SIL OFL, licences next to the files) and its icons. It loads nothing from elsewhere, and the CSP did not change for it. The terminal's code loads only on a session page. +- `scripts/ui-check.sh` checks the dashboard in a real browser on the development laptop: axe-core accessibility rules, no layout shift when data arrives, the keyboard rules, the Tab order, terminal typing latency, the CSP, and screenshots in both languages at 1280, 390 and 320 px. It is not part of CI. ## 12. Languages @@ -370,6 +378,8 @@ Implementation: - Exercise texts live in `exercises//i18n/{en,no}.yaml`. - CI fails if a key exists in one language and not the other. - Code, comments, commit messages and docs are in English. +- Counts use a `_one` / `_other` pair of keys, the two plural forms English and Norwegian both have. +- Interface text is plain and warm: verbs first on buttons, praise only for what was done, at most one exclamation mark (on a solve without hints), no streaks, guilt or urgency, no em dashes. Layouts leave room for Norwegian, which runs longer. ## 13. Security model @@ -536,6 +546,13 @@ Done when: a merged change reaches the cluster without manual kubectl. - README with an architecture diagram. The 60-second demo recording moved to the end of Phase 10, so it shows the final dashboard (owner, 2026-10-27). - Architecture decision records in `docs/adr/` for each locked decision. +### Phase 10: Dashboard revamp +- The dashboard of section 11, rebuilt to the owner's UI agreement: sidebar, inbox, catalog, history with filters, stats with a chart, the session workbench, command palette and shortcuts. +- The real start steps, progress toward the next level, the statistics view and "rebuild now" in the API. +- The 60-second demo recording, made locally and kept off the internet while the repo is private. + +Done when: `scripts/ui-check.sh` passes in both languages: accessibility, no layout shift, reflow at 320 px and at 200% zoom, the keyboard rules (shortcuts, the palette, the check from the terminal, a way out of the terminal), the Tab order, terminal latency unchanged, CSP unchanged. + ## 19. Public readiness checklist The owner decides when the repo goes public. Ready means: diff --git a/scripts/ui-check.sh b/scripts/ui-check.sh new file mode 100755 index 0000000..5aa4114 --- /dev/null +++ b/scripts/ui-check.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# Checks the dashboard in a real browser (spec section 11): accessibility +# (axe-core, WCAG 2.2 AA rules), layout shift on data arrival, keyboard rules +# (shortcuts never reach the terminal or a field, the palette is a modal), +# the Tab order, terminal typing latency, the CSP header, and screenshots. +# A developer tool for this laptop, not CI: it runs Windows' Chrome headless +# with a throwaway profile, driven over the DevTools protocol on Windows' +# 127.0.0.1 by Windows' Node. scripts/ui/*.mjs use Node's built-ins only. +# +# Usage: scripts/ui-check.sh [out-dir] +# +# The server at base-url must use a test database: the keyboard check runs +# a real check on the active session. For the terminal checks it runs with +# RC_RUNTIME=kubernetes. Exit 0 only if every check passes. +set -euo pipefail + +[[ $# -ge 3 && $# -le 4 ]] || { echo "usage: ui-check.sh [out-dir]" >&2; exit 2; } +base="${1%/}" +active="$2" +finished="$3" +root="$(cd "$(dirname "$0")/.." && pwd)" +out="${4:-$(mktemp -d)}" +mkdir -p "$out" + +# axe-core, pinned: never in web/package.json, the bundle or the repo. +AXE_VERSION=4.14.0 +AXE_SHA256=20c09fe157a8a34a30e241aaa1fcdade657734f08ab379ecfbeb7d45cc46e878 +node="${RC_UI_NODE:-/mnt/c/Program Files/nodejs/node.exe}" +chrome="${RC_UI_CHROME:-/mnt/c/Program Files/Google/Chrome/Application/chrome.exe}" +port="${RC_UI_CDP_PORT:-9333}" + +for tool in "$node" "$chrome"; do + [[ -x "$tool" ]] || { echo "ui-check: not found: $tool (set RC_UI_NODE / RC_UI_CHROME)" >&2; exit 1; } +done +[[ -n "${WSL_DISTRO_NAME:-}" ]] || { echo "ui-check: runs in WSL, with Windows' Node and Chrome" >&2; exit 1; } +# winpath turns a WSL path into the UNC path Windows' Node can read. +winpath() { printf '%s\n' "\\\\wsl.localhost\\${WSL_DISTRO_NAME}${1//\//\\}"; } +win() { (cd /tmp && "$node" "$@") | tr -d '\r'; } + +axe="$HOME/.cache/rootcause/axe-core-$AXE_VERSION.min.js" +if ! echo "$AXE_SHA256 $axe" | sha256sum --check --status 2>/dev/null; then + mkdir -p "$(dirname "$axe")" + tmp="$(mktemp -d)" + curl -fsSL --retry 3 -o "$tmp/axe.tgz" "https://registry.npmjs.org/axe-core/-/axe-core-$AXE_VERSION.tgz" + tar -xzf "$tmp/axe.tgz" -C "$tmp" package/axe.min.js + echo "$AXE_SHA256 $tmp/package/axe.min.js" | sha256sum --check --status || { echo "ui-check: axe-core checksum mismatch" >&2; exit 1; } + mv "$tmp/package/axe.min.js" "$axe" + rm -rf "$tmp" +fi +cp "$axe" "$out/axe.min.js" + +# A throwaway headless Chrome; closed again on exit. +profile="$(cd /tmp && cmd.exe /c 'echo %TEMP%' 2>/dev/null | tr -d '\r')\\rootcause-ui-check" +"$chrome" --headless=new --remote-debugging-port="$port" --remote-debugging-address=127.0.0.1 \ + --user-data-dir="$profile" --no-first-run --no-default-browser-check --disable-extensions --hide-scrollbars about:blank >/dev/null 2>&1 & +cdp() { win -e "fetch('http://127.0.0.1:$port/json/version').then(r => r.json()).then(j => { $1 }).catch(() => process.exit(1))"; } +close_chrome() { + cdp "const ws = new WebSocket(j.webSocketDebuggerUrl); ws.onopen = () => { ws.send(JSON.stringify({ id: 1, method: 'Browser.close' })); setTimeout(() => process.exit(0), 300); };" >/dev/null 2>&1 || true +} +trap close_chrome EXIT +for i in $(seq 40); do + cdp "console.log(j.Browser)" >/dev/null 2>&1 && break + [[ "$i" -lt 40 ]] || { echo "ui-check: Chrome did not start" >&2; exit 1; } + sleep 0.5 +done + +# setlang switches the learner's language through the API, with the token +# from the served page on curl's stdin, never on its command line. +token="$(curl -fsS "$base/" | sed -n 's/.*name="rootcause-token" content="\([0-9a-f]*\)".*/\1/p')" +[[ -n "$token" ]] || { echo "ui-check: no token in $base/ (is the dashboard built in?)" >&2; exit 1; } +setlang() { + curl -fsS -o /dev/null --config - -X PUT -H "Content-Type: application/json" --data "{\"lang\":\"$1\"}" "$base/api/lang" <<<"header = \"X-RootCause-Token: $token\"" +} + +failures=0 +report() { # report : show it, count its FAIL lines + cat "$1" + failures=$((failures + $(grep -c '^FAIL' "$1" || true))) +} + +echo "== CSP" +csp="$(curl -fsSI "$base/" | sed -n 's/^[Cc]ontent-[Ss]ecurity-[Pp]olicy: //p' | tr -d '\r')" +# The constant is one string split over two lines: its line and the next. +want="$(awk '/^const ContentSecurityPolicy = / { getline next_line; print $0 next_line; exit }' "$root/internal/server/server.go" | + sed 's/^const ContentSecurityPolicy = "//; s/" +[[:space:]]*"//; s/"$//')" +if [[ "$csp" == "$want" ]]; then echo "PASS CSP is the server's constant: $csp"; else echo "FAIL CSP '$csp', the constant says '$want'"; failures=$((failures + 1)); fi + +pages="inbox=#/ exercises=#/exercises history=#/history stats=#/stats session=#/session/$active finished=#/session/$finished" +plan() { # plan + python3 - "$1" "$2" "$3" "$base" "$pages" <<'PY' +import json, sys +kind, lang, scheme, base, pages = sys.argv[1:6] +# 640 x 400 is a 1280 x 800 window at 200% zoom. +sizes = [(1280, 800), (640, 400), (390, 844), (320, 700)] +items = [] +for p in pages.split(): + name, route = p.split("=", 1) + for w, h in sizes: + items.append({"name": f"{name}-{lang}-{scheme}-{w}", "url": f"{base}/{route}", "width": w, "height": h, + "scheme": scheme, "wait": 4000, "waitFor": "main", "fullPage": True}) + if kind == "audit" and lang == "en": + items.append({"name": f"{name}-{lang}-{scheme}-1280-reduced", "url": f"{base}/{route}", "width": 1280, + "height": 800, "scheme": scheme, "motion": "reduce", "wait": 4000}) +print(json.dumps(items)) +PY +} + +for combo in "en light" "no dark"; do + read -r lang scheme <<<"$combo" + setlang "$lang" + echo "== audit: axe-core $AXE_VERSION and CLS, $lang $scheme" + plan audit "$lang" "$scheme" >"$out/audit-$lang.json" + win "$(winpath "$root/scripts/ui/audit.mjs")" "$port" "$(winpath "$out/axe.min.js")" "$(winpath "$out/audit-$lang.json")" >"$out/audit-$lang.log" || true + report "$out/audit-$lang.log" + echo "== screenshots, $lang $scheme" + mkdir -p "$out/shots" + plan shots "$lang" "$scheme" >"$out/shots-$lang.json" + win "$(winpath "$root/scripts/ui/shots.mjs")" "$port" "$(winpath "$out/shots")" "$(winpath "$out/shots-$lang.json")" | tee "$out/shots-$lang.log" + if grep -q '"horizontalOverflow":true\|consoleErrors":\[\[' "$out/shots-$lang.log"; then + echo "FAIL console errors or horizontal overflow (see above)" + failures=$((failures + 1)) + else + echo "PASS no console errors and no horizontal overflow at 1280, 640 (200% zoom), 390 and 320" + fi +done +setlang en + +echo "== keyboard: shortcuts, palette, terminal" +win "$(winpath "$root/scripts/ui/keys.mjs")" "$port" "$base/#/session/$active" >"$out/keys.log" || true +report "$out/keys.log" + +echo "== Tab order" +win "$(winpath "$root/scripts/ui/tabwalk.mjs")" "$port" "$base/#/session/$active" 18 | tee "$out/tabwalk.log" +if grep -v ' body ' "$out/tabwalk.log" | grep -q 'NO-OUTLINE'; then + echo "FAIL a focus stop has no visible outline" + failures=$((failures + 1)) +else + echo "PASS every focus stop shows its outline, and Esc then Tab leaves the terminal" +fi + +echo "== terminal" +win "$(winpath "$root/scripts/ui/latency.mjs")" "$port" "$base/#/session/$active" 50 stability | tee "$out/latency.log" +if grep -q 'same terminal element after .*: true' "$out/latency.log" && grep -q '50/50 echoed' "$out/latency.log"; then + echo "PASS every keystroke echoed, and the terminal was never remounted" +else + echo "FAIL terminal latency or stability (see above)" + failures=$((failures + 1)) +fi + +echo +echo "screenshots and logs: $out" +if [[ "$failures" -gt 0 ]]; then + echo "ui-check: $failures check(s) failed" + exit 1 +fi +echo "ui-check: every check passed" diff --git a/scripts/ui/audit.mjs b/scripts/ui/audit.mjs new file mode 100644 index 0000000..d03103a --- /dev/null +++ b/scripts/ui/audit.mjs @@ -0,0 +1,55 @@ +// Accessibility (axe-core) and layout shift (CLS) per page, over CDP. +// node audit.mjs +// plan.json: [{name, url, width, height, scheme, wait}] +import { readFileSync } from "node:fs"; + +const [port, axePath, planFile] = process.argv.slice(2); +const axe = readFileSync(axePath, "utf8"); +const plan = JSON.parse(readFileSync(planFile, "utf8")); +const target = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" })).json(); +const ws = new WebSocket(target.webSocketDebuggerUrl); +await new Promise((r) => (ws.onopen = r)); +let id = 0; +const pending = new Map(); +ws.onmessage = (e) => { + const m = JSON.parse(e.data); + if (m.id && pending.has(m.id)) { + pending.get(m.id)(m); + pending.delete(m.id); + } +}; +const send = (method, params = {}) => new Promise((r) => { const n = ++id; pending.set(n, r); ws.send(JSON.stringify({ id: n, method, params })); }); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const ev = async (expression) => { + const m = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }); + if (m.result?.exceptionDetails) throw new Error(JSON.stringify(m.result.exceptionDetails).slice(0, 400)); + return m.result?.result?.value; +}; + +await send("Page.enable"); +// Sum every layout shift from the first paint on, as CLS does. +await send("Page.addScriptToEvaluateOnNewDocument", { + source: `window.__cls = 0; window.__shifts = []; new PerformanceObserver((l) => { for (const e of l.getEntries()) if (!e.hadRecentInput) { window.__cls += e.value; window.__shifts.push(e.value.toFixed(4) + " " + (e.sources || []).map((s) => s.node ? (s.node.className || s.node.nodeName) : "?").join(", ")); } }).observe({ type: "layout-shift", buffered: true });`, +}); +let failures = 0; +for (const s of plan) { + await send("Emulation.setDeviceMetricsOverride", { width: s.width, height: s.height, deviceScaleFactor: 1, mobile: s.width < 600 }); + await send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-color-scheme", value: s.scheme ?? "light" }, { name: "prefers-reduced-motion", value: s.motion ?? "no-preference" }] }); + // A fresh document for every page: a hash change alone keeps the old one, + // and its layout shift count with it. + await send("Page.navigate", { url: "about:blank" }); + await sleep(200); + await send("Page.navigate", { url: s.url }); + await sleep(s.wait ?? 4000); + await ev(axe); + const res = await ev(`axe.run(document, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"] } }).then(r => r.violations.map(v => ({ id: v.id, impact: v.impact, nodes: v.nodes.length, help: v.help, target: v.nodes[0]?.target?.join(" ") })))`); + const cls = await ev("window.__cls"); + const bad = res.length > 0 || cls > 0; + failures += bad ? 1 : 0; + console.log(`${bad ? "FAIL" : "PASS"} ${s.name}: axe violations ${res.length}, CLS ${cls.toFixed(4)}`); + for (const v of res) console.log(` ${v.impact} ${v.id} (${v.nodes}): ${v.help} @ ${v.target}`); + if (cls > 0) for (const sh of await ev("window.__shifts")) console.log(` shift ${sh}`); +} +await fetch(`http://127.0.0.1:${port}/json/close/${target.id}`); +ws.close(); +console.log(failures ? `${failures} page(s) failed` : "every page passed"); diff --git a/scripts/ui/keys.mjs b/scripts/ui/keys.mjs new file mode 100644 index 0000000..a663c12 --- /dev/null +++ b/scripts/ui/keys.mjs @@ -0,0 +1,115 @@ +// Keyboard gate checks (U1, U2, U3) on a live session page, over CDP. +// node keys.mjs +const [port, url] = process.argv.slice(2); +const target = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" })).json(); +const ws = new WebSocket(target.webSocketDebuggerUrl); +await new Promise((r) => (ws.onopen = r)); +let id = 0; +const pending = new Map(); +ws.onmessage = (e) => { + const m = JSON.parse(e.data); + if (m.id && pending.has(m.id)) { + pending.get(m.id)(m); + pending.delete(m.id); + } +}; +const send = (method, params = {}) => new Promise((r) => { const n = ++id; pending.set(n, r); ws.send(JSON.stringify({ id: n, method, params })); }); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const ev = async (expression) => (await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true })).result?.result?.value; +const key = async (k, code, vk, modifiers = 0, text) => { + await send("Input.dispatchKeyEvent", { type: "keyDown", key: k, code, windowsVirtualKeyCode: vk, modifiers, text }); + await send("Input.dispatchKeyEvent", { type: "keyUp", key: k, code, windowsVirtualKeyCode: vk, modifiers }); + await sleep(60); +}; +const type = async (s) => { + for (const ch of s) await key(ch, ch === " " ? "Space" : `Key${ch.toUpperCase()}`, ch.toUpperCase().charCodeAt(0), 0, ch); +}; +const results = []; +const check = (name, ok, detail = "") => results.push(`${ok ? "PASS" : "FAIL"} ${name}${detail ? `: ${detail}` : ""}`); + +await send("Page.enable"); +await send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false }); +await send("Page.navigate", { url }); +for (let i = 0; i < 60 && !(await ev("!!document.querySelector('.xterm textarea')")); i++) await sleep(250); +await sleep(1500); +const rows = () => ev("document.querySelector('.xterm-rows').innerText.replace(/\\u00a0/g, ' ')"); +const checksText = () => ev("(document.querySelector('.checklog summary')?.innerText ?? document.querySelector('.workbench .note')?.innerText ?? '')"); + +// U2: Ctrl+Enter inside the terminal runs the check and the shell gets nothing. +await ev("document.querySelector('.xterm textarea').focus()"); +await key("u", "KeyU", 85, 2); // Ctrl+U: clear the line +await type("echo marker"); +await sleep(400); +const before = await rows(); +const checksBefore = await checksText(); +await key("Enter", "Enter", 13, 2); // Ctrl+Enter +await sleep(500); +const afterKey = await rows(); +for (let i = 0; i < 40 && (await checksText()) === checksBefore; i++) await sleep(250); +const checksAfter = await checksText(); +const line = (t) => t.split("\n").filter((l) => l.includes("echo marker")).join("|"); +check("U2 Ctrl+Enter in the terminal ran a check", checksAfter !== checksBefore, `"${checksBefore}" -> "${checksAfter}"`); +check("U2 the shell received nothing (no newline, no characters)", line(before) === line(afterKey) && !afterKey.includes("marker\nmarker"), `line "${line(afterKey)}"`); +check("U2 focus stayed in the terminal", await ev("!!document.activeElement?.closest('.xterm')")); + +// U1: "gi" typed in the terminal stays in the terminal. +await key("u", "KeyU", 85, 2); +const hashBefore = await ev("location.hash"); +await type("gi"); +await sleep(400); +check("U1 'gi' in the terminal did not navigate", (await ev("location.hash")) === hashBefore, await ev("location.hash")); +check("U1 'gi' reached the shell", (await rows()).includes("# gi"), ""); +await key("u", "KeyU", 85, 2); + +// U3: Ctrl+K from the terminal opens the palette; Esc closes it and returns focus. +await key("k", "KeyK", 75, 2); +await sleep(300); +check("U3 Ctrl+K in the terminal opened the palette", await ev("!!document.querySelector('dialog.dialog-palette[open]')")); +check("U3 the shell did not receive Ctrl+K", !(await rows()).includes("^K")); +check("U3 focus is in the palette input", await ev("document.activeElement?.id === 'palette-input'")); +check("U3 combobox semantics", await ev("(() => { const i = document.getElementById('palette-input'); return i.getAttribute('role') === 'combobox' && !!i.getAttribute('aria-activedescendant') && !!document.getElementById(i.getAttribute('aria-activedescendant')); })()")); +await key("ArrowDown", "ArrowDown", 40); +const active2 = await ev("document.getElementById('palette-input').getAttribute('aria-activedescendant')"); +check("U3 arrow keys move the active option", active2 === "palette-go-exercises", active2); +await key("Escape", "Escape", 27); +await sleep(300); +check("U3 Esc closed the palette", await ev("!document.querySelector('dialog.dialog-palette[open]')")); +check("U3 focus went back to the terminal", await ev("!!document.activeElement?.closest('.xterm')")); + +// U1 off switch and the palette's Enter: open the palette with Ctrl+K from the page, search, Enter. +await ev("document.activeElement?.blur()"); +await key("k", "KeyK", 75, 2); +await sleep(300); +await type("hist"); +await key("Enter", "Enter", 13); +await sleep(800); +check("U3 typing and Enter in the palette navigate", (await ev("location.hash")) === "#/history", await ev("location.hash")); + +// U1: "gi" in a History filter does nothing; outside fields it goes to the inbox. +await ev("document.querySelector('.filters select').focus()"); +await type("gi"); +await sleep(300); +check("U1 'gi' in a History filter did not navigate", (await ev("location.hash")) === "#/history", await ev("location.hash")); +await ev("document.activeElement?.blur()"); +await type("gi"); +await sleep(500); +check("U1 'gi' outside fields goes to the inbox", (await ev("location.hash")) === "#/", await ev("location.hash")); + +// U1 off switch: '?' opens the shortcuts; turning single keys off stops 'g h'. +await type("?"); +await sleep(300); +check("'?' opens the shortcut list", await ev("!!document.querySelector('dialog.dialog-shortcuts[open]')")); +await ev("document.querySelector('dialog.dialog-shortcuts input[type=checkbox]').click()"); +await key("Escape", "Escape", 27); +await sleep(300); +await ev("document.activeElement?.blur()"); +await type("gh"); +await sleep(400); +check("U1 with single keys off, 'g h' does nothing", (await ev("location.hash")) === "#/", await ev("location.hash")); +const pref = await ev("localStorage.getItem('rootcause.prefs')"); +check("U1 the off switch is remembered", (pref ?? "").includes('"shortcuts":false'), pref); +await ev("localStorage.removeItem('rootcause.prefs')"); + +console.log(results.join("\n")); +await fetch(`http://127.0.0.1:${port}/json/close/${target.id}`); +ws.close(); diff --git a/scripts/ui/latency.mjs b/scripts/ui/latency.mjs new file mode 100644 index 0000000..f98a399 --- /dev/null +++ b/scripts/ui/latency.mjs @@ -0,0 +1,63 @@ +// Terminal typing latency and terminal stability, over CDP. +// node latency.mjs [stability] +// Types n characters into the web terminal, one at a time, and times each +// from the key event to its echo in the terminal's rows. With "stability", +// it also marks the xterm element, reveals a hint and waits through polls, +// then checks the same element is still mounted. +const [port, url, nArg, mode] = process.argv.slice(2); +const n = Number(nArg || 50); +const target = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" })).json(); +const ws = new WebSocket(target.webSocketDebuggerUrl); +await new Promise((r) => (ws.onopen = r)); +let id = 0; +const pending = new Map(); +ws.onmessage = (e) => { + const m = JSON.parse(e.data); + if (m.id && pending.has(m.id)) { + pending.get(m.id)(m); + pending.delete(m.id); + } +}; +const send = (method, params = {}) => new Promise((r) => { const k = ++id; pending.set(k, r); ws.send(JSON.stringify({ id: k, method, params })); }); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const ev = async (expression) => (await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true })).result?.result?.value; + +await send("Page.enable"); +await send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false }); +await send("Page.navigate", { url }); +for (let i = 0; i < 80 && !(await ev("!!document.querySelector('.xterm textarea')")); i++) await sleep(250); +await sleep(2000); +await ev("document.querySelector('.xterm textarea').focus()"); +await send("Input.dispatchKeyEvent", { type: "keyDown", key: "u", code: "KeyU", windowsVirtualKeyCode: 85, modifiers: 2 }); +await send("Input.dispatchKeyEvent", { type: "keyUp", key: "u", code: "KeyU", windowsVirtualKeyCode: 85, modifiers: 2 }); +await sleep(500); + +// The page measures in its own clock: from the key's dispatch to the first +// frame in which the rows show one more character. +await ev(`window.__lat = []; window.__count = () => (document.querySelector('.xterm-rows').innerText.match(/x/g) || []).length;`); +const times = []; +for (let i = 0; i < n; i++) { + await ev("window.__t0 = performance.now(); window.__c0 = window.__count();"); + await send("Input.dispatchKeyEvent", { type: "keyDown", key: "x", code: "KeyX", windowsVirtualKeyCode: 88, text: "x" }); + await send("Input.dispatchKeyEvent", { type: "keyUp", key: "x", code: "KeyX", windowsVirtualKeyCode: 88 }); + const ms = await ev(`new Promise((done) => { const tick = () => { if (window.__count() > window.__c0) done(performance.now() - window.__t0); else if (performance.now() - window.__t0 > 2000) done(-1); else requestAnimationFrame(tick); }; tick(); })`); + times.push(ms); + await sleep(40); +} +await send("Input.dispatchKeyEvent", { type: "keyDown", key: "u", code: "KeyU", windowsVirtualKeyCode: 85, modifiers: 2 }); +await send("Input.dispatchKeyEvent", { type: "keyUp", key: "u", code: "KeyU", windowsVirtualKeyCode: 85, modifiers: 2 }); +const ok = times.filter((t) => t >= 0).sort((a, b) => a - b); +const q = (p) => ok[Math.min(ok.length - 1, Math.floor(p * ok.length))]; +console.log(`latency: ${ok.length}/${n} echoed, median ${q(0.5).toFixed(1)} ms, p90 ${q(0.9).toFixed(1)} ms, max ${ok[ok.length - 1].toFixed(1)} ms`); + +if (mode === "stability") { + await ev("document.querySelector('.xterm').dataset.mark = 'kept'"); + const hint = await ev(`(() => { const b = [...document.querySelectorAll('.checkbar button')].find(b => b.querySelector('.btn-note')); if (b) { b.click(); return true; } return false; })()`); + await sleep(6000); // a hint reveal, then three session polls + const kept = await ev("document.querySelector('.xterm')?.dataset.mark === 'kept'"); + const focused = await ev("!!document.activeElement?.closest('.xterm') || document.activeElement?.tagName"); + console.log(`stability: hint revealed ${hint}, same terminal element after 6 s of polls and the reveal: ${kept}`); + console.log(`stability: focus after the reveal: ${focused}`); +} +await fetch(`http://127.0.0.1:${port}/json/close/${target.id}`); +ws.close(); diff --git a/scripts/ui/shots.mjs b/scripts/ui/shots.mjs new file mode 100644 index 0000000..87d5d2e --- /dev/null +++ b/scripts/ui/shots.mjs @@ -0,0 +1,94 @@ +// Screenshot driver for the RootCause dashboard. Runs with Node's built-ins +// only (fetch, WebSocket) against a headless Chrome started with +// --remote-debugging-port on 127.0.0.1. No npm packages. +// +// node shots.mjs +// plan.json: [{name, url, width, height, scheme, motion, waitFor, wait, fullPage}] +import { writeFileSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const [port, outDir, planFile] = process.argv.slice(2); +const plan = JSON.parse(readFileSync(planFile, "utf8")); + +const target = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" })).json(); +const ws = new WebSocket(target.webSocketDebuggerUrl); +await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; }); +let id = 0; +const pending = new Map(); +const consoleErrors = []; +ws.onmessage = (ev) => { + const m = JSON.parse(ev.data); + if (m.id && pending.has(m.id)) { + const { resolve, reject } = pending.get(m.id); + pending.delete(m.id); + m.error ? reject(new Error(JSON.stringify(m.error))) : resolve(m.result); + } else if (m.method === "Runtime.exceptionThrown") { + consoleErrors.push(["exception", m.params.exceptionDetails.text, m.params.exceptionDetails.exception?.description]); + } else if (m.method === "Runtime.consoleAPICalled" && (m.params.type === "error" || m.params.type === "warning")) { + consoleErrors.push([m.params.type, m.params.args.map((a) => a.value ?? a.description).join(" ")]); + } else if (m.method === "Log.entryAdded" && (m.params.entry.level === "error" || m.params.entry.level === "warning")) { + consoleErrors.push(["log-" + m.params.entry.level, m.params.entry.text, m.params.entry.url]); + } +}; +const send = (method, params = {}) => new Promise((resolve, reject) => { + const n = ++id; + pending.set(n, { resolve, reject }); + ws.send(JSON.stringify({ id: n, method, params })); +}); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const evaluate = async (expression) => (await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true })).result.value; + +await send("Page.enable"); +await send("Network.enable"); +await send("Runtime.enable"); +await send("Log.enable"); + +const results = []; +for (const s of plan) { + consoleErrors.length = 0; + await send("Emulation.setDeviceMetricsOverride", { width: s.width, height: s.height, deviceScaleFactor: 1, mobile: s.width < 600 }); + await send("Emulation.setEmulatedMedia", { features: [ + { name: "prefers-color-scheme", value: s.scheme ?? "light" }, + { name: "prefers-reduced-motion", value: s.motion ?? "no-preference" }, + ] }); + if (s.theme && s.url !== "keep") { + // The app's own theme switch is stored in localStorage; set it before load. + await send("Page.navigate", { url: new URL("/", s.url).href }); + await sleep(500); + await evaluate(`localStorage.setItem("rootcause.theme", ${JSON.stringify(s.theme)})`); + } + await send("Network.emulateNetworkConditions", { offline: false, latency: s.latency ?? 0, downloadThroughput: -1, uploadThroughput: -1 }); + // "keep" stays on the current page (the app may have navigated itself). + if (s.url !== "keep") await send("Page.navigate", { url: s.url }); + const deadline = Date.now() + 15000; + if (s.waitFor) { + while (Date.now() < deadline && !(await evaluate(`!!document.querySelector(${JSON.stringify(s.waitFor)})`))) await sleep(200); + } + if (s.click) { + await evaluate(`document.querySelector(${JSON.stringify(s.click)})?.click()`); + await sleep(s.afterClick ?? 1000); + } + if (s.keys) { + for (const k of s.keys) { + await send("Input.dispatchKeyEvent", { type: "keyDown", key: k.key, code: k.code ?? k.key, modifiers: k.modifiers ?? 0, windowsVirtualKeyCode: k.vk ?? 0, text: k.text }); + await send("Input.dispatchKeyEvent", { type: "keyUp", key: k.key, code: k.code ?? k.key, modifiers: k.modifiers ?? 0, windowsVirtualKeyCode: k.vk ?? 0 }); + await sleep(80); + } + } + await sleep(s.wait ?? 800); + let clip; + if (s.fullPage) { + const h = await evaluate("Math.ceil(document.documentElement.scrollHeight)"); + clip = { x: 0, y: 0, width: s.width, height: Math.max(h, s.height), scale: 1 }; + } + const shot = await send("Page.captureScreenshot", { format: "png", captureBeyondViewport: !!s.fullPage, ...(clip ? { clip } : {}) }); + const file = join(outDir, `${s.name}.png`); + writeFileSync(file, Buffer.from(shot.data, "base64")); + const overflow = await evaluate("document.documentElement.scrollWidth > window.innerWidth"); + results.push({ name: s.name, file, consoleErrors: [...consoleErrors], horizontalOverflow: overflow, title: await evaluate("document.title") }); +} +writeFileSync(join(outDir, "results.json"), JSON.stringify(results, null, 2)); +await fetch(`http://127.0.0.1:${port}/json/close/${target.id}`); +ws.close(); +console.log(`${results.length} screenshots in ${outDir}`); +for (const r of results) if (r.consoleErrors.length || r.horizontalOverflow) console.log(r.name, JSON.stringify({ consoleErrors: r.consoleErrors, horizontalOverflow: r.horizontalOverflow })); diff --git a/scripts/ui/tabwalk.mjs b/scripts/ui/tabwalk.mjs new file mode 100644 index 0000000..f33f36e --- /dev/null +++ b/scripts/ui/tabwalk.mjs @@ -0,0 +1,29 @@ +// Walks the Tab order of a page and lists each stop, with whether a focus +// outline is visible. node tabwalk.mjs +const [port, url, stopsArg] = process.argv.slice(2); +const t = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" })).json(); +const ws = new WebSocket(t.webSocketDebuggerUrl); +await new Promise((r) => (ws.onopen = r)); +let id = 0; const p = new Map(); +ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && p.has(m.id)) { p.get(m.id)(m); p.delete(m.id); } }; +const send = (method, params = {}) => new Promise((r) => { const n = ++id; p.set(n, r); ws.send(JSON.stringify({ id: n, method, params })); }); +const ev = async (expression) => (await send("Runtime.evaluate", { expression, returnByValue: true })).result?.result?.value; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +await send("Page.enable"); +await send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false }); +await send("Page.navigate", { url }); +await sleep(4000); +const stops = []; +for (let i = 0; i < Number(stopsArg); i++) { + // In the terminal, Esc first: that is the way out of it. + if (await ev("!!document.activeElement?.closest('.xterm')")) { + await send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }); + await send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }); + } + await send("Input.dispatchKeyEvent", { type: "keyDown", key: "Tab", code: "Tab", windowsVirtualKeyCode: 9 }); + await send("Input.dispatchKeyEvent", { type: "keyUp", key: "Tab", code: "Tab", windowsVirtualKeyCode: 9 }); + await sleep(120); + stops.push(await ev(`(() => { const a = document.activeElement; if (!a) return "none"; const s = getComputedStyle(a); const outline = s.outlineStyle !== "none" && parseFloat(s.outlineWidth) > 0; const name = (a.getAttribute("aria-label") || a.innerText || a.value || a.className || a.tagName).trim().replace(/\\s+/g, " ").slice(0, 40); return (outline || a.closest(".xterm") ? "ok " : "NO-OUTLINE ") + a.tagName.toLowerCase() + " " + name; })()`)); +} +console.log(stops.map((s, i) => `${String(i + 1).padStart(2)} ${s}`).join("\n")); +await fetch(`http://127.0.0.1:${port}/json/close/${t.id}`); ws.close(); diff --git a/web/index.html b/web/index.html index 7cab8c2..f80c05b 100644 --- a/web/index.html +++ b/web/index.html @@ -6,6 +6,9 @@ + + + RootCause diff --git a/web/public/assets/fonts/Inter-OFL.txt b/web/public/assets/fonts/Inter-OFL.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/web/public/assets/fonts/Inter-OFL.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web/public/assets/fonts/InterVariable.woff2 b/web/public/assets/fonts/InterVariable.woff2 new file mode 100644 index 0000000..5a8d3e7 Binary files /dev/null and b/web/public/assets/fonts/InterVariable.woff2 differ diff --git a/web/public/assets/fonts/JetBrainsMono-Bold.woff2 b/web/public/assets/fonts/JetBrainsMono-Bold.woff2 new file mode 100644 index 0000000..4917f43 Binary files /dev/null and b/web/public/assets/fonts/JetBrainsMono-Bold.woff2 differ diff --git a/web/public/assets/fonts/JetBrainsMono-OFL.txt b/web/public/assets/fonts/JetBrainsMono-OFL.txt new file mode 100644 index 0000000..8bee414 --- /dev/null +++ b/web/public/assets/fonts/JetBrainsMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web/public/assets/fonts/JetBrainsMono-Regular.woff2 b/web/public/assets/fonts/JetBrainsMono-Regular.woff2 new file mode 100644 index 0000000..40da427 Binary files /dev/null and b/web/public/assets/fonts/JetBrainsMono-Regular.woff2 differ diff --git a/web/public/assets/fonts/README.md b/web/public/assets/fonts/README.md new file mode 100644 index 0000000..9b07285 --- /dev/null +++ b/web/public/assets/fonts/README.md @@ -0,0 +1,15 @@ +# Fonts + +Self-hosted, so the dashboard loads nothing from outside the binary. Both +families are under the SIL Open Font License 1.1, which allows bundling; each +license file sits next to its fonts. + +| File | Source | SHA-256 | +|---|---|---| +| InterVariable.woff2 | Inter 4.1, `web/InterVariable.woff2` in https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip | 693b77d4f32ee9b8bfc995589b5fad5e99adf2832738661f5402f9978429a8e3 | +| Inter-OFL.txt | `LICENSE.txt` in the same archive | 262481e844521b326f5ecd053e59b98c8b2da78c8ee1bdbb6e8174305e54935a | +| JetBrainsMono-Regular.woff2 | JetBrains Mono 2.304, `fonts/webfonts/JetBrainsMono-Regular.woff2` in https://github.com/JetBrains/JetBrainsMono/releases/download/v2.304/JetBrainsMono-2.304.zip | a9cb1cd82332b23a47e3a1239d25d13c86d16c4220695e34b243effa999f45f2 | +| JetBrainsMono-Bold.woff2 | `fonts/webfonts/JetBrainsMono-Bold.woff2` in the same archive | c503cc5ec5f8b2c7666b7ecda1adf44bd45f2e6579b2eba0fc292150416588a2 | +| JetBrainsMono-OFL.txt | `OFL.txt` in the same archive | 30f0c136e3c88e422d0791acd97238870f9054a9729bc34cf2ff0d4ed8cac4ad | + +To check them: `sha256sum web/public/assets/fonts/*.woff2 web/public/assets/fonts/*.txt`. diff --git a/web/src/App.tsx b/web/src/App.tsx index bb1f8fb..eb33b86 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,25 +1,37 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { api, type Lang, type Strings } from "./api"; -import { ErrorBanner, Header } from "./components"; import { usePoll, useRoute, useTheme } from "./hooks"; import { I18nContext, makeI18n } from "./i18n"; +import { usePrefs } from "./prefs"; +import { Palette, ShortcutsDialog, type PaletteItem } from "./shell/Overlays"; +import { paletteEvent, useShortcuts } from "./shell/keyboard"; +import { Sidebar } from "./shell/Sidebar"; +import { LiveRegionProvider } from "./ui/kit"; import { Exercises } from "./views/Exercises"; import { History } from "./views/History"; -import { Home } from "./views/Home"; +import { Inbox } from "./views/Inbox"; import { SessionView } from "./views/Session"; +import { Stats } from "./views/Stats"; // overviewPollMs is how often the app looks for a session the CLI started or // finished, and for statistics that finished rebuilding. const overviewPollMs = 4000; +// startingPollMs is how often it looks while a session is starting, to tick +// off its steps. +const startingPollMs = 500; export function App() { const route = useRoute(); const [theme, setTheme] = useTheme(); + const [prefs, setPrefs] = usePrefs(); const [lang, setLangState] = useState(); const [strings, setStrings] = useState(); const [bootError, setBootError] = useState(""); + const [startingHere, setStartingHere] = useState(false); + const [palette, setPalette] = useState(false); + const [help, setHelp] = useState(false); - const overview = usePoll(api.overview, overviewPollMs, lang ?? ""); + const overview = usePoll(api.overview, startingHere ? startingPollMs : overviewPollMs, lang ?? ""); // The learner's language lives in the profile, shared with the CLI. Follow // it when the CLI changes it. @@ -46,41 +58,114 @@ export function App() { ); }, []); - const i18n = useMemo( - () => (lang && strings ? makeI18n(lang, strings, setLang) : null), - [lang, strings, setLang], - ); + const i18n = useMemo(() => (lang && strings ? makeI18n(lang, strings, setLang) : null), [lang, strings, setLang]); + + useShortcuts({ + singleKeys: prefs.shortcuts, + go: useCallback((hash: string) => { + window.location.hash = hash; + }, []), + help: useCallback(() => setHelp(true), []), + toggleSidebar: useCallback(() => setPrefs({ sidebarCollapsed: !prefs.sidebarCollapsed }), [setPrefs, prefs.sidebarCollapsed]), + }); + useEffect(() => { + const open = () => setPalette(true); + window.addEventListener(paletteEvent, open); + return () => window.removeEventListener(paletteEvent, open); + }, []); + // Each page has its own title, and after a navigation focus moves to the + // page's heading, so a screen reader hears where it is. + const first = useRef(true); + const routeKey = route.name === "session" ? `session/${route.id}` : route.name; useEffect(() => { if (!i18n) return; document.documentElement.lang = i18n.lang === "no" ? "nb" : "en"; - document.title = i18n.t("web.app_name"); - }, [i18n]); + if (route.name !== "session") document.title = `${i18n.t(`web.title.${route.name}`)} · ${i18n.t("web.app_name")}`; + }, [i18n, route.name]); + useEffect(() => { + if (first.current) { + first.current = false; + return; + } + const id = window.setTimeout(() => document.getElementById("page-title")?.focus({ preventScroll: false }), 0); + return () => window.clearTimeout(id); + }, [routeKey]); + + const paletteActions = useMemo(() => { + if (!i18n) return []; + const { t } = i18n; + return [ + { id: "act-lang", icon: "flag", label: t("web.palette.switch_lang"), hint: lang === "no" ? "EN" : "NO", run: () => setLang(lang === "no" ? "en" : "no") }, + { id: "act-theme", icon: theme === "dark" ? "sun" : "moon", label: t("web.palette.switch_theme"), run: () => setTheme(theme === "dark" ? "light" : "dark") }, + { id: "act-sidebar", icon: "panel", label: t("web.shortcuts.sidebar"), hint: "[", run: () => setPrefs({ sidebarCollapsed: !prefs.sidebarCollapsed }) }, + { id: "act-help", icon: "keyboard", label: t("web.shortcuts.title"), hint: "?", run: () => setHelp(true) }, + ]; + }, [i18n, lang, theme, setLang, setTheme, setPrefs, prefs.sidebarCollapsed]); if (!i18n) { // No strings yet, so nothing translated can be shown. A failure here // means the server is gone; show the server's own error text if any. return (
- {bootError || overview.error ?

{bootError || overview.error}

: ); } const activeId = overview.data?.active?.id ?? null; + const offline = overview.error !== ""; return ( -
-
- {overview.error && } - - {route.name === "home" && } - {route.name === "exercises" && } - {route.name === "history" && } - {route.name === "session" && ( - - )} -
+ + + {i18n.t("web.skip")} + +
+ setPrefs({ sidebarCollapsed: c })} + onShortcuts={() => setHelp(true)} + /> +
+ {offline && ( +
+ {i18n.t("web.error.load")} +
+ )} + {bootError && ( +
+ {bootError} +
+ )} + {route.name === "home" && ( + + )} + {route.name === "exercises" && ( + + )} + {route.name === "history" && } + {route.name === "stats" && } + {route.name === "session" && ( + + )} +
+
+ setHelp(false)} singleKeys={prefs.shortcuts} onSingleKeys={(on) => setPrefs({ shortcuts: on })} /> + setPalette(false)} overview={overview.data} actions={paletteActions} /> +
); } diff --git a/web/src/WebTerminal.tsx b/web/src/WebTerminal.tsx index 3dbc99b..71747ee 100644 --- a/web/src/WebTerminal.tsx +++ b/web/src/WebTerminal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { memo, useEffect, useRef, useState } from "react"; import { Terminal } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; import { openTerminal } from "./api"; @@ -12,8 +12,9 @@ type State = { kind: "connecting" } | { kind: "open" } | { kind: "closed"; reaso // WebTerminal is a shell in the session's sandbox (v2): xterm.js on a // WebSocket to /api/terminal, where the server runs bash in the sandbox pod. // The socket closes with a reason when the shell exits or the session ends; -// the learner can open it again while the session is active. -export function WebTerminal({ sessionId }: { sessionId: string }) { +// the learner can open it again while the session is active. It is memoised +// on the session id, so nothing around it re-renders the terminal. +export const WebTerminal = memo(function WebTerminal({ sessionId }: { sessionId: string }) { const { t } = useI18n(); const box = useRef(null); const [state, setState] = useState({ kind: "connecting" }); @@ -23,14 +24,46 @@ export function WebTerminal({ sessionId }: { sessionId: string }) { const el = box.current; if (!el) return; setState({ kind: "connecting" }); + let disposed = false; + let cleanup = () => {}; + // Measure cells in the terminal's own font: wait until it has loaded. + void document.fonts.load('14px "JetBrains Mono"').finally(() => { + if (!disposed) cleanup = attach(el); + }); + return () => { + disposed = true; + cleanup(); + }; + }, [sessionId, attempt]); + + // attach opens the terminal in el and connects it; it returns the cleanup. + const attach = (el: HTMLDivElement) => { const term = new Terminal({ rows: ROWS, cursorBlink: true, - fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", + fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', fontSize: 14, scrollback: 2000, }); term.open(el); + // Ctrl+Enter (run the check) and Ctrl+K (the palette) are the app's: the + // shell never receives them. The key event goes on to the app's own + // listener, which acts on it. + // + // Tab completes in the shell, so the terminal would trap the keyboard + // (WCAG 2.1.2). Esc, then Tab or Shift+Tab, leaves it instead: the Esc + // still reaches the shell, the Tab moves focus on. + let afterEscape = false; + term.attachCustomKeyEventHandler((e) => { + if ((e.ctrlKey || e.metaKey) && (e.key === "Enter" || e.key.toLowerCase() === "k")) return false; + if (e.type !== "keydown") return true; + if (e.key === "Tab" && afterEscape) { + afterEscape = false; + return false; + } + afterEscape = e.key === "Escape"; + return true; + }); const ws = openTerminal(); ws.binaryType = "arraybuffer"; @@ -65,25 +98,26 @@ export function WebTerminal({ sessionId }: { sessionId: string }) { ws.close(); term.dispose(); }; - }, [sessionId, attempt]); + }; return (
- {state.kind === "connecting" &&

{t("web.terminal.connecting")}

} + {/* One status line, always there, so nothing below it moves. */} +

{state.kind === "connecting" ? t("web.terminal.connecting") : t("web.terminal.leave")}

{state.kind === "closed" && ( -
-

{t(closeText(state.reason))}

+
+

{t(closeText(state.reason))}

{state.reason !== "session_ended" && state.reason !== "no_session" && ( - )}
)}
); -} +}); // closeText maps the server's close reason to a message key. function closeText(reason: string): string { diff --git a/web/src/api.ts b/web/src/api.ts index 71fb0ec..eadc1f1 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -10,24 +10,59 @@ export type Strings = Record; export interface Overview { lang: Lang; active: { id: string; exercise: string } | null; + // A session start in progress: the engine's generic step, nothing more. + starting: { exercise: string; step: number; steps: number } | null; next: { exercise: string; + title: string; + from: string; topic: string; difficulty: number; reason: "review" | "weakest_topic"; level: number; } | null; source: "marts" | "fallback" | ""; - levels: { topic: string; level: number }[]; + levels: TopicLevel[]; review: { exercise: string; due_at: string; due_now: boolean }[] | null; stats: { fresh: boolean; updating: boolean; error: string; built_at: string | null }; } +// TopicLevel is a topic's level and the strong solves toward the next one +// (needed is 0 at the top level). +export interface TopicLevel { + topic: string; + level: number; + streak: number; + needed: number; +} + export interface ExerciseRow { id: string; + title: string; + from: string; topic: string; difficulty: number; best_stars: number | null; + last: { status: SessionStatus; stars: number | null; ended_at: string | null } | null; +} + +export interface StatsPage { + built: boolean; + exercises: { + id: string; + title: string; + topic: string; + difficulty: number; + sessions: number; + solves: number; + solve_rate: number | null; + avg_hints: number | null; + avg_solve_seconds: number | null; + calibration_flag: string | null; + }[]; + days: { day: string; sessions: number; solves: number; avg_stars: number | null }[]; + stats: Overview["stats"]; + can_rebuild: boolean; } export interface HistoryRow { @@ -108,6 +143,8 @@ export const api = { overview: () => request("GET", "/api/overview"), exercises: () => request("GET", "/api/exercises"), history: () => request("GET", "/api/history"), + stats: () => request("GET", "/api/stats"), + rebuild: () => request("POST", "/api/stats/rebuild"), active: () => request("GET", "/api/session"), session: (id: string) => request("GET", `/api/sessions/${encodeURIComponent(id)}`), strings: (lang: Lang) => request("GET", `/api/i18n/${lang}`), diff --git a/web/src/components.tsx b/web/src/components.tsx deleted file mode 100644 index 51dec60..0000000 --- a/web/src/components.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { useState } from "react"; -import type { Theme } from "./hooks"; -import type { Lang } from "./api"; -import { useI18n } from "./i18n"; - -// Mark is the RootCause logo: a trunk splitting into roots, one of them -// highlighted as the cause. -export function Mark({ size = 28 }: { size?: number }) { - return ( - - ); -} - -export function Stars({ n }: { n: number | null }) { - const { t } = useI18n(); - if (n === null) return -; - return ( - - {[1, 2, 3].map((i) => ( - - ))} - - ); -} - -export function LevelBar({ level }: { level: number }) { - return ( -
); } diff --git a/web/src/views/History.tsx b/web/src/views/History.tsx index 524f20d..bdb4731 100644 --- a/web/src/views/History.tsx +++ b/web/src/views/History.tsx @@ -1,61 +1,130 @@ -import { api } from "../api"; -import { ErrorBanner, Stars } from "../components"; +import { useMemo, useState } from "react"; +import { api, type SessionStatus } from "../api"; import { sessionHref, usePoll } from "../hooks"; import { useI18n } from "../i18n"; +import { Banner, EmptyState, Skeleton, Stars, TopicLabel, useDuration } from "../ui/kit"; +const topics = ["permissions", "services", "logs", "networking", "disk"]; +const outcomes: SessionStatus[] = ["solved", "gave_up", "expired", "voided"]; + +// History lists the finished sessions (spec section 11), newest first, with +// filters by topic and outcome. export function History() { - const { t, date, lang } = useI18n(); - const list = usePoll(api.history, 10000, lang); + const { t, date, num } = useI18n(); + const duration = useDuration(); + const list = usePoll(api.history, 15000, "history"); + const catalog = usePoll(api.exercises, 60000, "history-catalog"); + const [topic, setTopic] = useState(""); + const [outcome, setOutcome] = useState(""); - const duration = (start: string, end: string | null): string => { - if (!end) return "-"; - const minutes = Math.max(0, Math.round((Date.parse(end) - Date.parse(start)) / 60000)); - if (minutes < 60) return t("web.duration.minutes", { minutes }); - return t("web.duration.hours", { hours: Math.floor(minutes / 60), minutes: minutes % 60 }); - }; + const byId = useMemo(() => new Map((catalog.data ?? []).map((e) => [e.id, e])), [catalog.data]); + const rows = (list.data ?? []).filter((h) => (!topic || byId.get(h.exercise)?.topic === topic) && (!outcome || h.status === outcome)); return ( -
-

{t("web.history.title")}

- +
+
+

+ {t("web.history.title")} +

+
+ {list.error && !list.data && {list.error}} {!list.data ? ( -
+ ) : list.data.length === 0 ? ( -

{t("web.history.empty")}

+ + {t("web.history.empty_action")} + + } + > + {t("web.history.empty")} + ) : ( - - - - - - - - - - - - - - {list.data.map((h) => ( - - - - - - - - - - ))} - -
{t("web.col.ended")}{t("web.col.exercise")}{t("web.col.outcome")}{t("web.col.stars")}{t("web.col.hints")}{t("web.col.checks")}{t("web.col.duration")}
{h.ended_at ? date(h.ended_at) : "-"} - - {h.exercise} - - - {t(`web.outcome.${h.status}`)} - {h.status === "solved" ? : -}{h.hints}{h.checks}{duration(h.started_at, h.ended_at)}
+ <> +
+ + +

+ {t("web.filter.count", { shown: num(rows.length), total: num(list.data.length) })} +

+
+
+ + + + + + + + + + + + + + {rows.map((h) => { + const ex = byId.get(h.exercise); + return ( + + + + + + + + + + ); + })} + {rows.length === 0 && ( + + + + )} + +
{t("web.col.ended")}{t("web.col.ticket")}{t("web.col.outcome")}{t("web.col.stars")} + {t("web.col.hints")} + + {t("web.col.checks")} + + {t("web.col.duration")} +
{h.ended_at ? date(h.ended_at) : "-"} + + {ex?.title ?? h.exercise} + + + {ex && } {h.exercise} + + + {t(`web.outcome.${h.status}`)} + {h.status === "solved" ? : -}{num(h.hints)}{num(h.checks)}{h.ended_at ? duration((new Date(h.ended_at).getTime() - new Date(h.started_at).getTime()) / 1000) : "-"}
+ {t("web.filter.none")} +
+
+ )} -
+
); } diff --git a/web/src/views/Home.tsx b/web/src/views/Home.tsx deleted file mode 100644 index fe63326..0000000 --- a/web/src/views/Home.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { useState } from "react"; -import { api, type Overview } from "../api"; -import { ErrorBanner, LevelBar, TopicTag } from "../components"; -import { sessionHref } from "../hooks"; -import { useI18n } from "../i18n"; - -interface Props { - overview: Overview | undefined; - reloadOverview: () => void; -} - -export function Home({ overview, reloadOverview }: Props) { - const { t, date } = useI18n(); - const [starting, setStarting] = useState(false); - const [error, setError] = useState(""); - - if (!overview) return
; - - const start = () => { - setStarting(true); - setError(""); - api.start("").then( - (s) => { - reloadOverview(); - window.location.hash = sessionHref(s.id); - }, - (e: unknown) => { - setStarting(false); - setError(e instanceof Error ? e.message : String(e)); - reloadOverview(); - }, - ); - }; - - const { active, next } = overview; - return ( -
- - - {active ? ( -
-
-

-

-

{active.exercise}

-

{t("web.home.active_body", { exercise: active.exercise })}

-
- - {t("web.home.open")} - -
- ) : ( -
-
-

{t("web.home.next_title")}

- {next ? ( - <> -

{next.exercise}

-

- - {t("web.difficulty", { difficulty: next.difficulty })} -

-

- {next.reason === "review" - ? t("web.home.reason.review") - : t("web.home.reason.weakest_topic", { topic: t(`web.topic.${next.topic}`), level: next.level })} -

- - ) : ( -

{t("web.next.none")}

- )} - {starting &&

{t("web.home.starting")}

} -
- {next && ( - - )} -
- )} - - - -
-
-

{t("web.home.levels_title")}

-
    - {overview.levels.map((l) => ( -
  • - - - {t("web.home.level", { level: l.level })} -
  • - ))} -
-
- -
-

{t("web.home.review_title")}

- {overview.review === null ? ( -

{t("web.home.review_unavailable")}

- ) : overview.review.length === 0 ? ( -

{t("web.home.review_empty")}

- ) : ( -
    - {overview.review.map((r) => ( -
  • - {r.exercise} - - {r.due_now ? t("web.home.review_due_now") : t("web.home.review_due", { due: date(r.due_at) })} - -
  • - ))} -
- )} -
-
-
- ); -} - -// StatsNote says when the statistics are rebuilding, stale or failed, and -// when they were last built. -function StatsNote({ overview }: { overview: Overview }) { - return ( - <> - - - - ); -} - -// BuiltAt shows when dbt last built the statistics, so statistics that lag -// behind the latest session are visibly older. -function BuiltAt({ builtAt }: { builtAt: string | null }) { - const { t, date } = useI18n(); - if (!builtAt) return

{t("web.stats.never_built")}

; - return

{t("web.stats.built_at", { time: date(builtAt) })}

; -} - -function StatsState({ overview }: { overview: Overview }) { - const { t } = useI18n(); - const { stats, source } = overview; - if (stats.updating) { - return ( -

-

- ); - } - if (stats.error) { - return ( -
-

{t("web.stats.failed")}

-
{stats.error}
-
- ); - } - if (source === "fallback") return

{t("web.stats.fallback")}

; - return null; -} diff --git a/web/src/views/Inbox.tsx b/web/src/views/Inbox.tsx new file mode 100644 index 0000000..ae53599 --- /dev/null +++ b/web/src/views/Inbox.tsx @@ -0,0 +1,237 @@ +import { useMemo } from "react"; +import { api, type ExerciseRow, type Overview } from "../api"; +import { sessionHref, usePoll } from "../hooks"; +import { useI18n } from "../i18n"; +import type { Prefs } from "../prefs"; +import { Icon } from "../ui/icons"; +import { Banner, Button, Difficulty, Skeleton, Stars, TopicLabel } from "../ui/kit"; +import { StartProgress, useStart } from "./start"; + +interface Props { + overview: Overview | undefined; + offline: boolean; + prefs: Prefs; + setPrefs: (p: Partial) => void; + reloadOverview: () => void; + onStarting: (on: boolean) => void; +} + +type Tag = "next" | "due" | "new" | "solved" | "tried"; + +interface Row { + ex: ExerciseRow; + tag: Tag; + dueAt?: string; +} + +// Inbox is the home page (spec section 11): the tickets as a helpdesk inbox. +// The recommended one is pinned first with why it was picked; a session in +// progress is pinned above everything. +export function Inbox({ overview, offline, prefs, setPrefs, reloadOverview, onStarting }: Props) { + const { t } = useI18n(); + const list = usePoll(api.exercises, 15000, "inbox"); + const { start, busy, error } = useStart(reloadOverview, onStarting); + + const rows = useMemo(() => (overview && list.data ? inboxRows(overview, list.data) : null), [overview, list.data]); + const firstRun = list.data !== undefined && list.data.every((e) => e.last === null); + const active = overview?.active ?? null; + const starting = overview?.starting ?? null; + const locked = active !== null || starting !== null || busy !== null || offline; + + return ( +
+
+

+ {t("web.inbox.title")} +

+ {overview && } +
+ + {error && {error}} + + {firstRun && !prefs.introDismissed && ( +
+

{t("web.inbox.intro")}

+ +
+ )} + + {active && ( + + + )} + {active &&

{t("web.inbox.locked_active")}

} + + {!rows ? ( + + ) : ( +
    + {rows.map((r) => ( + start(r.tag === "next" ? "" : r.ex.id)} + starting={starting && starting.exercise === r.ex.id ? starting : busy === r.ex.id || (busy === "" && r.tag === "next") ? (starting ?? { exercise: r.ex.id, step: 0, steps: 4 }) : null} + /> + ))} +
+ )} + {overview?.source === "fallback" &&

{t("web.stats.fallback")}

} +
+ ); +} + +// inboxRows orders the catalog for the inbox: the recommended ticket, then +// the ones due for review, then the rest by id. +function inboxRows(o: Overview, exercises: ExerciseRow[]): Row[] { + const now = Date.now(); + const due = new Map((o.review ?? []).map((r) => [r.exercise, r])); + const rows: Row[] = exercises.map((ex) => { + const review = due.get(ex.id); + // A give-up stores 0 stars; a solve always has 1 or more. + let tag: Tag = ex.last === null ? "new" : (ex.best_stars ?? 0) > 0 ? "solved" : "tried"; + if (review && new Date(review.due_at).getTime() <= now) tag = "due"; + if (o.next?.exercise === ex.id) tag = "next"; + return { ex, tag, dueAt: review?.due_at }; + }); + const rank: Record = { next: 0, due: 1, new: 2, tried: 3, solved: 4 }; + return rows.sort((a, b) => rank[a.tag] - rank[b.tag] || a.ex.id.localeCompare(b.ex.id)); +} + +function TicketRow({ + row, + overview, + locked, + lockedReason, + busy, + onStart, + starting, +}: { + row: Row; + overview: Overview; + locked: boolean; + lockedReason?: string; + busy: boolean; + onStart: () => void; + starting: Overview["starting"]; +}) { + const { t } = useI18n(); + const { ex, tag } = row; + const next = tag === "next"; + return ( +
  • +
    +
    + {next && } + {starting && } +
  • + ); +} + +function StatusTag({ row }: { row: Row }) { + const { t, day } = useI18n(); + switch (row.tag) { + case "next": + return {t("web.tag.next")}; + case "due": + return {t("web.tag.due")}; + case "new": + return {t("web.tag.new")}; + case "solved": + return ( + + + + ); + default: + return row.dueAt ? {t("web.tag.due_on", { date: day(row.dueAt) })} : {t("web.tag.tried")}; + } +} + +// WhyPicked explains the recommendation: the rule that picked it, the last +// result on it, and what would change that. +function WhyPicked({ overview, ex }: { overview: Overview; ex: ExerciseRow }) { + const { t, tn, date } = useI18n(); + const next = overview.next; + if (!next) return null; + const level = overview.levels.find((l) => l.topic === next.topic); + const topic = t(`web.topic.${next.topic}`); + const rule = next.reason === "review" ? t("web.why.rule.review") : t("web.why.rule.weakest", { topic, level: next.level }); + const last = ex.last + ? t("web.why.last", { outcome: t(`web.outcome.${ex.last.status}`), date: ex.last.ended_at ? date(ex.last.ended_at) : "-" }) + : t("web.why.never"); + let after: string; + if (next.reason === "review") after = t("web.why.after.review"); + else if (level && level.needed > 0) after = tn("web.why.after.level", level.needed - level.streak, { topic, next: level.level + 1 }); + else after = t("web.why.after.top", { topic }); + return ( +
    + + + {t("web.why.summary")} + +
    +
    {t("web.why.rule_label")}
    +
    {rule}
    +
    {t("web.why.last_label")}
    +
    {last}
    +
    {t("web.why.after_label")}
    +
    {after}
    +
    +
    + ); +} + +// StatsLine is the one line about the statistics: building, failed, built at, +// or never built. +export function StatsLine({ overview }: { overview: Overview }) { + const { t, date } = useI18n(); + const s = overview.stats; + if (s.updating) + return ( +

    +

    + ); + if (s.error) return

    {t("web.stats.failed")}

    ; + if (!s.built_at) return

    {t("web.stats.never_built")}

    ; + return

    {t("web.stats.built_at", { time: date(s.built_at) })}

    ; +} diff --git a/web/src/views/Session.tsx b/web/src/views/Session.tsx index 2a9c73c..d224991 100644 --- a/web/src/views/Session.tsx +++ b/web/src/views/Session.tsx @@ -1,12 +1,18 @@ -import { useState } from "react"; +import { lazy, Suspense, useEffect, useRef, useState } from "react"; import { api, type Session } from "../api"; -import { CopyCommand, ErrorBanner, Stars, TopicTag } from "../components"; import { sessionHref, usePoll } from "../hooks"; import { useI18n } from "../i18n"; -import { WebTerminal } from "../WebTerminal"; +import type { Prefs } from "../prefs"; +import { checkEvent } from "../shell/keyboard"; +import { Icon } from "../ui/icons"; +import { Banner, Button, ConfirmButton, Difficulty, IconSwap, Prose, Stars, Time, TopicLabel, timeOf, useAnnounce, useDelayed } from "../ui/kit"; + +// The terminal (xterm.js) is the largest part of the bundle; it loads only +// when a session page with a web terminal opens. +const WebTerminal = lazy(() => import("../WebTerminal").then((m) => ({ default: m.WebTerminal }))); // sessionPollMs is how often an open session is re-read, so hints, checks and -// results from the CLI show up here (plan decision D2). +// results from the CLI show up here. const sessionPollMs = 2000; type Action = "hint" | "check" | "reset" | "giveup"; @@ -15,35 +21,49 @@ interface Props { id: string; activeId: string | null; activeExercise: string; + offline: boolean; + prefs: Prefs; + setPrefs: (p: Partial) => void; reloadOverview: () => void; } -export function SessionView({ id, activeId, activeExercise, reloadOverview }: Props) { - const { t, lang } = useI18n(); +export function SessionView({ id, activeId, activeExercise, offline, prefs, setPrefs, reloadOverview }: Props) { + const { t, tn, lang } = useI18n(); + const announce = useAnnounce(); const [open, setOpen] = useState(true); const polled = usePoll(() => api.session(id), sessionPollMs, `${id}/${lang}`, open); const [busy, setBusy] = useState(null); const [error, setError] = useState(""); const [lastCheck, setLastCheck] = useState(); + const [solvedHere, setSolvedHere] = useState(false); const s = polled.data; const isActive = s?.status === "active"; if (s && !isActive && open) setOpen(false); if (s && isActive && !open) setOpen(true); - if (polled.error && !s) return ; - if (!s) return
    ; + useEffect(() => { + if (s) document.title = `${s.ticket.title} · ${t("web.app_name")}`; + }, [s?.ticket.title, t]); const run = (action: Action) => { - if (action === "reset" && !window.confirm(t("web.session.reset_confirm"))) return; - if (action === "giveup" && !window.confirm(t("web.session.giveup_confirm"))) return; + if (!s || busy !== null || offline) return; setBusy(action); setError(""); const call = { hint: api.hint, check: api.check, reset: api.reset, giveup: api.giveUp }[action]; call(s.id).then( (next) => { polled.set(next); - if (action === "check") setLastCheck(next.check); + if (action === "check") { + setLastCheck(next.check); + if (next.check?.result === "pass") { + setSolvedHere(true); + announce(t("web.solve.announce", { stars: next.check.stars })); + } else if (next.check?.result === "fail") { + announce(next.check.feedback ? t("check.fail", { feedback: next.check.feedback }) : t("check.fail_generic")); + } else announce(t("check.infra")); + } + if (action === "hint") announce(t("web.session.hint_n", { tier: next.hints.length })); if (action === "reset") setLastCheck(undefined); setBusy(null); reloadOverview(); @@ -56,205 +76,360 @@ export function SessionView({ id, activeId, activeExercise, reloadOverview }: Pr ); }; - const nextTier = s.hints.length + 1; + // Ctrl+Enter (from anywhere, the terminal included) runs the check. + const runRef = useRef(run); + runRef.current = run; + useEffect(() => { + const onCheck = () => runRef.current("check"); + window.addEventListener(checkEvent, onCheck); + return () => window.removeEventListener(checkEvent, onCheck); + }, []); + + if (polled.error && !s) return {polled.error}; + if (!s) return ; + const newer = activeId && activeId !== s.id; + const nextTier = s.hints.length + 1; + const hasNextHint = isActive && nextTier <= s.hints_total; + const disabled = busy !== null || offline; return ( -
    +
    {newer && ( -
    - {t("web.session.newer", { exercise: activeExercise })}{" "} - {t("web.session.open_newer")} -
    + {t("web.session.open_newer")}}> + {t("web.session.newer", { exercise: activeExercise })} + )} - + {error && {error}} + +
    +
    +

    + {t("web.session.ticket")} {s.exercise} +

    +

    + {s.ticket.title} +

    +

    + {t("web.session.from")} {s.ticket.from} +

    + +

    + + + {isActive && {t("web.session.expires", { time: timeOf(s.expires_at, lang) })}} +

    -
    -
    -
    -
    - {t("web.session.ticket")} - {s.exercise} -
    -

    {s.ticket.title}

    -

    - {t("web.session.from")} {s.ticket.from} -

    -
    {s.ticket.body}
    -
    - - {t("web.difficulty", { difficulty: s.difficulty })} -
    -
    + {s.hints.length > 0 && ( +
    +

    + {t("web.session.hints_title")} +

    +
      + {s.hints.map((h) => ( +
    1. + {t("web.session.hint_n", { tier: h.tier })} +

      {h.text}

      +
    2. + ))} +
    +
    + )} + +
    + + + {t("web.session.activity")} + +
      + {s.events.map((e, i) => ( +
    • + {t(`web.event.${e.type}`, { tier: e.tier ?? "" })} +
    • + ))} +
    +
    +
    +
    {isActive ? ( <> +
    + + {hasNextHint && ( + + )} + run("reset")}> + {t("web.session.reset")} + + + setPrefs({ timer: !prefs.timer })} /> + run("giveup")} /> +
    + + {offline &&

    {t("web.session.offline")}

    } {s.terminal ? ( -
    -

    {t("web.session.terminal_title")}

    + }> -
    + ) : ( -
    -

    {t("web.session.shell_title")}

    -

    {t("web.session.shell_body")}

    - -
    + )} - - - -
    - - - - {!s.can_give_up && {t("giveup.locked")}} -
    + ) : ( - + )} -
    + +
    +
    + ); +} - +function SessionSkeleton() { + const { t } = useI18n(); + return ( +
    +
    + +
    ); } -// paragraphs splits a YAML block text into paragraphs at blank lines, and -// joins the hard-wrapped lines inside each one. -function paragraphs(text: string): string[] { - return text - .split(/\n\s*\n/) - .map((p) => p.replace(/\s*\n\s*/g, " ").trim()) - .filter((p) => p !== ""); +// Timer shows the time since the session started, when the learner turns it +// on. It is never scored. +function Timer({ startedAt, on, onToggle }: { startedAt: string; on: boolean; onToggle: () => void }) { + const { t } = useI18n(); + const [now, setNow] = useState(Date.now()); + useEffect(() => { + if (!on) return; + const id = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(id); + }, [on]); + const secs = Math.max(0, Math.floor((now - new Date(startedAt).getTime()) / 1000)); + const text = `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, "0")}`; + return ( + + ); } -function timeOf(iso: string, lang: string): string { - return new Date(iso).toLocaleTimeString(lang === "no" ? "nb-NO" : "en-GB", { hour: "2-digit", minute: "2-digit" }); +// MoreMenu holds the rare actions, apart from the check: give up. +function MoreMenu({ canGiveUp, disabled, onGiveUp }: { canGiveUp: boolean; disabled: boolean; onGiveUp: () => void }) { + const { t } = useI18n(); + const [open, setOpen] = useState(false); + const box = useRef(null); + useEffect(() => { + if (!open) return; + const away = (e: Event) => { + if (!box.current?.contains(e.target as Node)) setOpen(false); + }; + const esc = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("pointerdown", away); + document.addEventListener("keydown", esc); + return () => { + document.removeEventListener("pointerdown", away); + document.removeEventListener("keydown", esc); + }; + }, [open]); + return ( +
    + + {open && ( +
    + + {t("web.session.giveup")} + + {!canGiveUp &&

    {t("giveup.locked")}

    } +
    + )} +
    + ); } -function Time({ iso }: { iso: string }) { - const { lang } = useI18n(); +// CheckLine is the result of the last check run here, in one line under the +// check bar. It changes in place, with an icon that swaps. +function CheckLine({ check, running }: { check: Session["check"]; running: boolean }) { + const { t } = useI18n(); + const shown = useDelayed(running); + if (running && shown) { + return ( +

    +

    + ); + } + if (!check || check.result === "pass") return

    + } b={} /> + {infra ? t("check.infra") : check.feedback ? t("check.fail", { feedback: check.feedback }) : t("check.fail_generic")} +

    ); } -// CheckResult announces the last check run from this page. -function CheckResult({ check }: { check: Session["check"] }) { +function CheckLog({ checks }: { checks: Session["checks"] }) { + const { t, tn } = useI18n(); + if (checks.length === 0) return

    {t("web.session.checks_none")}

    ; + return ( +
    + + + {tn("web.session.check_log", checks.length)} + +
      + {checks.map((c, i) => ( +
    • + + + {c.passed ? t("web.session.check_passed") : t("web.session.check_failed")} + {c.feedback && {c.feedback}} + +
    • + ))} +
    +
    + ); +} + +function ShellCommand({ command }: { command: string }) { const { t } = useI18n(); - if (!check || check.result === "pass") return null; - if (check.result === "infra") return
    {t("check.infra")}
    ; + const [copied, setCopied] = useState(false); + const copy = () => { + navigator.clipboard?.writeText(command).then( + () => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + }, + () => setCopied(false), + ); + }; return ( -
    - {check.feedback ? t("check.fail", { feedback: check.feedback }) : t("check.fail_generic")} +
    +

    {t("web.session.shell_body")}

    +
    + + {" "} + {command} + + +
    ); } -// Result is the end of a session: outcome, stars, level change and the -// walkthrough. -function Result({ s }: { s: Session }) { +// Result ends a session: the outcome, the stars, any level change, the +// walkthrough, and one next action. A solve made on this page plays the one +// expressive animation of the app; any key or click skips it. +function Result({ s, animate }: { s: Session; animate: boolean }) { const { t } = useI18n(); + const [playing, setPlaying] = useState(animate); + useEffect(() => { + if (!playing) return; + const stop = () => setPlaying(false); + const id = window.setTimeout(stop, 1300); + window.addEventListener("keydown", stop, { once: true }); + window.addEventListener("pointerdown", stop, { once: true }); + return () => { + window.clearTimeout(id); + window.removeEventListener("keydown", stop); + window.removeEventListener("pointerdown", stop); + }; + }, [playing]); const lc = s.level_change; + const solved = s.status === "solved"; return ( -
    -
    +
    +
    + {solved && ( + + )}
    -

    {t(`web.event.${s.status}`)}

    -

    {s.status === "solved" ? t("check.pass") : t(`web.result.${s.status}`)}

    +

    {t(`web.event.${s.status}`)}

    +

    {solved ? t(praiseKey(s)) : t(`web.result.${s.status}`)}

    - {s.status === "solved" && } -
    + {solved && ( + + + + )} +
    {lc && ( -

    lc.from ? "level-change up" : "level-change down"}> +

    lc.from ? "level-change is-up" : "level-change is-down"}> {t(lc.to > lc.from ? "level.up" : "level.down", { topic: t(`web.topic.${lc.topic}`), from: lc.from, to: lc.to })}

    )} {s.walkthrough && ( -
    -

    {t("web.result.walkthrough")}

    -
    - {paragraphs(s.walkthrough).map((para, i) => ( -

    {para}

    - ))} -
    -
    +
    +

    {t("web.result.walkthrough")}

    + +
    )} -

    - - {t("web.result.next")} - -

    - + + {t("web.result.next")} + + +
    ); } + +// praiseKey picks the summary line by how the session was solved: praise +// only for what was done. +function praiseKey(s: Session): string { + if (s.stars === 3) return "web.solve.no_hints"; + if (s.stars === 2) return "web.solve.some_hints"; + return "web.solve.with_hints"; +} diff --git a/web/src/views/Stats.tsx b/web/src/views/Stats.tsx new file mode 100644 index 0000000..26b24bc --- /dev/null +++ b/web/src/views/Stats.tsx @@ -0,0 +1,269 @@ +import { useState } from "react"; +import { api, type StatsPage, type TopicLevel } from "../api"; +import { usePoll } from "../hooks"; +import { useI18n } from "../i18n"; +import { Icon } from "../ui/icons"; +import { Banner, Button, EmptyState, Ring, Skeleton, useAnnounce, useDuration, useLevelLabel, useLevelNext } from "../ui/kit"; + +// Stats is the statistics view (spec section 7.3): topic levels, solves per +// day, and the per-exercise statistics with calibration flags, all from the +// dbt marts. +export function Stats({ levels }: { levels: TopicLevel[] | undefined }) { + const { t, num, date } = useI18n(); + const duration = useDuration(); + const announce = useAnnounce(); + const page = usePoll(api.stats, 5000, "stats"); + const levelLabel = useLevelLabel(); + const levelNext = useLevelNext(); + const [rebuildError, setRebuildError] = useState(""); + + const p = page.data; + const updating = p?.stats.updating ?? false; + const rebuild = () => { + setRebuildError(""); + api.rebuild().then( + () => { + announce(t("web.stats.updating")); + page.reload(); + }, + (e: unknown) => setRebuildError(e instanceof Error ? e.message : String(e)), + ); + }; + + return ( +
    +
    +

    + {t("web.stats.title")} +

    + {p && ( +
    +

    + {updating ? ( + <> +

    + {p.can_rebuild ? ( + + ) : ( +

    {t("web.stats.rebuild_unavailable")}

    + )} +
    + )} +
    + {rebuildError && {rebuildError}} + {p?.stats.error && {t("web.stats.failed")}} + {page.error && !p && {page.error}} + + {levels && ( +
    +

    {t("web.levels.title")}

    +
      + {levels.map((l) => { + const label = levelLabel(l.topic, l.level, l.streak, l.needed); + return ( +
    • + + {t(`web.topic.${l.topic}`)} + {levelNext(l.level, l.streak, l.needed)} +
    • + ); + })} +
    +
    + )} + + {!p ? ( + + ) : !p.built ? ( + {t("web.stats.empty")} + ) : ( + <> +
    +

    {t("web.stats.days_title")}

    +

    {t("web.stats.days_sub")}

    + {p.days.length === 0 ?

    {t("web.stats.days_empty")}

    : } +
    + +
    +

    {t("web.stats.exercises_title")}

    +
    + + + + + + + + + + + + + {p.exercises.map((e) => ( + + + + + + + + + ))} + +
    {t("web.col.ticket")} + {t("web.col.sessions")} + + {t("web.col.solve_rate")} + + {t("web.col.avg_hints")} + + {t("web.col.avg_time")} + {t("web.col.calibration")}
    + {e.title} + {e.id} + {num(e.sessions)}{e.solve_rate === null ? "-" : `${num(e.solve_rate * 100)}%`}{e.avg_hints === null ? "-" : num(e.avg_hints, 1)}{e.avg_solve_seconds === null ? "-" : duration(e.avg_solve_seconds)} + {e.calibration_flag ? ( + + + {t(`web.stats.flag.${e.calibration_flag}`)} + + ) : e.sessions >= 10 ? ( + {t("web.stats.flag.none")} + ) : ( + + -{t("web.stats.flag.too_few")} + + )} +
    +
    +

    {t("web.stats.flag_note")}

    +
    + + )} +
    + ); +} + +// DaysChart draws solves per day as columns on one axis. Every column has a +// tooltip on hover and focus, and the same numbers are in a table below. +function DaysChart({ days }: { days: StatsPage["days"] }) { + const { t, num, lang } = useI18n(); + const [hover, setHover] = useState(null); + const shown = days.slice(-30); + const max = Math.max(1, ...shown.map((d) => d.solves)); + const ticks = niceTicks(max); + const top = ticks[ticks.length - 1] ?? 1; + const W = 640, + H = 200, + left = 32, + bottom = 24, + plotH = H - bottom - 8; + const band = (W - left) / shown.length; + const barW = Math.min(24, band - 2); + const y = (v: number) => 8 + plotH - (v / top) * plotH; + const dayLabel = (iso: string) => new Date(`${iso}T00:00:00Z`).toLocaleDateString(lang === "no" ? "nb-NO" : "en-GB", { day: "numeric", month: "short", timeZone: "UTC" }); + const tip = hover !== null ? shown[hover] : null; + return ( +
    + + {ticks.map((v) => ( + + + + {num(v)} + + + ))} + {shown.map((d, i) => { + const x = left + i * band + (band - barW) / 2; + const h = Math.max(0, y(0) - y(d.solves)); + const r = Math.min(4, h); + return ( + setHover(i)} + onPointerLeave={() => setHover(null)} + onFocus={() => setHover(i)} + onBlur={() => setHover(null)} + > + + {h > 0 && ( + + )} + + ); + })} + + {shown.map((d, i) => + i === 0 || i === shown.length - 1 || (shown.length > 6 && i % Math.ceil(shown.length / 6) === 0) ? ( + + {dayLabel(d.day)} + + ) : null, + )} + + {tip && ( + + )} +
    + {t("web.stats.show_table")} + + + + + + + + + + + {shown.map((d) => ( + + + + + + + ))} + +
    {t("web.col.day")} + {t("web.col.sessions")} + + {t("web.col.solves")} + + {t("web.col.avg_stars")} +
    {dayLabel(d.day)}{num(d.sessions)}{num(d.solves)}{d.avg_stars === null ? "-" : num(d.avg_stars, 1)}
    +
    +
    + ); +} + +// niceTicks returns 0 and up to four round steps covering max. +function niceTicks(max: number): number[] { + const step = max <= 4 ? 1 : max <= 10 ? 2 : max <= 25 ? 5 : Math.ceil(max / 4 / 5) * 5; + const out: number[] = []; + let v = 0; + for (; v < max && out.length < 5; v += step) out.push(v); + out.push(v); + return out; +} diff --git a/web/src/views/start.tsx b/web/src/views/start.tsx new file mode 100644 index 0000000..9075338 --- /dev/null +++ b/web/src/views/start.tsx @@ -0,0 +1,50 @@ +import { useState } from "react"; +import { api, type Overview } from "../api"; +import { sessionHref } from "../hooks"; +import { useI18n } from "../i18n"; +import { ProgressSteps, useAnnounce } from "../ui/kit"; + +// stepKeys name the engine's start steps (session.StartStep), in order. +const stepKeys = ["web.start.step.image", "web.start.step.machine", "web.start.step.setup", "web.start.step.fault"]; + +// useStart starts a session ("" = the recommended one) and opens it. While +// it runs, onStarting(true) makes the app poll the overview fast, so the +// start steps tick off. +export function useStart(reloadOverview: () => void, onStarting: (on: boolean) => void) { + const announce = useAnnounce(); + const { t } = useI18n(); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(""); + const start = (exercise: string) => { + setBusy(exercise); + setError(""); + onStarting(true); + announce(t("web.start.announce")); + api.start(exercise).then( + (s) => { + onStarting(false); + reloadOverview(); + window.location.hash = sessionHref(s.id); + }, + (e: unknown) => { + onStarting(false); + setBusy(null); + setError(e instanceof Error ? e.message : String(e)); + reloadOverview(); + }, + ); + }; + return { start, busy, error }; +} + +// StartProgress shows the real steps of a start while it runs. Before the +// first report it shows the first step as running. +export function StartProgress({ starting }: { starting: Overview["starting"] }) { + const { t } = useI18n(); + return ( +
    +

    {t("web.start.title")}

    + t(k))} current={starting?.step ?? 0} /> +
    + ); +}