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: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,11 @@ The dashboard is a React app in `web/`, built into the binary. Building it needs

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.
The home page is an inbox of tickets with the recommended one on top. A browser's first visit opens a short guided intro; the "?" button at the bottom of the sidebar shows it again, with all the shortcuts. Press Ctrl+K anywhere for the command palette, Ctrl+Enter to run the check (in the terminal too), and `?` for help. 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.
To start over as a new learner, use "Reset all data" in the help dialog, or `rootcause reset --all` in the CLI (type RESET to confirm, or pass `--yes` in a script). It deletes your history, sessions, levels, statistics and settings, ends a session in progress and removes its sandbox, and cannot be undone. It acts only on the database the dashboard or CLI uses.

`scripts/ui-check.sh <url> <active-session> <finished-session>` checks the dashboard in Windows' Chrome on this laptop: the first-run intro, the collapsed sidebar, centred pages on wide screens, the reset dialog, 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. With `RC_UI_WIPE=1` it ends by resetting all data through the dashboard, which empties that database. 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
2 changes: 1 addition & 1 deletion cmd/rootcause/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func runApp(ctx context.Context, cmd string, args []string) int {
case "check":
cmdErr = app.Check(ctx)
case "reset":
cmdErr = app.Reset(ctx)
cmdErr = app.Reset(ctx, args)
case "giveup":
cmdErr = app.GiveUp(ctx)
case "status":
Expand Down
17 changes: 12 additions & 5 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ Every dbt container is labelled with its database. Before a build, containers le
| `rootcause hint` | Reveals the next hint tier |
| `rootcause check` | Grades the active session |
| `rootcause reset` | Fresh copy of the same variant |
| `rootcause reset --all [--yes]` | Deletes all of the learner's data, as the dashboard's "Reset all data" does: asks for the confirmation word (RESET, or NULLSTILL in Norwegian) unless `--yes` is given |
| `rootcause giveup` | Ends the session and shows the walkthrough (after hint 3) |
| `rootcause status` | Topic levels, active session, review queue |
| `rootcause list` | All exercises with best stars |
Expand All @@ -350,27 +351,31 @@ Every dbt container is labelled with its database. Before a build, containers le

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.
- Sidebar: Inbox, Exercises, History, Stats and the session in progress; the five topic level rings; the EN/NO and theme switches; a "?" button for help. It collapses (remembered per browser) to a 64 px rail with only the mark, the expand toggle, the page icons, one language button, one theme button and "?". The current page's indicator sits at the sidebar's left edge in both states. The width animates only after the learner toggles it, never on load, and changes at once under reduced motion. On a phone it becomes a top bar.
- Help ("?" button or key): the keyboard shortcuts and the single-key switch, "Show the intro again", and a Data section with "Reset all data".
- Intro: a browser's first visit (no stored preferences, whatever the database holds) opens a four-step guided intro in a dialog: the inbox and why a ticket is picked, the session (ticket left, terminal right, Run check), hints and stars, levels and stats. Back, Next and Skip; Esc skips. Finishing or skipping is remembered per browser.
- Reset all data: a dialog that says what goes (history, sessions, topic levels, statistics and settings) and that it cannot be undone. Its button works only once the learner has typed the confirmation word (RESET, or NULLSTILL in Norwegian). The reset ends a session in progress and destroys its sandbox, deletes the sessions and events, the statistics (the analytics schema, which then reads as never built) and the profile language, in one transaction, then rebuilds the statistics where the server builds them. The browser's stored preferences are cleared and the app loads fresh, with the intro. It touches only the database the server uses; the exercise catalog and the migrations stay.
- 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. "Why this ticket" opens with a short height and fade animation.
- 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.
- Wide screens: the page column is centred, up to 1440 px (the inbox up to 1280 px), with prose kept to its measure; the Stats rings sit in one row and the chart fills the column.
- 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 `?` opens help. 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` (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.
- 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), `/api/data/reset` (reset all data; needs `{"confirm": "RESET"}`, 400 without it, 409 while another action such as a session start runs); `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.
- `scripts/ui-check.sh` checks the dashboard in a real browser on the development laptop: the intro on a first visit (and that Esc skips it and help restarts it), the collapsed rail (no overflow, no rings, the indicator inside the rail with a gap), centred pages at 1280, 1440, 1920 and 2560 px and at 1920 px with 75% zoom, the reset dialog by keyboard, 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 and at 1920 px (100% and 75%). With `RC_UI_WIPE=1` it ends by resetting all data through the dialog. It is not part of CI.

## 12. Languages

Expand Down Expand Up @@ -553,6 +558,8 @@ Done when: a merged change reaches the cluster without manual kubectl.

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.

Phase 10.1, from the owner's feedback on v0.10.0: the collapsed rail, its indicator and animation, the help button, the guided intro, centred wide layouts, and "Reset all data" (dashboard and `rootcause reset --all`). Done when `scripts/ui-check.sh` also passes its intro, rail, wide-screen and reset checks.

## 19. Public readiness checklist

The owner decides when the repo goes public. Ready means:
Expand Down
4 changes: 3 additions & 1 deletion internal/cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
Expand All @@ -32,6 +33,7 @@ type App struct {
bundle *i18n.Bundle
learner store.Learner
out *os.File
in io.Reader // where confirmations are read from
}

// NewApp builds the app for commands that need the database. It assumes
Expand Down Expand Up @@ -88,7 +90,7 @@ func NewApp(ctx context.Context) (*App, error) {
// Best-effort: a reap error must not block the command.
_, _ = session.NewReaper(eng, st, 0).ReapOnce(ctx)

return &App{root: root, dsn: dsn, st: st, eng: eng, runtime: rtName, bundle: bundle, learner: learner, out: os.Stdout}, nil
return &App{root: root, dsn: dsn, st: st, eng: eng, runtime: rtName, bundle: bundle, learner: learner, out: os.Stdout, in: os.Stdin}, nil
}

// EnvDBWait is how many seconds NewApp keeps trying to reach the database
Expand Down
51 changes: 49 additions & 2 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cli

import (
"bufio"
"context"
"errors"
"fmt"
Expand Down Expand Up @@ -147,8 +148,28 @@ func (a *App) Check(ctx context.Context) error {
return nil
}

// Reset gives a fresh copy of the same variant.
func (a *App) Reset(ctx context.Context) error {
// Reset gives a fresh copy of the same variant. With --all it deletes all of
// the learner's data instead (see WipeAll).
func (a *App) Reset(ctx context.Context, args []string) error {
all, yes := false, false
for _, arg := range args {
switch arg {
case "--all":
all = true
case "--yes":
yes = true
default:
a.say("reset.usage", nil)
return nil
}
}
if all {
return a.WipeAll(ctx, yes)
}
if yes {
a.say("reset.usage", nil)
return nil
}
sess, ok, err := a.active(ctx)
if err != nil {
return err
Expand All @@ -164,6 +185,32 @@ func (a *App) Reset(ctx context.Context) error {
return nil
}

// WipeAll deletes all of the learner's data, the same way the dashboard's
// "Reset all data" does (session.Engine.WipeAll): the active session and its
// sandbox, the history, the levels, the statistics and the settings. Unless
// yes is set, the learner must type the confirmation word first. The
// statistics are rebuilt afterwards.
func (a *App) WipeAll(ctx context.Context, yes bool) error {
if !yes {
word := a.t("wipe.word", nil)
a.say("wipe.warning", nil)
fmt.Fprint(a.out, a.t("wipe.prompt", map[string]string{"word": word}))
line, _ := bufio.NewReader(a.in).ReadString('\n')
if strings.TrimSpace(line) != word {
a.say("wipe.cancelled", nil)
return nil
}
}
n, err := a.eng.WipeAll(ctx, a.learner.ID)
if err != nil {
return err
}
a.say("wipe.done", map[string]string{"sandboxes": fmt.Sprint(n)})
a.learner.Lang = "en" // the wipe resets the language to the default
a.refreshAfterSession(ctx)
return nil
}

// GiveUp ends the session and shows the walkthrough (after hint 3).
func (a *App) GiveUp(ctx context.Context) error {
sess, ok, err := a.active(ctx)
Expand Down
33 changes: 30 additions & 3 deletions internal/runtime/scripted/scripted.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type Runtime struct {
checkPasses bool
feedback string
buildHook func()
live map[string]runtime.Sandbox // created and not yet destroyed, by id
}

// SetBuildHook makes BuildImage call hook first, so a test can hold a start
Expand All @@ -36,8 +37,16 @@ func (r *Runtime) SetCheck(passes bool, feedback string) {
r.checkPasses, r.feedback = passes, feedback
}

// Create records the sandbox as live until Destroy.
func (r *Runtime) Create(_ context.Context, spec runtime.SandboxSpec) (runtime.Sandbox, error) {
return runtime.Sandbox{ID: "fake-" + spec.Name, Name: spec.Name, Labels: spec.Labels}, nil
b := runtime.Sandbox{ID: "fake-" + spec.Name, Name: spec.Name, Image: spec.Image, Labels: spec.Labels}
r.mu.Lock()
defer r.mu.Unlock()
if r.live == nil {
r.live = make(map[string]runtime.Sandbox)
}
r.live[b.ID] = b
return b, nil
}

func (r *Runtime) Exec(_ context.Context, _ string, cmd []string, _ io.Reader) (runtime.ExecResult, error) {
Expand All @@ -57,8 +66,26 @@ func (r *Runtime) Exec(_ context.Context, _ string, cmd []string, _ io.Reader) (

func (r *Runtime) CopyIn(context.Context, string, string, []byte) error { return nil }
func (r *Runtime) Restart(context.Context, string) error { return nil }
func (r *Runtime) Destroy(context.Context, string) error { return nil }
func (r *Runtime) List(context.Context) ([]runtime.Sandbox, error) { return nil, nil }

// Destroy forgets the sandbox. An unknown id is not an error, as in Docker's
// forced removal.
func (r *Runtime) Destroy(_ context.Context, id string) error {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.live, id)
return nil
}

// List returns the live sandboxes.
func (r *Runtime) List(context.Context) ([]runtime.Sandbox, error) {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]runtime.Sandbox, 0, len(r.live))
for _, b := range r.live {
out = append(out, b)
}
return out, nil
}

// BuildImage calls the build hook, if set, and succeeds.
func (r *Runtime) BuildImage(context.Context, string, string) error {
Expand Down
Loading