From 85013f7b8b1db97f6dae2733d818ad89f07330fa Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 5 Jul 2026 13:17:47 +0400 Subject: [PATCH] mavweb: add rule trace page at /trace New template showing the most recent tick's rule evaluation results with color-coded table, expandable gate detail panels, nav links to dash and history. Uses the TickTrace IPC method from #15. --- SESSION-05-07-2026.md | 4 +- cmd/mavweb/dash.html | 2 +- cmd/mavweb/handlers_test.go | 93 +++++++++++++++++++++++++++++++++++++ cmd/mavweb/main.go | 40 ++++++++++++++++ cmd/mavweb/trace.html | 37 +++++++++++++++ 5 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 cmd/mavweb/trace.html diff --git a/SESSION-05-07-2026.md b/SESSION-05-07-2026.md index b97765a..a92deb3 100644 --- a/SESSION-05-07-2026.md +++ b/SESSION-05-07-2026.md @@ -63,7 +63,7 @@ Notes: | 12 | **Recurring reminders** — cron+next_fire_ts cols, RescheduleReminder, dispatcher logic | 1eca17f | done | | 13 | **Capability model** — `scope` column on tools table, migration, UI, tests | 6b80fd0 | done | | 14 | **Notification batching/digest mode** — in-memory queue, configurable window/max_items/severity_ceiling | 354990f | done | -| 15 | **Rule trace / explanation engine** — `why` query: "why did/didn't you nudge me?" Reads predicate eval log. New `/trace` page or CLI query | — | pending | +| 15 | **Rule trace/explanation engine** — ExplainTick/ExplainGate, TickTrace IPC, daemon cache | 2689db1 | done | | 16 | **Backup/restore automation** — scripts/maven-backup.sh with backup/restore/verify/list | 3f09cdb | done | Notes: @@ -79,7 +79,7 @@ Notes: |---|------|--------|--------| | 17 | **In-process auth gate for /tools** — local PasskeySession check on POST, returns 403 if unasserted | 5afff00 | done | | 18 | **Digest / notification history UI** — section on `/dash` showing batched notifications | — | pending | -| 19 | **Rule trace page** — new `/trace` route showing predicate eval results per rule per tick | — | pending | +| 19 | **Rule trace page** — new /trace route showing predicate eval results per rule per tick | — | pending (depends on #15 backend, can now build) | 20 | **Command history page** — new `/history` route showing recent facts/commands | f8ba396 | done | | 21 | **PWA icons** — SVG icon + manifest.json icons array | 00a3bba | done | | 22 | **Language unification** — bilingual cheatsheet with RU/EN toggle in nav + ?lang= param | c225ba3 | done | diff --git a/cmd/mavweb/dash.html b/cmd/mavweb/dash.html index 699c9f3..b21f013 100644 --- a/cmd/mavweb/dash.html +++ b/cmd/mavweb/dash.html @@ -10,7 +10,7 @@ .updated{color:#666;font-size:.85rem;margin-top:-.5rem;margin-bottom:1rem} nav a{color:#8cf;margin-right:1rem;font-size:.85rem} - +

presence

{{.Presence.Bucket}} — score {{printf "%.2f" .Presence.Score}} ({{ago .Presence.Updated}})

