mavweb: add notifications history page at /notifications
RecentNudges IPC method, store adapter, dispatch, and client proxy. Web UI at /notifications showing recent nudge history with color-coded outcomes, nav links from /dash and /history.
This commit is contained in:
@@ -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> <a href=/trace>trace</a></nav>
|
||||
<nav><a href=/history>history</a> <a href=/trace>trace</a> <a href=/notifications>notifications</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>
|
||||
|
||||
@@ -52,6 +52,9 @@ type fakeCore struct {
|
||||
revertNewID int64
|
||||
revertErr error
|
||||
|
||||
// for handleNotifications tests
|
||||
nudgesErr error
|
||||
|
||||
// for handleHistory tests
|
||||
historyFacts []ipc.Fact
|
||||
historyErr error
|
||||
@@ -115,6 +118,9 @@ func (f *fakeCore) RecentFacts(_ context.Context, _ int) ([]ipc.Fact, error) {
|
||||
}
|
||||
|
||||
func (f *fakeCore) RecentNudges(_ context.Context, _ int) ([]ipc.Nudge, error) {
|
||||
if f.nudgesErr != nil {
|
||||
return nil, f.nudgesErr
|
||||
}
|
||||
if f.dashErr != nil {
|
||||
return nil, f.dashErr
|
||||
}
|
||||
@@ -549,6 +555,58 @@ func TestHandleDash(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// --- handleNotifications ---
|
||||
|
||||
func TestHandleNotifications_NilCore_503(t *testing.T) {
|
||||
t.Parallel()
|
||||
rr := httptest.NewRecorder()
|
||||
handleNotifications(rr, httptest.NewRequest(http.MethodGet, "/notifications", nil), nil)
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("status = %d, want 503", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNotifications_CoreError_502(t *testing.T) {
|
||||
t.Parallel()
|
||||
core := &fakeCore{nudgesErr: ipc.ErrNudgeNotFound}
|
||||
rr := httptest.NewRecorder()
|
||||
handleNotifications(rr, httptest.NewRequest(http.MethodGet, "/notifications", nil), core)
|
||||
if rr.Code != http.StatusBadGateway {
|
||||
t.Errorf("status = %d, want 502", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNotifications_Renders(t *testing.T) {
|
||||
t.Parallel()
|
||||
core := &fakeCore{
|
||||
nudges: []ipc.Nudge{
|
||||
{Rule: "test-rule", Channel: "telegram", Message: "hello world", Outcome: "pending"},
|
||||
{Rule: "other-rule", Channel: "voice", Message: "something happened", Outcome: "acted"},
|
||||
},
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
handleNotifications(rr, httptest.NewRequest(http.MethodGet, "/notifications", 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, "test-rule") {
|
||||
t.Error("rendered output missing rule name")
|
||||
}
|
||||
if !strings.Contains(body, "telegram") {
|
||||
t.Error("rendered output missing channel")
|
||||
}
|
||||
if !strings.Contains(body, "pending") {
|
||||
t.Error("rendered output missing outcome")
|
||||
}
|
||||
if !strings.Contains(body, "hello world") {
|
||||
t.Error("rendered output missing message")
|
||||
}
|
||||
if !strings.Contains(body, "other-rule") {
|
||||
t.Error("rendered output missing second rule")
|
||||
}
|
||||
}
|
||||
|
||||
// --- handleHistory ---
|
||||
|
||||
func TestHandleHistory(t *testing.T) {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
.msg-err{background:#ffe6e6;display:block!important}
|
||||
</style>
|
||||
<h1>maven · command history</h1>
|
||||
<nav><a href=/dash>dash</a> <a href=/tools>tools</a> <a href=/auth/passkey>passkey</a></nav>
|
||||
<nav><a href=/dash>dash</a> <a href=/tools>tools</a> <a href=/auth/passkey>passkey</a> <a href=/notifications>notifications</a></nav>
|
||||
<p class=count>{{len .Facts}} facts shown (newest first)</p>
|
||||
<div id=msg></div>
|
||||
<table>
|
||||
|
||||
@@ -51,6 +51,9 @@ var historyHTML string
|
||||
//go:embed trace.html
|
||||
var traceHTML string
|
||||
|
||||
//go:embed notifications.html
|
||||
var notificationsHTML 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,
|
||||
@@ -139,6 +142,9 @@ func main() {
|
||||
mux.HandleFunc("/trace", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleTrace(w, r, core)
|
||||
})
|
||||
mux.HandleFunc("/notifications", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleNotifications(w, r, core)
|
||||
})
|
||||
mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleRevert(w, r, core)
|
||||
})
|
||||
@@ -378,6 +384,8 @@ input[type=text]{width:22rem}code{background:#f4f4f4;padding:.1rem .3rem}
|
||||
|
||||
var historyTmpl = template.Must(template.New("history").Parse(historyHTML))
|
||||
|
||||
var notificationsTmpl = template.Must(template.New("notifications").Parse(notificationsHTML))
|
||||
|
||||
var traceTmpl = template.Must(template.New("trace").Funcs(template.FuncMap{
|
||||
"ago": func(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
@@ -414,6 +422,24 @@ func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
}
|
||||
}
|
||||
|
||||
func handleNotifications(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if core == nil {
|
||||
http.Error(w, "notifications disabled (no -core)", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
nudges, err := core.RecentNudges(ctx, 50)
|
||||
if err != nil {
|
||||
log.Printf("notifications: %v", err)
|
||||
http.Error(w, "notifications error: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := notificationsTmpl.Execute(w, map[string]any{"Nudges": nudges}); err != nil {
|
||||
log.Printf("notifications template: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if core == nil {
|
||||
http.Error(w, "trace disabled (no -core)", http.StatusServiceUnavailable)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Notifications — Maven</title>
|
||||
<style>
|
||||
body{background:#111;color:#ddd;font-family:system-ui,sans-serif;margin:0;padding:1rem;font-size:.9rem}
|
||||
a{color:#00aaff;text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
h1{font-size:1.2rem;font-weight:400;color:#888;letter-spacing:.1em;text-transform:uppercase}
|
||||
nav{margin-bottom:1rem;display:flex;gap:1rem}
|
||||
nav a{font-size:.85rem;letter-spacing:.05em;text-transform:uppercase;color:#666}
|
||||
nav a:hover{color:#ddd}
|
||||
table{width:100%;border-collapse:collapse;margin-top:.5rem}
|
||||
th,td{padding:.4rem .5rem;text-align:left;border-bottom:1px solid #222;white-space:nowrap}
|
||||
th{color:#555;font-size:.75rem;text-transform:uppercase;letter-spacing:.05em}
|
||||
td{color:#aaa}
|
||||
.rule{color:#00ff88;font-weight:600}
|
||||
.chan{font-size:.75rem;color:#555;background:#1a1a2e;padding:.15rem .35rem;border-radius:3px}
|
||||
.pending{color:#888}
|
||||
.acted{color:#00ff88}
|
||||
.snoozed{color:#ffaa00}
|
||||
.ignored{color:#ff4444}
|
||||
.msg{color:#666;max-width:300px;overflow:hidden;text-overflow:ellipsis}
|
||||
.ts{color:#555;font-size:.8rem}
|
||||
.empty{color:#555;padding:2rem;text-align:center}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href=/dash>dash</a>
|
||||
<a href=/trace>trace</a>
|
||||
<a href=/tools>tools</a>
|
||||
<a href=/history>history</a>
|
||||
</nav>
|
||||
<h1>Notifications</h1>
|
||||
{{if .Nudges}}<table>
|
||||
<tr><th>when</th><th>rule</th><th>channel</th><th>outcome</th><th>message</th></tr>
|
||||
{{range .Nudges}}<tr>
|
||||
<td class=ts>{{.Ts.Format "15:04"}}</td>
|
||||
<td class=rule>{{.Rule}}</td>
|
||||
<td><span class=chan>{{.Channel}}</span></td>
|
||||
<td class={{.Outcome}}>{{.Outcome}}</td>
|
||||
<td class=msg>{{.Message}}</td>
|
||||
</tr>{{end}}</table>
|
||||
{{else}}<div class=empty>no notifications yet</div>{{end}}
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user