From c137c09f341d056ab549e209486894ad05f75a0e Mon Sep 17 00:00:00 2001 From: DIZAMU <117295462+DIZAMU@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:59:59 +0100 Subject: [PATCH 1/3] docs(adr): reword the sandbox UID range in ADR 0011 --- docs/adr/0011-v2-k3d-user-namespaces.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0011-v2-k3d-user-namespaces.md b/docs/adr/0011-v2-k3d-user-namespaces.md index 57e8366..10bf2a9 100644 --- a/docs/adr/0011-v2-k3d-user-namespaces.md +++ b/docs/adr/0011-v2-k3d-user-namespaces.md @@ -8,7 +8,7 @@ v2 runs sandboxes as pods in a local k3d cluster. k3d runs its node containers p ## Decision -- Every sandbox pod runs with `hostUsers: false`: pod root maps to an unprivileged host UID range (verified on this kernel: pod root is host UID 3236954112 and up, not 0). +- Every sandbox pod runs with `hostUsers: false`: pod root maps to an unprivileged range of 65536 host UIDs, different per pod, never 0 (verified on this kernel, and checked by `scripts/k8s-prove.sh`). - Since Phase 9 a ValidatingAdmissionPolicy denies any pod in `sandboxes` without it, for every requester (ADR 0012). - The privileged k3d nodes are accepted as a known limit for a local, single-user cluster, with the compensations in ADR 0012: user namespaces, the admission policy, Pod Security Admission, RBAC limited to pods in `sandboxes`, default-deny NetworkPolicies, no service account token in sandboxes, and nothing published beyond the host's 127.0.0.1. - Rootless k3d (on rootless Docker or Podman) is the future fix for node root. It needs sudo for packages and subordinate ID setup, and k3d's support for it is experimental. From 2d69ad73ce1d7fed4c70a1665393f89716415505 Mon Sep 17 00:00:00 2001 From: DIZAMU <117295462+DIZAMU@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:59:59 +0100 Subject: [PATCH 2/3] feat(session): report the start steps and the progress toward the next level --- internal/grading/grading.go | 22 ++++++++++-- internal/grading/grading_test.go | 26 +++++++++++++++ internal/runtime/scripted/scripted.go | 21 +++++++++++- internal/session/engine.go | 48 ++++++++++++++++++++++++++- internal/session/progress_test.go | 34 +++++++++++++++++++ 5 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 internal/session/progress_test.go diff --git a/internal/grading/grading.go b/internal/grading/grading.go index ce46e7b..b4bf1dc 100644 --- a/internal/grading/grading.go +++ b/internal/grading/grading.go @@ -62,6 +62,21 @@ type Outcome struct { // skipped outcome, it neither advances nor resets either streak. Fails count at // any difficulty. func Level(outcomes []Outcome) int { + level, _ := Progress(outcomes) + return level +} + +// StreakToLevelUp is how many consecutive strong solves at the current level +// raise it (spec section 7.1). +const StreakToLevelUp = 2 + +// MaxLevel is the highest topic level in v1. +const MaxLevel = 3 + +// Progress replays outcomes like Level and also returns the current streak +// of strong solves toward the next level: 0 or 1 of StreakToLevelUp. At +// MaxLevel the streak is always 0. +func Progress(outcomes []Outcome) (level, streak int) { level, up, down := 1, 0, 0 for _, o := range outcomes { switch o.Kind { @@ -80,7 +95,7 @@ func Level(outcomes []Outcome) int { continue } switch { - case up >= 2 && level < 3: + case up >= StreakToLevelUp && level < MaxLevel: level++ up, down = 0, 0 case down >= 2 && level > 1: @@ -88,5 +103,8 @@ func Level(outcomes []Outcome) int { up, down = 0, 0 } } - return level + if level == MaxLevel { + up = 0 + } + return level, up } diff --git a/internal/grading/grading_test.go b/internal/grading/grading_test.go index f727022..c8d51f1 100644 --- a/internal/grading/grading_test.go +++ b/internal/grading/grading_test.go @@ -80,3 +80,29 @@ func TestLevel(t *testing.T) { }) } } + +func TestProgress(t *testing.T) { + tests := []struct { + name string + outcomes []Outcome + level, streak int + }{ + {"nothing yet", nil, 1, 0}, + {"one strong at level 1", []Outcome{st(1)}, 1, 1}, + {"a level up resets the streak", []Outcome{st(1), st(1)}, 2, 0}, + {"one strong at level 2", []Outcome{st(1), st(1), st(2)}, 2, 1}, + {"a below-level solve does not count", []Outcome{st(1), st(1), st(1)}, 2, 0}, + {"a weak solve resets it", []Outcome{st(1), wk(1)}, 1, 0}, + {"a fail resets it", []Outcome{st(1), fl(1)}, 1, 0}, + {"no streak at the top level", []Outcome{st(1), st(1), st(2), st(2), st(3)}, 3, 0}, + } + for _, tt := range tests { + level, streak := Progress(tt.outcomes) + if level != tt.level || streak != tt.streak { + t.Errorf("%s: Progress = (%d, %d), want (%d, %d)", tt.name, level, streak, tt.level, tt.streak) + } + if l := Level(tt.outcomes); l != level { + t.Errorf("%s: Level = %d, Progress level = %d", tt.name, l, level) + } + } +} diff --git a/internal/runtime/scripted/scripted.go b/internal/runtime/scripted/scripted.go index 40fffcb..d505488 100644 --- a/internal/runtime/scripted/scripted.go +++ b/internal/runtime/scripted/scripted.go @@ -18,6 +18,15 @@ type Runtime struct { mu sync.Mutex checkPasses bool feedback string + buildHook func() +} + +// SetBuildHook makes BuildImage call hook first, so a test can hold a start +// at its first step. +func (r *Runtime) SetBuildHook(hook func()) { + r.mu.Lock() + defer r.mu.Unlock() + r.buildHook = hook } // SetCheck makes later checks pass, or fail with the feedback key. @@ -50,7 +59,17 @@ func (r *Runtime) CopyIn(context.Context, string, string, []byte) error { return 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 } -func (r *Runtime) BuildImage(context.Context, string, string) error { return nil } + +// BuildImage calls the build hook, if set, and succeeds. +func (r *Runtime) BuildImage(context.Context, string, string) error { + r.mu.Lock() + hook := r.buildHook + r.mu.Unlock() + if hook != nil { + hook() + } + return nil +} // Terminal echoes stdin to stdout, like a shell printing what is typed, until // stdin ends or ctx does. diff --git a/internal/session/engine.go b/internal/session/engine.go index df20531..abfb3c3 100644 --- a/internal/session/engine.go +++ b/internal/session/engine.go @@ -71,9 +71,39 @@ type LevelChange struct { To int } +// StartStep is one stage of a session start, in order. A dashboard shows +// them while the machine comes up; they are generic and say nothing about +// the exercise or its variant. +type StartStep int + +const ( + StepBuildImage StartStep = iota // build (or import) the exercise image + StepStartMachine // create the sandbox and wait until it is ready + StepSetUp // run setup.sh, if the exercise has one + StepPlantFault // run break.sh +) + +// StartSteps is how many StartStep values there are. +const StartSteps = 4 + +type progressKey struct{} + +// WithStartProgress returns a context under which Start (and Reset) calls +// report with each step as it begins. +func WithStartProgress(ctx context.Context, report func(StartStep)) context.Context { + return context.WithValue(ctx, progressKey{}, report) +} + +// reportStep tells the caller of Start which step begins, if it asked. +func reportStep(ctx context.Context, step StartStep) { + if report, ok := ctx.Value(progressKey{}).(func(StartStep)); ok { + report(step) + } +} + // Start creates a session and its sandbox, builds the healthy state, plants the // fault, and logs session_started. The seed makes the variant and fault -// reproducible. +// reproducible. A context from WithStartProgress hears each StartStep. func (e *Engine) Start(ctx context.Context, learnerID int, exerciseID string, seed int64) (store.Session, error) { ex, err := exercise.Load(filepath.Join(e.exDir, exerciseID)) if err != nil { @@ -91,6 +121,7 @@ func (e *Engine) Start(ctx context.Context, learnerID int, exerciseID string, se } tag := imageTag(ex) + reportStep(ctx, StepBuildImage) if err := e.builder.BuildImage(ctx, tag, ex.Dir()); err != nil { return store.Session{}, err } @@ -125,6 +156,7 @@ func (e *Engine) Start(ctx context.Context, learnerID int, exerciseID string, se } }() + reportStep(ctx, StepStartMachine) sb, err := e.createSandbox(ctx, ex, e.specFor(ex, sessionID)) if err != nil { return store.Session{}, fmt.Errorf("create sandbox: %w", err) @@ -246,6 +278,7 @@ func (e *Engine) createSandbox(ctx context.Context, ex *exercise.Exercise, spec // Both scripts are copied in, run as root and deleted, so neither the healthy // config nor the fault logic stays readable inside the sandbox. func (e *Engine) prepare(ctx context.Context, sandboxID string, ex *exercise.Exercise, env map[string]string) (map[string]any, error) { + reportStep(ctx, StepSetUp) if _, err := os.Stat(filepath.Join(ex.Dir(), "setup.sh")); err == nil { res, err := e.execScript(ctx, sandboxID, ex.Dir(), "setup.sh", env) if err != nil { @@ -255,6 +288,7 @@ func (e *Engine) prepare(ctx context.Context, sandboxID string, ex *exercise.Exe return nil, fmt.Errorf("setup.sh exited %d: %s", res.ExitCode, strings.TrimSpace(string(res.Stderr))) } } + reportStep(ctx, StepPlantFault) res, err := e.execScript(ctx, sandboxID, ex.Dir(), "break.sh", env) if err != nil { return nil, fmt.Errorf("plant fault: %w", err) @@ -472,6 +506,18 @@ func (e *Engine) TopicLevel(ctx context.Context, learnerID int, topic string) (i return grading.Level(levelOutcomes(outs)), nil } +// TopicProgress returns the learner's level in a topic and the streak of +// strong solves toward the next level (grading.Progress), from the same +// replay as TopicLevel. +func (e *Engine) TopicProgress(ctx context.Context, learnerID int, topic string) (level, streak int, err error) { + outs, err := e.st.TopicOutcomes(ctx, learnerID, topic) + if err != nil { + return 0, 0, err + } + level, streak = grading.Progress(levelOutcomes(outs)) + return level, streak, nil +} + // levelOutcomes turns stored sessions into the inputs the level rule needs. func levelOutcomes(outs []store.Outcome) []grading.Outcome { levels := make([]grading.Outcome, len(outs)) diff --git a/internal/session/progress_test.go b/internal/session/progress_test.go new file mode 100644 index 0000000..8cec205 --- /dev/null +++ b/internal/session/progress_test.go @@ -0,0 +1,34 @@ +package session + +import ( + "context" + "path/filepath" + "slices" + "testing" + + "github.com/DIZAMU/RootCause/internal/runtime/scripted" + "github.com/DIZAMU/RootCause/internal/testenv" +) + +// TestStartReportsSteps: Start reports the four generic steps, in order, +// to a context from WithStartProgress. +func TestStartReportsSteps(t *testing.T) { + st := testenv.Store(t, testenv.DSN(t)) + rt := &scripted.Runtime{} + exDir, _ := filepath.Abs("../../exercises") + eng := New(rt, rt, nil, st, exDir) + learner := testLearner(t, st, rt) + + var steps []StartStep + ctx := WithStartProgress(context.Background(), func(s StartStep) { steps = append(steps, s) }) + if _, err := eng.Start(ctx, learner, "perm-01-shared-folder", 1); err != nil { + t.Fatal(err) + } + want := []StartStep{StepBuildImage, StepStartMachine, StepSetUp, StepPlantFault} + if !slices.Equal(steps, want) { + t.Errorf("steps %v, want %v", steps, want) + } + if len(want) != StartSteps { + t.Errorf("StartSteps is %d, the engine reports %d steps", StartSteps, len(want)) + } +} From b292ed2d4f54488c434a6221fdf9823e169a6591 Mon Sep 17 00:00:00 2001 From: DIZAMU <117295462+DIZAMU@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:59:59 +0100 Subject: [PATCH 3/3] feat(server): add the stats view, rebuild now, start progress and ticket headers --- internal/cli/serve.go | 1 + internal/play/play.go | 29 +++++ internal/server/api.go | 213 +++++++++++++++++++++++++++++--- internal/server/refresh.go | 29 +++++ internal/server/refresh_test.go | 37 ++++++ internal/server/server.go | 13 +- internal/server/server_test.go | 124 +++++++++++++++++++ internal/store/analytics.go | 35 ++++++ locales/en.json | 126 ++++++++++++++++++- locales/no.json | 126 ++++++++++++++++++- 10 files changed, 707 insertions(+), 26 deletions(-) diff --git a/internal/cli/serve.go b/internal/cli/serve.go index 8a8858b..dc65768 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -45,6 +45,7 @@ func (a *App) Serve(ctx context.Context, args []string) error { BuildStats: a.buildStats, Describe: func(lang string, err error) string { return Message(a.bundle, lang, err) }, Assets: web.Assets(), + CanRebuild: os.Getenv(EnvDBTMode) != "cronjob", }) if err != nil { return err diff --git a/internal/play/play.go b/internal/play/play.go index 1f3c21e..b960765 100644 --- a/internal/play/play.go +++ b/internal/play/play.go @@ -37,6 +37,35 @@ func Texts(exercisesDir string, sess store.Session, lang string) (*exercise.I18n return texts, TextVars(sess), nil } +// Ticket is an exercise's ticket header outside a session, for the inbox and +// the catalog. +type Ticket struct { + Title string + From string +} + +// CatalogTicket returns the ticket title and sender of an exercise in lang, +// before any session exists. Placeholders are filled from the first value of +// each variant, a fixed display choice that does not depend on any seed. No +// derived value exists yet, and the ticket header never uses one. +func CatalogTicket(exercisesDir, id, lang string) (Ticket, error) { + ex, err := exercise.Load(filepath.Join(exercisesDir, id)) + if err != nil { + return Ticket{}, err + } + texts, err := ex.LoadI18n(lang) + if err != nil { + return Ticket{}, err + } + vars := make(map[string]string, len(ex.Variants)) + for name, values := range ex.Variants { + if len(values) > 0 { + vars[name] = values[0] + } + } + return Ticket{Title: i18n.Fill(texts.Ticket.Title, vars), From: i18n.Fill(texts.Ticket.From, vars)}, nil +} + // TextVars collects variant and derived values for placeholder filling. func TextVars(sess store.Session) map[string]string { m := make(map[string]string) diff --git a/internal/server/api.go b/internal/server/api.go index 4f4fc6a..b2d6078 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -10,6 +10,7 @@ import ( "slices" "time" + "github.com/DIZAMU/RootCause/internal/grading" "github.com/DIZAMU/RootCause/internal/i18n" "github.com/DIZAMU/RootCause/internal/play" "github.com/DIZAMU/RootCause/internal/selector" @@ -34,6 +35,8 @@ func (s *Server) routes(mux *http.ServeMux) { mux.HandleFunc("GET /api/overview", s.handleOverview) mux.HandleFunc("GET /api/exercises", s.handleExercises) mux.HandleFunc("GET /api/history", s.handleHistory) + mux.HandleFunc("GET /api/stats", s.handleStats) + mux.HandleFunc("POST /api/stats/rebuild", s.handleRebuild) mux.HandleFunc("GET /api/session", s.handleActive) mux.HandleFunc("GET /api/sessions/{id}", s.handleSession) mux.HandleFunc("POST /api/start", s.handleStart) @@ -82,13 +85,23 @@ func decode(r *http.Request, v any) error { // --- read endpoints ---------------------------------------------------------- type overviewJSON struct { - Lang string `json:"lang"` - Active *activeJSON `json:"active"` - Next *nextJSON `json:"next"` - Source string `json:"source"` // marts | fallback - Levels []levelJSON `json:"levels"` - Review *[]reviewJSON `json:"review"` // null until the marts are built - Stats statsJSON `json:"stats"` + Lang string `json:"lang"` + Active *activeJSON `json:"active"` + Starting *startingJSON `json:"starting"` // a start in progress; null if none + Next *nextJSON `json:"next"` + Source string `json:"source"` // marts | fallback + Levels []levelJSON `json:"levels"` + Review *[]reviewJSON `json:"review"` // null until the marts are built + Stats statsJSON `json:"stats"` +} + +// startingJSON is a session start in progress: which exercise, and which of +// the engine's generic steps (session.StartStep) is running. Nothing else +// about the exercise or its variant is sent. +type startingJSON struct { + Exercise string `json:"exercise"` + Step int `json:"step"` // 0-based index of the running step + Steps int `json:"steps"` // session.StartSteps } type activeJSON struct { @@ -98,6 +111,8 @@ type activeJSON struct { type nextJSON struct { Exercise string `json:"exercise"` + Title string `json:"title"` // the ticket header, see play.CatalogTicket + From string `json:"from"` Topic string `json:"topic"` Difficulty int `json:"difficulty"` Reason string `json:"reason"` // review | weakest_topic @@ -105,8 +120,10 @@ type nextJSON struct { } type levelJSON struct { - Topic string `json:"topic"` - Level int `json:"level"` + Topic string `json:"topic"` + Level int `json:"level"` + Streak int `json:"streak"` // strong solves toward the next level so far + Needed int `json:"needed"` // strong solves the next level needs; 0 at the top level } type reviewJSON struct { @@ -131,6 +148,12 @@ func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) { ctx := r.Context() lang := s.lang(ctx) out := overviewJSON{Lang: lang, Levels: []levelJSON{}} + s.startMu.Lock() + if s.starting != nil { + st := *s.starting + out.Starting = &st + } + s.startMu.Unlock() if sess, ok, err := s.active(ctx); err != nil { s.failErr(w, lang, err) @@ -161,17 +184,26 @@ func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) { return default: p := rec.Pick.Exercise - out.Next = &nextJSON{Exercise: p.ID, Topic: p.Topic, Difficulty: p.Difficulty, Reason: string(rec.Pick.Reason), Level: rec.Level} + ticket, err := play.CatalogTicket(s.cfg.ExercisesDir, p.ID, lang) + if err != nil { + s.failErr(w, lang, err) + return + } + out.Next = &nextJSON{Exercise: p.ID, Title: ticket.Title, From: ticket.From, Topic: p.Topic, Difficulty: p.Difficulty, Reason: string(rec.Pick.Reason), Level: rec.Level} } out.Source = string(rec.Source) for _, topic := range play.Topics { - level, err := s.cfg.Engine.TopicLevel(ctx, s.cfg.LearnerID, topic) + level, streak, err := s.cfg.Engine.TopicProgress(ctx, s.cfg.LearnerID, topic) if err != nil { s.failErr(w, lang, err) return } - out.Levels = append(out.Levels, levelJSON{Topic: topic, Level: level}) + needed := grading.StreakToLevelUp + if level == grading.MaxLevel { + needed = 0 + } + out.Levels = append(out.Levels, levelJSON{Topic: topic, Level: level, Streak: streak, Needed: needed}) } queue, err := s.cfg.Store.MartReviewQueue(ctx, s.cfg.LearnerID) @@ -201,14 +233,24 @@ func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) { } type exerciseJSON struct { - ID string `json:"id"` - Topic string `json:"topic"` - Difficulty int `json:"difficulty"` - BestStars *int `json:"best_stars"` // null if never solved + ID string `json:"id"` + Title string `json:"title"` // the ticket header, see play.CatalogTicket + From string `json:"from"` + Topic string `json:"topic"` + Difficulty int `json:"difficulty"` + BestStars *int `json:"best_stars"` // null if never solved + Last *lastJSON `json:"last"` // the latest finished session; null if none +} + +// lastJSON is an exercise's latest finished session. +type lastJSON struct { + Status string `json:"status"` + Stars *int `json:"stars"` + EndedAt *time.Time `json:"ended_at"` } -// handleExercises lists the catalog with the best stars per exercise, as -// rootcause list does. +// handleExercises lists the catalog with the ticket header, the best stars +// and the latest finished session per exercise, as rootcause list does. func (s *Server) handleExercises(w http.ResponseWriter, r *http.Request) { ctx := r.Context() lang := s.lang(ctx) @@ -222,6 +264,17 @@ func (s *Server) handleExercises(w http.ResponseWriter, r *http.Request) { s.failErr(w, lang, err) return } + history, err := s.cfg.Store.History(ctx, s.cfg.LearnerID, historyLimit) + if err != nil { + s.failErr(w, lang, err) + return + } + last := make(map[string]*lastJSON) + for _, h := range history { // newest first + if _, seen := last[h.ExerciseID]; !seen { + last[h.ExerciseID] = &lastJSON{Status: h.Status, Stars: h.Stars, EndedAt: h.EndedAt} + } + } slices.SortFunc(catalog, func(a, b store.ExerciseRow) int { switch { case a.ID < b.ID: @@ -233,7 +286,12 @@ func (s *Server) handleExercises(w http.ResponseWriter, r *http.Request) { }) out := []exerciseJSON{} for _, ex := range catalog { - e := exerciseJSON{ID: ex.ID, Topic: ex.Topic, Difficulty: ex.Difficulty} + ticket, err := play.CatalogTicket(s.cfg.ExercisesDir, ex.ID, lang) + if err != nil { + s.failErr(w, lang, err) + return + } + e := exerciseJSON{ID: ex.ID, Title: ticket.Title, From: ticket.From, Topic: ex.Topic, Difficulty: ex.Difficulty, Last: last[ex.ID]} if n, ok := best[ex.ID]; ok { e.BestStars = &n } @@ -270,6 +328,116 @@ func (s *Server) handleHistory(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) } +type statsPageJSON struct { + Built bool `json:"built"` // false until dbt has built the marts once + Exercises []exerciseStatJSON `json:"exercises"` + Days []progressDayJSON `json:"days"` + Stats statsJSON `json:"stats"` + // CanRebuild is false where the server cannot build the marts itself (a + // CronJob builds them in the cluster). + CanRebuild bool `json:"can_rebuild"` +} + +type exerciseStatJSON struct { + ID string `json:"id"` + Title string `json:"title"` + Topic string `json:"topic"` + Difficulty int `json:"difficulty"` + Sessions int `json:"sessions"` + Solves int `json:"solves"` + SolveRate *float64 `json:"solve_rate"` + AvgHints *float64 `json:"avg_hints"` + AvgSolveSeconds *float64 `json:"avg_solve_seconds"` + CalibrationFlag *string `json:"calibration_flag"` +} + +type progressDayJSON struct { + Day string `json:"day"` // YYYY-MM-DD, UTC + Sessions int `json:"sessions"` + Solves int `json:"solves"` + AvgStars *float64 `json:"avg_stars"` +} + +// handleStats is the statistics view (spec section 7.3): the per-exercise +// statistics and the learner's results per day, both read from the marts, +// and the state of the statistics. Before the first build it returns empty +// lists with built false. +func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + lang := s.lang(ctx) + out := statsPageJSON{Exercises: []exerciseStatJSON{}, Days: []progressDayJSON{}, CanRebuild: s.cfg.CanRebuild} + + rows, err := s.cfg.Store.MartExerciseStats(ctx) + switch { + case errors.Is(err, store.ErrNoMarts): + case err != nil: + s.failErr(w, lang, err) + return + default: + out.Built = true + for _, st := range rows { + ticket, err := play.CatalogTicket(s.cfg.ExercisesDir, st.ExerciseID, lang) + if err != nil { + s.failErr(w, lang, err) + return + } + out.Exercises = append(out.Exercises, exerciseStatJSON{ + ID: st.ExerciseID, Title: ticket.Title, Topic: st.Topic, Difficulty: st.Difficulty, + Sessions: st.Sessions, Solves: st.Solves, SolveRate: st.SolveRate, AvgHints: st.AvgHints, + AvgSolveSeconds: st.AvgSolveSeconds, CalibrationFlag: st.CalibrationFlag, + }) + } + } + days, err := s.cfg.Store.MartProgress(ctx, s.cfg.LearnerID) + switch { + case errors.Is(err, store.ErrNoMarts): + case err != nil: + s.failErr(w, lang, err) + return + default: + for _, d := range days { + out.Days = append(out.Days, progressDayJSON{Day: d.Day.UTC().Format("2006-01-02"), Sessions: d.Sessions, Solves: d.Solves, AvgStars: d.AvgStars}) + } + } + fresh, err := s.cfg.Store.MartsFresh(ctx, s.cfg.LearnerID) + if err != nil { + s.failErr(w, lang, err) + return + } + out.Stats = statsJSON{Fresh: fresh, Updating: s.stats.busy()} + if out.Stats.BuiltAt, err = s.cfg.Store.StatsBuiltAt(ctx); err != nil { + s.failErr(w, lang, err) + return + } + if err := s.stats.err(); err != nil { + out.Stats.Error = plainText(s.cfg.Describe(lang, err)) + } + writeJSON(w, http.StatusOK, out) +} + +// handleRebuild starts a statistics rebuild in the background, even if the +// marts are fresh. One runs at a time: while one is running it answers 409. +// Where the server cannot build (CanRebuild false) it answers 404. +func (s *Server) handleRebuild(w http.ResponseWriter, r *http.Request) { + lang := s.lang(r.Context()) + if !s.cfg.CanRebuild { + s.fail(w, http.StatusNotFound, lang, "web.stats.rebuild_unavailable", nil) + return + } + if err := s.stats.force(); errors.Is(err, errBusy) { + s.fail(w, http.StatusConflict, lang, "web.stats.rebuild_running", nil) + return + } + w.WriteHeader(http.StatusAccepted) +} + +// setStarting records the start in progress, or none. +func (s *Server) setStarting(st *startingJSON) { + s.startMu.Lock() + s.starting = st + s.startMu.Unlock() +} + // handleActive returns the active session, or 204 if there is none. func (s *Server) handleActive(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -388,7 +556,12 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) { exerciseID = rec.Pick.Exercise.ID } - sess, err := s.cfg.Engine.Start(ctx, s.cfg.LearnerID, exerciseID, seed) + s.setStarting(&startingJSON{Exercise: exerciseID, Steps: session.StartSteps}) + defer s.setStarting(nil) + progress := session.WithStartProgress(ctx, func(step session.StartStep) { + s.setStarting(&startingJSON{Exercise: exerciseID, Step: int(step), Steps: session.StartSteps}) + }) + sess, err := s.cfg.Engine.Start(progress, s.cfg.LearnerID, exerciseID, seed) if errors.Is(err, store.ErrActiveSession) { // The CLI started one first (migration 0002 keeps one active session). s.fail(w, http.StatusConflict, lang, "web.error.active_exists", nil) diff --git a/internal/server/refresh.go b/internal/server/refresh.go index 3398c3e..6f251ab 100644 --- a/internal/server/refresh.go +++ b/internal/server/refresh.go @@ -2,6 +2,7 @@ package server import ( "context" + "errors" "sync" "sync/atomic" ) @@ -42,6 +43,11 @@ func (r *refresher) run(ctx context.Context) error { if fresh, err := r.fresh(ctx); err == nil && fresh { return nil } + return r.buildLocked(ctx) +} + +// buildLocked runs the build and records its error. The caller holds sem. +func (r *refresher) buildLocked(ctx context.Context) error { err := r.build(ctx) r.mu.Lock() r.lastErr = err @@ -49,6 +55,29 @@ func (r *refresher) run(ctx context.Context) error { return err } +// errBusy means a rebuild is already running or waiting to run. +var errBusy = errors.New("a statistics rebuild is already running") + +// force starts a rebuild in the background even if the marts are fresh, the +// learner's "rebuild now". Only one runs at a time: if one is running or +// waiting, force starts nothing and returns errBusy. +func (r *refresher) force() error { + select { + case r.sem <- struct{}{}: + default: + return errBusy + } + r.pending.Add(1) + r.wg.Add(1) + go func() { + defer r.wg.Done() + defer r.pending.Add(-1) + defer func() { <-r.sem }() + _ = r.buildLocked(r.ctx) + }() + return nil +} + // trigger runs a rebuild in the background and returns at once. func (r *refresher) trigger() { r.wg.Add(1) diff --git a/internal/server/refresh_test.go b/internal/server/refresh_test.go index a249eaa..41c5511 100644 --- a/internal/server/refresh_test.go +++ b/internal/server/refresh_test.go @@ -137,3 +137,40 @@ func TestPlainText(t *testing.T) { t.Errorf("plainText = %q, want %q", got, want) } } + +// TestRefresherForce: force builds even when the marts are fresh, and a +// second force while one runs starts nothing and returns errBusy. +func TestRefresherForce(t *testing.T) { + var builds atomic.Int32 + release := make(chan struct{}) + r := newRefresher(context.Background(), + func(context.Context) error { + builds.Add(1) + <-release + return nil + }, + func(context.Context) (bool, error) { return true, nil }) + + if err := r.force(); err != nil { + t.Fatalf("first force: %v", err) + } + waitFor(t, func() bool { return builds.Load() == 1 }) + if !r.busy() { + t.Error("busy() is false during a forced build") + } + if err := r.force(); !errors.Is(err, errBusy) { + t.Errorf("second force during a build: %v, want errBusy", err) + } + close(release) + r.wait() + if n := builds.Load(); n != 1 { + t.Errorf("%d builds, want 1", n) + } + if err := r.force(); err != nil { + t.Errorf("force after the build: %v", err) + } + r.wait() + if n := builds.Load(); n != 2 { + t.Errorf("%d builds after the second force, want 2", n) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index c6d49a0..1c4c6f5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -47,8 +47,8 @@ const TokenHeader = "X-RootCause-Token" // tokenPlaceholder is replaced by the token in the served index.html. const tokenPlaceholder = "__RC_TOKEN__" -// Config is what the server needs. Every field except Assets and Random is -// required. +// Config is what the server needs. Every field except Assets, Random and +// CanRebuild is required. type Config struct { Store *store.Store Engine *session.Engine @@ -66,6 +66,10 @@ type Config struct { Assets fs.FS // Random returns seeds for new sessions. nil means math/rand. Random func() int64 + // CanRebuild is true where BuildStats really builds the marts, so the + // dashboard may offer "rebuild now". It is false in the cluster, where a + // CronJob builds them. + CanRebuild bool } // Server serves the dashboard. @@ -77,6 +81,11 @@ type Server struct { // actions serializes the session actions (start, hint, check, reset, // give up), so a double click cannot run two at once. actions sync.Mutex + + // starting is the session start in progress, for the overview; nil when + // none runs. Guarded by startMu. + startMu sync.Mutex + starting *startingJSON } // New builds a server with a fresh random token. Background statistics diff --git a/internal/server/server_test.go b/internal/server/server_test.go index ee69142..c685170 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -195,6 +195,9 @@ func TestGuard(t *testing.T) { {"api, wrong token", "GET", "/api/i18n/en", "", strings.Repeat("0", 64), 403}, {"api post, no token", "POST", "/api/start", "", "", 403}, {"api preflight", "OPTIONS", "/api/start", "", "", 403}, + {"stats, no token", "GET", "/api/stats", "", "", 403}, + {"rebuild, no token", "POST", "/api/stats/rebuild", "", "", 403}, + {"rebuild, rebinding host with token", "POST", "/api/stats/rebuild", "evil.example:" + r.port, r.token, 403}, {"api, token", "GET", "/api/i18n/en", "", r.token, 200}, {"api, localhost host", "GET", "/api/i18n/no", "localhost:" + r.port, r.token, 200}, {"api, upper-case host", "GET", "/api/i18n/no", "LOCALHOST:" + r.port, r.token, 200}, @@ -236,6 +239,13 @@ type world struct { } func newWorld(t *testing.T, assets bool) *world { + t.Helper() + return newWorldWith(t, assets, nil) +} + +// newWorldWith is newWorld with a change to the server's config before it +// starts. +func newWorldWith(t *testing.T, assets bool, change func(*server.Config)) *world { t.Helper() dsn := testenv.DSN(t) ctx := context.Background() @@ -264,6 +274,9 @@ func newWorld(t *testing.T, assets bool) *world { if assets { cfg.Assets = fstest.MapFS{"index.html": {Data: []byte(indexHTML)}} } + if change != nil { + change(&cfg) + } w.running = serve(t, cfg) return w } @@ -576,3 +589,114 @@ func TestContentSecurityPolicy(t *testing.T) { t.Errorf("policy %q: only style-src may allow inline, scripts must fall back to default-src 'self'", csp) } } + +// TestRebuildOverAPI: "rebuild now" starts a build even when the marts are +// fresh, answers 409 while one runs, and 404 where the server cannot build. +func TestRebuildOverAPI(t *testing.T) { + release := make(chan struct{}) + started := make(chan struct{}, 10) + w := newWorldWith(t, true, func(c *server.Config) { + c.CanRebuild = true + c.BuildStats = func(context.Context) error { + started <- struct{}{} + <-release + return nil + } + }) + var page struct { + CanRebuild bool `json:"can_rebuild"` + Built bool `json:"built"` + } + w.api("GET", "/api/stats", nil, 200, &page) + if !page.CanRebuild { + t.Error("can_rebuild is false with CanRebuild set") + } + w.api("POST", "/api/stats/rebuild", nil, 202, nil) + select { + case <-started: + case <-time.After(10 * time.Second): + t.Fatal("the rebuild did not start") + } + w.api("POST", "/api/stats/rebuild", nil, 409, nil) + close(release) + + off := newWorldWith(t, true, func(c *server.Config) { c.CanRebuild = false }) + off.api("GET", "/api/stats", nil, 200, &page) + if page.CanRebuild { + t.Error("can_rebuild is true without CanRebuild") + } + off.api("POST", "/api/stats/rebuild", nil, 404, nil) +} + +// TestStartProgressInOverview: while a start runs, the overview reports its +// exercise and step, and nothing from the exercise's variant. +func TestStartProgressInOverview(t *testing.T) { + w := newWorld(t, true) + hold, entered := make(chan struct{}), make(chan struct{}) + w.rt.SetBuildHook(func() { + close(entered) + <-hold + }) + done := make(chan struct{}) + go func() { + defer close(done) + // Not w.api: it calls Fatalf, which may not run outside the test's + // own goroutine. + res := w.do("POST", "/api/start", "", map[string]string{"exercise": "perm-01-shared-folder"}, w.token) + if res.status != 200 { + t.Errorf("POST /api/start: status %d: %s", res.status, res.body) + } + }() + select { + case <-entered: + case <-time.After(10 * time.Second): + t.Fatal("the start did not reach the image build") + } + + raw := w.api("GET", "/api/overview", nil, 200, nil) + var ov struct { + Starting *struct { + Exercise string + Step, Steps int + } + } + if err := json.Unmarshal(raw, &ov); err != nil { + t.Fatal(err) + } + if ov.Starting == nil || ov.Starting.Exercise != "perm-01-shared-folder" || ov.Starting.Step != 0 || ov.Starting.Steps != session.StartSteps { + t.Errorf("starting = %+v, want perm-01 at step 0 of %d", ov.Starting, session.StartSteps) + } + ex, err := exercise.Load(filepath.Join(w.exDir, "perm-01-shared-folder")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "RC_") { + t.Error("the overview contains an RC_ variable name") + } + // The start itself carries exactly three fields, none from the variant. + // (The overview's next pick may show another ticket header, filled for + // display; see play.CatalogTicket.) + var fields struct { + Starting map[string]json.RawMessage `json:"starting"` + } + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + if len(fields.Starting) != 3 || fields.Starting["exercise"] == nil || fields.Starting["step"] == nil || fields.Starting["steps"] == nil { + t.Errorf("starting has fields %v, want exactly exercise, step, steps", fields.Starting) + } + startingRaw, _ := json.Marshal(fields.Starting) + for name, values := range ex.Variants { + for _, v := range values { + if strings.Contains(string(startingRaw), v) || strings.Contains(string(startingRaw), name) { + t.Errorf("starting contains variant %s or its value %q: %s", name, v, startingRaw) + } + } + } + close(hold) + <-done + w.api("GET", "/api/overview", nil, 200, &ov) + if ov.Starting != nil { + t.Errorf("starting is still set after the start: %+v", ov.Starting) + } +} diff --git a/internal/store/analytics.go b/internal/store/analytics.go index 4fa18a5..96b0d9d 100644 --- a/internal/store/analytics.go +++ b/internal/store/analytics.go @@ -158,6 +158,41 @@ func (s *Store) MartExerciseStats(ctx context.Context) ([]ExerciseStat, error) { return out, rows.Err() } +// ProgressDay is one learner's results on one day (UTC), over all topics. +type ProgressDay struct { + Day time.Time + Sessions int + Solves int + AvgStars *float64 // over the day's solves; nil if none +} + +// MartProgress returns the learner's results per day, oldest first, summed +// over topics from mart_progress. It returns ErrNoMarts if dbt never ran. +func (s *Store) MartProgress(ctx context.Context, learnerID int) ([]ProgressDay, error) { + rows, err := s.pool.Query(ctx, ` + SELECT day, sum(sessions)::int, sum(solves)::int, + (sum(avg_stars * solves) / nullif(sum(solves), 0))::float8 + FROM analytics.mart_progress + WHERE learner_id = $1 + GROUP BY day ORDER BY day`, learnerID) + if missingRelation(err) { + return nil, ErrNoMarts + } + if err != nil { + return nil, fmt.Errorf("mart_progress: %w", err) + } + defer rows.Close() + var out []ProgressDay + for rows.Next() { + var d ProgressDay + if err := rows.Scan(&d.Day, &d.Sessions, &d.Solves, &d.AvgStars); err != nil { + return nil, err + } + out = append(out, d) + } + return out, rows.Err() +} + // ErrNoMarts means the dbt models have never been built in this database. var ErrNoMarts = errors.New("the dbt models have not been built yet") diff --git a/locales/en.json b/locales/en.json index 136ce4a..70ae205 100644 --- a/locales/en.json +++ b/locales/en.json @@ -32,7 +32,7 @@ "lang.usage": "Usage: rootcause lang en|no", "help.unknown_command": "Unknown command: {command}", "help.usage": "rootcause - hands-on IT troubleshooting practice\n\nPlay:\n init Start Postgres, run migrations, create the local profile\n next Start the recommended next exercise\n start Start a specific exercise\n ticket Show the active ticket again\n shell Open a shell in the active sandbox\n hint Reveal the next hint\n check Grade the active session\n reset Fresh copy of the same problem\n giveup End the session and show the walkthrough (after hint 3)\n status Active session, topic levels, review queue and best stars\n list All exercises with best stars\n stats [--refresh] Statistics per exercise (--refresh rebuilds them first)\n serve [--port N] Start the local dashboard on http://localhost:7300\n lang en|no Set the interface language\n\nDeveloper:\n validate Validate an exercise definition\n vars --seed Print a seed's variant values\n selftest --seed Prove an exercise end to end in real sandboxes\n", - "error.generic":"Something went wrong: {detail}", + "error.generic": "Something went wrong: {detail}", "error.detail": "Details: {detail}", "error.no_root": "Cannot find the RootCause folder. Run rootcause from inside the repo, or set RC_ROOT to its path.", "error.no_env": "Cannot read deploy/compose/.env. Copy deploy/compose/.env.example to deploy/compose/.env and set a password, or set RC_DATABASE_URL.", @@ -186,5 +186,127 @@ "web.outcome.expired": "Expired", "web.outcome.voided": "Voided", "web.duration.minutes": "{minutes} min", - "web.duration.hours": "{hours} h {minutes} min" + "web.duration.hours": "{hours} h {minutes} min", + "web.stats.rebuild_unavailable": "The statistics rebuild by themselves here, every 10 minutes.", + "web.stats.rebuild_running": "A rebuild is already running. It will finish on its own.", + "web.close": "Close", + "web.skip": "Skip to content", + "web.nav.label": "Main", + "web.nav.inbox": "Inbox", + "web.nav.stats": "Stats", + "web.nav.collapse": "Collapse the sidebar", + "web.nav.expand": "Expand the sidebar", + "web.title.home": "Inbox", + "web.title.exercises": "Exercises", + "web.title.history": "History", + "web.title.stats": "Stats", + "web.levels.title": "Topic levels", + "web.level.progress_one": "{topic}: level {level}. 1 more strong solve to reach level {next}.", + "web.level.progress_other": "{topic}: level {level}. {count} more strong solves to reach level {next}.", + "web.level.top": "{topic}: level {level}, the top level.", + "web.inbox.title": "Inbox", + "web.inbox.intro": "Each ticket is a broken server. Open it, fix the fault in the terminal, then run the check. Hints are there when you need them.", + "web.inbox.intro_dismiss": "Hide this tip", + "web.inbox.in_progress": "In progress", + "web.inbox.resume": "Resume", + "web.inbox.open_ticket": "Open ticket", + "web.inbox.start": "Start", + "web.inbox.locked_active": "Finish or give up the session in progress first.", + "web.tag.next": "Next", + "web.tag.due": "Due", + "web.tag.new": "New", + "web.tag.tried": "Tried", + "web.tag.due_on": "Due {date}", + "web.why.summary": "Why this ticket", + "web.why.rule_label": "Picked because", + "web.why.last_label": "Last time", + "web.why.after_label": "What comes next", + "web.why.rule.review": "It is due for review, as a new variant.", + "web.why.rule.weakest": "It is from your weakest topic, {topic}, at level {level}.", + "web.why.last": "{outcome}, {date}", + "web.why.never": "You have not tried this one yet.", + "web.why.after.review": "Solve it with 2 or more stars and it leaves the review queue.", + "web.why.after.level_one": "1 more strong solve in {topic} takes you to level {next}.", + "web.why.after.level_other": "{count} more strong solves in {topic} take you to level {next}.", + "web.why.after.top": "{topic} is at the top level. This keeps it fresh.", + "web.start.title": "Starting your machine", + "web.start.announce": "Starting the machine.", + "web.start.step.image": "Build the image", + "web.start.step.machine": "Start the machine", + "web.start.step.setup": "Set up the server", + "web.start.step.fault": "Plant the fault", + "web.exercises.sub": "Every ticket, by topic. Start any one, or follow the inbox.", + "web.col.ticket": "Ticket", + "web.col.last_played": "Last played", + "web.col.actions": "Actions", + "web.col.sessions": "Sessions", + "web.col.solve_rate": "Solve rate", + "web.col.avg_hints": "Average hints", + "web.col.avg_time": "Average time", + "web.col.calibration": "Calibration", + "web.col.day": "Day", + "web.col.solves": "Solves", + "web.col.avg_stars": "Average stars", + "web.filter.all": "All", + "web.filter.count": "{shown} of {total}", + "web.filter.none": "No sessions match these filters.", + "web.history.empty_title": "No sessions yet", + "web.history.empty_action": "Go to the inbox", + "web.session.workbench": "Workbench", + "web.session.toolbar": "Session actions", + "web.session.run_check": "Run check", + "web.session.show_hint": "Show hint {tier}", + "web.session.hint_cost_one": "at most 1 star", + "web.session.hint_cost_other": "at most {count} stars", + "web.session.reset_confirm_short": "Reset? Click again", + "web.session.giveup_confirm_short": "Give up? Click again", + "web.session.more": "More actions", + "web.session.offline": "The dashboard cannot reach rootcause serve, so the actions are paused until it is back.", + "web.session.timer_show": "Show the timer", + "web.session.timer_hide": "Hide the timer", + "web.session.check_log_one": "Check log (1 check)", + "web.session.check_log_other": "Check log ({count} checks)", + "web.solve.announce": "Solved, {stars} of 3 stars.", + "web.solve.no_hints": "Nice work, solved without hints!", + "web.solve.some_hints": "Nice work, solved.", + "web.solve.with_hints": "Solved. The hints did their job.", + "web.stats.title": "Stats", + "web.stats.rebuild": "Rebuild now", + "web.stats.days_title": "Solves per day", + "web.stats.days_sub": "The last 30 days you practised. Hover or focus a day for its numbers.", + "web.stats.days_empty": "Finish a session and your days show up here.", + "web.stats.chart_label": "Solves per day, over {days} days. The numbers are in the table below the chart.", + "web.stats.chart_point": "{day}: {solves} solved of {sessions} sessions", + "web.stats.tip_solves": "{solves} solved of {sessions}", + "web.stats.tip_stars": "{stars} stars on average", + "web.stats.show_table": "Show the numbers", + "web.stats.exercises_title": "Per exercise", + "web.stats.flag.none": "OK", + "web.stats.flag.too_hard": "May be too hard", + "web.stats.flag.too_easy": "May be too easy", + "web.stats.flag_note": "A flag appears after 10 sessions: difficulty 1 solved under half the time, or difficulty 3 solved more than 9 times in 10. With one learner it is information only.", + "web.stats.empty_title": "No statistics yet", + "web.stats.empty": "Statistics are built from finished sessions. Finish one and they appear here, usually within a minute.", + "web.palette.label": "Go to or do", + "web.palette.placeholder": "Search pages, tickets and actions", + "web.palette.none": "Nothing matches.", + "web.palette.open_session": "Open the session in progress", + "web.palette.switch_lang": "Switch language", + "web.palette.switch_theme": "Switch light and dark", + "web.shortcuts.title": "Keyboard shortcuts", + "web.shortcuts.palette": "Open the command palette", + "web.shortcuts.check": "Run the check (works in the terminal too)", + "web.shortcuts.go_inbox": "Go to the inbox", + "web.shortcuts.go_exercises": "Go to exercises", + "web.shortcuts.go_history": "Go to history", + "web.shortcuts.go_stats": "Go to stats", + "web.shortcuts.sidebar": "Collapse or expand the sidebar", + "web.shortcuts.help": "Show these shortcuts", + "web.shortcuts.single_keys": "Use single-key shortcuts (g i, [, ?)", + "web.shortcuts.note": "Single-key shortcuts never fire while you type in the terminal or a field.", + "web.level.next_one": "1 more strong solve to reach level {next}.", + "web.level.next_other": "{count} more strong solves to reach level {next}.", + "web.level.next_top": "The top level.", + "web.stats.flag.too_few": "Fewer than 10 sessions, too few for a flag.", + "web.terminal.leave": "Tab completes in the terminal. To leave it with the keyboard, press Esc, then Tab." } diff --git a/locales/no.json b/locales/no.json index d624df7..eb8af56 100644 --- a/locales/no.json +++ b/locales/no.json @@ -32,7 +32,7 @@ "lang.usage": "Bruk: rootcause lang en|no", "help.unknown_command": "Ukjent kommando: {command}", "help.usage": "rootcause - praktisk øving i IT-feilsøking\n\nØving:\n init Start Postgres, kjør migreringer, lag den lokale profilen\n next Start den anbefalte neste øvelsen\n start <øvelse-id> Start en bestemt øvelse\n ticket Vis den aktive saken igjen\n shell Åpne et skall i den aktive sandkassen\n hint Vis neste hint\n check Vurder den aktive økten\n reset Ny kopi av samme problem\n giveup Avslutt økten og vis gjennomgangen (etter hint 3)\n status Aktiv økt, nivå per emne, repetisjonskø og beste stjerner\n list Alle øvelser med beste stjerner\n stats [--refresh] Statistikk per øvelse (--refresh bygger den på nytt først)\n serve [--port N] Start det lokale dashbordet på http://localhost:7300\n lang en|no Velg språk for grensesnittet\n\nUtvikler:\n validate Valider en øvelsesdefinisjon\n vars --seed Vis variantverdiene for en seed\n selftest --seed Bevis at en øvelse virker i ekte sandkasser\n", - "error.generic":"Noe gikk galt: {detail}", + "error.generic": "Noe gikk galt: {detail}", "error.detail": "Detaljer: {detail}", "error.no_root": "Finner ikke RootCause-mappen. Kjør rootcause fra repoet, eller sett RC_ROOT til stien.", "error.no_env": "Kan ikke lese deploy/compose/.env. Kopier deploy/compose/.env.example til deploy/compose/.env og sett et passord, eller sett RC_DATABASE_URL.", @@ -186,5 +186,127 @@ "web.outcome.expired": "Utløpt", "web.outcome.voided": "Annullert", "web.duration.minutes": "{minutes} min", - "web.duration.hours": "{hours} t {minutes} min" + "web.duration.hours": "{hours} t {minutes} min", + "web.stats.rebuild_unavailable": "Statistikken bygges av seg selv her, hvert tiende minutt.", + "web.stats.rebuild_running": "En ny bygging pågår allerede. Den blir ferdig av seg selv.", + "web.close": "Lukk", + "web.skip": "Hopp til innholdet", + "web.nav.label": "Hovedmeny", + "web.nav.inbox": "Innboks", + "web.nav.stats": "Statistikk", + "web.nav.collapse": "Skjul sidepanelet", + "web.nav.expand": "Vis sidepanelet", + "web.title.home": "Innboks", + "web.title.exercises": "Øvelser", + "web.title.history": "Historikk", + "web.title.stats": "Statistikk", + "web.levels.title": "Nivå per emne", + "web.level.progress_one": "{topic}: nivå {level}. 1 sterk løsning til for å nå nivå {next}.", + "web.level.progress_other": "{topic}: nivå {level}. {count} sterke løsninger til for å nå nivå {next}.", + "web.level.top": "{topic}: nivå {level}, det høyeste nivået.", + "web.inbox.title": "Innboks", + "web.inbox.intro": "Hver sak er en server med en feil. Åpne den, rett feilen i terminalen og kjør sjekken. Hintene er der når du trenger dem.", + "web.inbox.intro_dismiss": "Skjul dette tipset", + "web.inbox.in_progress": "Pågår", + "web.inbox.resume": "Fortsett", + "web.inbox.open_ticket": "Åpne saken", + "web.inbox.start": "Start", + "web.inbox.locked_active": "Fullfør eller gi opp økten som pågår først.", + "web.tag.next": "Neste", + "web.tag.due": "Til repetisjon", + "web.tag.new": "Ny", + "web.tag.tried": "Prøvd", + "web.tag.due_on": "Repeteres {date}", + "web.why.summary": "Hvorfor denne saken", + "web.why.rule_label": "Valgt fordi", + "web.why.last_label": "Sist", + "web.why.after_label": "Hva skjer så", + "web.why.rule.review": "Den skal repeteres, som en ny variant.", + "web.why.rule.weakest": "Den er fra ditt svakeste emne, {topic}, på nivå {level}.", + "web.why.last": "{outcome}, {date}", + "web.why.never": "Du har ikke prøvd denne ennå.", + "web.why.after.review": "Løs den med 2 stjerner eller mer, så forsvinner den fra repetisjonskøen.", + "web.why.after.level_one": "1 sterk løsning til i {topic} gir deg nivå {next}.", + "web.why.after.level_other": "{count} sterke løsninger til i {topic} gir deg nivå {next}.", + "web.why.after.top": "{topic} er på høyeste nivå. Denne holder det ferskt.", + "web.start.title": "Starter maskinen din", + "web.start.announce": "Starter maskinen.", + "web.start.step.image": "Bygg avbildningen", + "web.start.step.machine": "Start maskinen", + "web.start.step.setup": "Sett opp serveren", + "web.start.step.fault": "Plant feilen", + "web.exercises.sub": "Alle sakene, etter emne. Start hvilken du vil, eller følg innboksen.", + "web.col.ticket": "Sak", + "web.col.last_played": "Sist spilt", + "web.col.actions": "Handlinger", + "web.col.sessions": "Økter", + "web.col.solve_rate": "Løst", + "web.col.avg_hints": "Hint i snitt", + "web.col.avg_time": "Tid i snitt", + "web.col.calibration": "Kalibrering", + "web.col.day": "Dag", + "web.col.solves": "Løst", + "web.col.avg_stars": "Stjerner i snitt", + "web.filter.all": "Alle", + "web.filter.count": "{shown} av {total}", + "web.filter.none": "Ingen økter passer med disse filtrene.", + "web.history.empty_title": "Ingen økter ennå", + "web.history.empty_action": "Gå til innboksen", + "web.session.workbench": "Arbeidsflate", + "web.session.toolbar": "Handlinger for økten", + "web.session.run_check": "Kjør sjekken", + "web.session.show_hint": "Vis hint {tier}", + "web.session.hint_cost_one": "høyst 1 stjerne", + "web.session.hint_cost_other": "høyst {count} stjerner", + "web.session.reset_confirm_short": "Tilbakestille? Klikk igjen", + "web.session.giveup_confirm_short": "Gi opp? Klikk igjen", + "web.session.more": "Flere handlinger", + "web.session.offline": "Dashbordet får ikke kontakt med rootcause serve, så handlingene står på pause til den er tilbake.", + "web.session.timer_show": "Vis tidtakeren", + "web.session.timer_hide": "Skjul tidtakeren", + "web.session.check_log_one": "Sjekklogg (1 sjekk)", + "web.session.check_log_other": "Sjekklogg ({count} sjekker)", + "web.solve.announce": "Løst, {stars} av 3 stjerner.", + "web.solve.no_hints": "Godt jobbet, løst uten hint!", + "web.solve.some_hints": "Godt jobbet, løst.", + "web.solve.with_hints": "Løst. Hintene gjorde jobben sin.", + "web.stats.title": "Statistikk", + "web.stats.rebuild": "Bygg på nytt nå", + "web.stats.days_title": "Løste per dag", + "web.stats.days_sub": "De siste 30 dagene du øvde. Pek på eller fokuser en dag for å se tallene.", + "web.stats.days_empty": "Fullfør en økt, så dukker dagene dine opp her.", + "web.stats.chart_label": "Løste per dag, over {days} dager. Tallene står i tabellen under diagrammet.", + "web.stats.chart_point": "{day}: {solves} løst av {sessions} økter", + "web.stats.tip_solves": "{solves} løst av {sessions}", + "web.stats.tip_stars": "{stars} stjerner i snitt", + "web.stats.show_table": "Vis tallene", + "web.stats.exercises_title": "Per øvelse", + "web.stats.flag.none": "OK", + "web.stats.flag.too_hard": "Kan være for vanskelig", + "web.stats.flag.too_easy": "Kan være for lett", + "web.stats.flag_note": "Et flagg vises etter 10 økter: vanskelighetsgrad 1 løst under halvparten av gangene, eller vanskelighetsgrad 3 løst mer enn 9 av 10 ganger. Med én bruker er det bare til informasjon.", + "web.stats.empty_title": "Ingen statistikk ennå", + "web.stats.empty": "Statistikken bygges fra fullførte økter. Fullfør én, så dukker den opp her, vanligvis innen et minutt.", + "web.palette.label": "Gå til eller gjør", + "web.palette.placeholder": "Søk i sider, saker og handlinger", + "web.palette.none": "Ingen treff.", + "web.palette.open_session": "Åpne økten som pågår", + "web.palette.switch_lang": "Bytt språk", + "web.palette.switch_theme": "Bytt mellom lyst og mørkt", + "web.shortcuts.title": "Hurtigtaster", + "web.shortcuts.palette": "Åpne kommandopaletten", + "web.shortcuts.check": "Kjør sjekken (virker i terminalen også)", + "web.shortcuts.go_inbox": "Gå til innboksen", + "web.shortcuts.go_exercises": "Gå til øvelser", + "web.shortcuts.go_history": "Gå til historikk", + "web.shortcuts.go_stats": "Gå til statistikk", + "web.shortcuts.sidebar": "Skjul eller vis sidepanelet", + "web.shortcuts.help": "Vis disse hurtigtastene", + "web.shortcuts.single_keys": "Bruk hurtigtaster med én tast (g i, [, ?)", + "web.shortcuts.note": "Hurtigtaster med én tast virker aldri mens du skriver i terminalen eller et felt.", + "web.level.next_one": "1 sterk løsning til for å nå nivå {next}.", + "web.level.next_other": "{count} sterke løsninger til for å nå nivå {next}.", + "web.level.next_top": "Høyeste nivå.", + "web.stats.flag.too_few": "Færre enn 10 økter, for få for et flagg.", + "web.terminal.leave": "Tab fullfører i terminalen. For å gå ut av den med tastaturet, trykk Esc og så Tab." }