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
+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) {