Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url> <active-session> <finished-session>` 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`.

Expand Down
35 changes: 26 additions & 9 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,30 +346,40 @@ 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

- UI strings live in `locales/en.json` and `locales/no.json`. No user-facing text hard-coded in Go or React.
- Exercise texts live in `exercises/<id>/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

Expand Down Expand Up @@ -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:
Expand Down
156 changes: 156 additions & 0 deletions scripts/ui-check.sh
Original file line number Diff line number Diff line change
@@ -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 <base-url> <active-session-id> <finished-session-id> [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 <base-url> <active-session-id> <finished-session-id> [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 <log>: 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 <kind> <lang> <scheme>
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"
Loading