обновляется каждые 10с
diff --git a/cmd/mavweb/handlers_test.go b/cmd/mavweb/handlers_test.go index 557cab1..c17d387 100644 --- a/cmd/mavweb/handlers_test.go +++ b/cmd/mavweb/handlers_test.go @@ -55,6 +55,10 @@ type fakeCore struct { // for handleHistory tests historyFacts []ipc.Fact historyErr error + + // for handleTrace tests + tickTrace ipc.TickTrace + traceErr error } func (f *fakeCore) EnableTool(_ context.Context, name string, cmd []string, destructive bool, scope string, _ time.Time) error { @@ -131,6 +135,13 @@ func (f *fakeCore) RevertFact(_ context.Context, _ string) (int64, error) { return f.revertNewID, nil } +func (f *fakeCore) TickTrace(_ context.Context) (ipc.TickTrace, error) { + if f.traceErr != nil { + return ipc.TickTrace{}, f.traceErr + } + return f.tickTrace, nil +} + // --- GET --- func TestHandleTools_GET_RendersAndEscapes(t *testing.T) { @@ -578,6 +589,88 @@ func TestHandleHistory(t *testing.T) { }) } +// --- handleTrace --- + +func TestHandleTrace(t *testing.T) { + t.Parallel() + + t.Run("nil core returns 503", func(t *testing.T) { + rr := httptest.NewRecorder() + handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), nil) + if rr.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want 503", rr.Code) + } + }) + + t.Run("core TickTrace error returns 502", func(t *testing.T) { + core := &fakeCore{traceErr: ipc.ErrNoFact} + rr := httptest.NewRecorder() + handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), core) + if rr.Code != http.StatusBadGateway { + t.Errorf("status = %d, want 502", rr.Code) + } + }) + + t.Run("renders template with trace data", func(t *testing.T) { + now := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) + snooze := time.Date(2025, 6, 1, 13, 0, 0, 0, time.UTC) + core := &fakeCore{ + tickTrace: ipc.TickTrace{ + Now: now, + Winner: "win-rule", + Rules: []ipc.RuleTrace{ + { + RuleName: "win-rule", + Severity: 5, + PredicateResult: true, + GateResult: true, + GateDetail: ipc.GateDetail{Presence: "present"}, + WasSelected: true, + }, + { + RuleName: "lose-rule", + Severity: 3, + PredicateResult: true, + GateResult: false, + GateBlockedBy: "quiet_hours", + GateDetail: ipc.GateDetail{ + QuietHours: true, + Presence: "away", + SnoozeUntil: &snooze, + }, + WasSelected: false, + LostTo: "win-rule", + }, + }, + }, + } + rr := httptest.NewRecorder() + handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), core) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + if !strings.Contains(body, "win-rule") { + t.Error("rendered output missing winning rule name") + } + if !strings.Contains(body, "lose-rule") { + t.Error("rendered output missing losing rule name") + } + if !strings.Contains(body, "quiet_hours") { + t.Error("rendered output missing gate blocked by") + } + if !strings.Contains(body, "5") { + t.Error("rendered output missing severity") + } + if !strings.Contains(body, "3") { + t.Error("rendered output missing severity for second rule") + } + if strings.Contains(body, "nothing fired") { + t.Error("rendered 'nothing fired' but a winner was set") + } + }) +} + // --- handleRevert --- func TestHandleRevert(t *testing.T) { diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index 7f28b67..a0ad818 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -48,6 +48,9 @@ var dashHTML string //go:embed history.html var historyHTML string +//go:embed trace.html +var traceHTML string + // dashTmpl — the monitoring read surface, server-rendered from dash.html (no JS, // no client fetch); meta-refresh keeps it live. html/template escapes the user // text in facts/nudges. Read-only: browses the append-only store via CoreAPI, @@ -133,6 +136,9 @@ func main() { mux.HandleFunc("/history", func(w http.ResponseWriter, r *http.Request) { handleHistory(w, r, core) }) + mux.HandleFunc("/trace", func(w http.ResponseWriter, r *http.Request) { + handleTrace(w, r, core) + }) mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) { handleRevert(w, r, core) }) @@ -372,6 +378,22 @@ input[type=text]{width:22rem}code{background:#f4f4f4;padding:.1rem .3rem} var historyTmpl = template.Must(template.New("history").Parse(historyHTML)) +var traceTmpl = template.Must(template.New("trace").Funcs(template.FuncMap{ + "ago": func(t time.Time) string { + if t.IsZero() { + return "never" + } + return time.Since(t).Round(time.Second).String() + " ago" + }, + "fmtTime": func(t *time.Time) string { + if t == nil || t.IsZero() { + return "—" + } + return t.Format("15:04:05") + }, + "join": strings.Join, +}).Parse(traceHTML)) + func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { if core == nil { http.Error(w, "history disabled (no -core)", http.StatusServiceUnavailable) @@ -392,6 +414,24 @@ func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { } } +func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { + if core == nil { + http.Error(w, "trace disabled (no -core)", http.StatusServiceUnavailable) + return + } + ctx := r.Context() + trace, err := core.TickTrace(ctx) + if err != nil { + log.Printf("trace: %v", err) + http.Error(w, "core read failed", http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := traceTmpl.Execute(w, trace); err != nil { + log.Printf("trace render: %v", err) + } +} + func handleRevert(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) diff --git a/cmd/mavweb/trace.html b/cmd/mavweb/trace.html new file mode 100644 index 0000000..61c176e --- /dev/null +++ b/cmd/mavweb/trace.html @@ -0,0 +1,37 @@ + +maven · rule trace + + +

rule trace

+
{{.Now | ago}} — winner: {{if .Winner}}{{.Winner}}{{else}}nothing fired{{end}}
+ + +{{range .Rules}} + + + + + + + + +{{end}} +
rulesevpredicategateblocked bydetailselectedlost to
{{.RuleName}}{{.Severity}}{{.PredicateResult}}{{.GateResult}}{{.GateBlockedBy}}
gate +snooze_until: {{.GateDetail.SnoozeUntil | fmtTime}}
+cooldown_until: {{.GateDetail.CooldownUntil | fmtTime}}
+quiet_hours: {{.GateDetail.QuietHours}}
+calendar_busy: {{.GateDetail.CalendarBusy}}
+presence: {{.GateDetail.Presence}}
+inert_keys_missing: {{if .GateDetail.InertKeysMissing}}{{join .GateDetail.InertKeysMissing ", "}}{{else}}—{{end}} +
{{.WasSelected}}{{.LostTo}}