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.
This commit is contained in:
kami
2026-07-05 13:17:47 +04:00
parent 2689db1248
commit 85013f7b8b
5 changed files with 173 additions and 3 deletions
+2 -2
View File
@@ -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 |
+1 -1
View File
@@ -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}
</style>
<nav><a href=/history>history</a></nav>
<nav><a href=/history>history</a> <a href=/trace>trace</a></nav>
<h2>presence</h2>
<p><span class={{.Presence.Bucket}}>{{.Presence.Bucket}}</span> — score {{printf "%.2f" .Presence.Score}} ({{ago .Presence.Updated}})</p>
<div class=updated>обновляется каждые 10с</div>
+93
View File
@@ -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) {
+40
View File
@@ -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)
+37
View File
@@ -0,0 +1,37 @@
<!doctype html><meta charset=utf-8>
<title>maven · rule trace</title>
<style>
body{font:14px monospace;background:#111;color:#ddd;margin:1rem}
h2{color:#8cf;margin:1.2rem 0 .3rem}
table{border-collapse:collapse;width:100%}
td,th{border-bottom:1px solid #333;padding:.2rem .5rem;text-align:left;vertical-align:top}
.green{color:#6c6}.red{color:#f66}.gray{color:#888}
.updated{color:#666;font-size:.85rem}
nav a{color:#8cf;margin-right:1rem;font-size:.85rem}
details{font-size:.85rem;color:#aaa;margin-top:.2rem}
summary{cursor:pointer}
.gl{color:#888}
</style>
<nav><a href=/dash>dash</a> <a href=/history>history</a></nav>
<h2>rule trace</h2>
<div class=updated>{{.Now | ago}} — winner: <strong>{{if .Winner}}{{.Winner}}{{else}}nothing fired{{end}}</strong></div>
<table>
<tr><th>rule<th>sev<th>predicate<th>gate<th>blocked by<th>detail<th>selected<th>lost to</tr>
{{range .Rules}}<tr>
<td>{{.RuleName}}</td>
<td>{{.Severity}}</td>
<td class={{if .PredicateResult}}green{{else}}gray{{end}}>{{.PredicateResult}}</td>
<td class={{if .GateResult}}green{{else if .GateBlockedBy}}red{{else}}gray{{end}}>{{.GateResult}}</td>
<td>{{.GateBlockedBy}}</td>
<td><details><summary>gate</summary>
<span class=gl>snooze_until:</span> {{.GateDetail.SnoozeUntil | fmtTime}}<br>
<span class=gl>cooldown_until:</span> {{.GateDetail.CooldownUntil | fmtTime}}<br>
<span class=gl>quiet_hours:</span> {{.GateDetail.QuietHours}}<br>
<span class=gl>calendar_busy:</span> {{.GateDetail.CalendarBusy}}<br>
<span class=gl>presence:</span> {{.GateDetail.Presence}}<br>
<span class=gl>inert_keys_missing:</span> {{if .GateDetail.InertKeysMissing}}{{join .GateDetail.InertKeysMissing ", "}}{{else}}—{{end}}
</details></td>
<td class={{if .WasSelected}}green{{end}}>{{.WasSelected}}</td>
<td>{{.LostTo}}</td>
</tr>{{end}}
</table>