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:
@@ -63,7 +63,7 @@ Notes:
|
|||||||
| 12 | **Recurring reminders** — cron+next_fire_ts cols, RescheduleReminder, dispatcher logic | 1eca17f | done |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 16 | **Backup/restore automation** — scripts/maven-backup.sh with backup/restore/verify/list | 3f09cdb | done |
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
@@ -79,7 +79,7 @@ Notes:
|
|||||||
|---|------|--------|--------|
|
|---|------|--------|--------|
|
||||||
| 17 | **In-process auth gate for /tools** — local PasskeySession check on POST, returns 403 if unasserted | 5afff00 | done |
|
| 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 |
|
| 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 |
|
| 20 | **Command history page** — new `/history` route showing recent facts/commands | f8ba396 | done |
|
||||||
| 21 | **PWA icons** — SVG icon + manifest.json icons array | 00a3bba | 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 |
|
| 22 | **Language unification** — bilingual cheatsheet with RU/EN toggle in nav + ?lang= param | c225ba3 | done |
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
.updated{color:#666;font-size:.85rem;margin-top:-.5rem;margin-bottom:1rem}
|
.updated{color:#666;font-size:.85rem;margin-top:-.5rem;margin-bottom:1rem}
|
||||||
nav a{color:#8cf;margin-right:1rem;font-size:.85rem}
|
nav a{color:#8cf;margin-right:1rem;font-size:.85rem}
|
||||||
</style>
|
</style>
|
||||||
<nav><a href=/history>history</a></nav>
|
<nav><a href=/history>history</a> <a href=/trace>trace</a></nav>
|
||||||
<h2>presence</h2>
|
<h2>presence</h2>
|
||||||
<p><span class={{.Presence.Bucket}}>{{.Presence.Bucket}}</span> — score {{printf "%.2f" .Presence.Score}} ({{ago .Presence.Updated}})</p>
|
<p><span class={{.Presence.Bucket}}>{{.Presence.Bucket}}</span> — score {{printf "%.2f" .Presence.Score}} ({{ago .Presence.Updated}})</p>
|
||||||
<div class=updated>обновляется каждые 10с</div>
|
<div class=updated>обновляется каждые 10с</div>
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ type fakeCore struct {
|
|||||||
// for handleHistory tests
|
// for handleHistory tests
|
||||||
historyFacts []ipc.Fact
|
historyFacts []ipc.Fact
|
||||||
historyErr error
|
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 {
|
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
|
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 ---
|
// --- GET ---
|
||||||
|
|
||||||
func TestHandleTools_GET_RendersAndEscapes(t *testing.T) {
|
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 ---
|
// --- handleRevert ---
|
||||||
|
|
||||||
func TestHandleRevert(t *testing.T) {
|
func TestHandleRevert(t *testing.T) {
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ var dashHTML string
|
|||||||
//go:embed history.html
|
//go:embed history.html
|
||||||
var historyHTML string
|
var historyHTML string
|
||||||
|
|
||||||
|
//go:embed trace.html
|
||||||
|
var traceHTML string
|
||||||
|
|
||||||
// dashTmpl — the monitoring read surface, server-rendered from dash.html (no JS,
|
// 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
|
// 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,
|
// 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) {
|
mux.HandleFunc("/history", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleHistory(w, r, core)
|
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) {
|
mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleRevert(w, r, core)
|
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 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) {
|
func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||||
if core == nil {
|
if core == nil {
|
||||||
http.Error(w, "history disabled (no -core)", http.StatusServiceUnavailable)
|
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) {
|
func handleRevert(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user