From 45d1d98e56d9564d9d358930d5fe902946770b26 Mon Sep 17 00:00:00 2001 From: Thomas Cravey Date: Sat, 16 Aug 2025 15:24:50 -0500 Subject: [PATCH] feat(webui): add minimal dashboard (Pico.css) with tail, trigger, status; JSON endpoints: /api/info,/api/channels,/api/tail,/api/trigger; default compose/UI auth via HTTP_TOKEN --- cmd/sojuboy/main.go | 4 + internal/httpapi/server.go | 190 +++++++++++++++++++++++++++++++++++++ internal/store/store.go | 18 ++++ 3 files changed, 212 insertions(+) diff --git a/cmd/sojuboy/main.go b/cmd/sojuboy/main.go index 66eb654..51a55cd 100644 --- a/cmd/sojuboy/main.go +++ b/cmd/sojuboy/main.go @@ -118,6 +118,10 @@ func main() { Metrics: metrics, Ready: func() bool { return atomic.LoadInt64(&metrics.ConnectedGauge) == 1 }, SummarizerTimeout: cfg.SummarizerTimeout, + Version: version, + Commit: commit, + BuiltAt: builtAt, + StartedAt: time.Now(), } go func() { if err := api.Start(ctx); err != nil && err != http.ErrServerClosed { diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 7566b10..3fd1276 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -1,6 +1,7 @@ package httpapi import ( + "encoding/json" "context" "fmt" "log/slog" @@ -34,10 +35,17 @@ type Server struct { Ready func() bool // Optional timeout override for summarizer SummarizerTimeout time.Duration + // Build/runtime info for UI + Version string + Commit string + BuiltAt string + StartedAt time.Time } func (s *Server) Start(ctx context.Context) error { mux := http.NewServeMux() + // Minimal web UI + mux.HandleFunc("/", s.handleUI) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) @@ -54,6 +62,11 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("/trigger", s.handleTrigger) mux.HandleFunc("/tail", s.handleTail) mux.HandleFunc("/metrics", s.handleMetrics) + // JSON endpoints for UI + mux.HandleFunc("/api/info", s.handleInfo) + mux.HandleFunc("/api/channels", s.handleChannels) + mux.HandleFunc("/api/tail", s.handleTailJSON) + mux.HandleFunc("/api/trigger", s.handleTriggerJSON) srv := &http.Server{ Addr: s.ListenAddr, @@ -184,6 +197,183 @@ func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) { _, _ = fmt.Fprintf(w, "sojuboy_connected %d\n", conn) } +// --- Web UI handlers --- + +func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + // Pico.css from CDN and a tiny app + page := ` + + + + + sojuboy + + + + + +
+

sojuboy

+
+
+ + + + +
+

+    
+
+
+ + + +
+

+    
+
+

Status

+
    +
  • Version:
  • +
  • Built:
  • +
  • Uptime:
  • +
  • Connected:
  • +
  • Counters:
  • +
+
+
+ +` + _, _ = w.Write([]byte(page)) +} + +func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "version": s.Version, + "commit": s.Commit, + "builtAt": s.BuiltAt, + "startedAt": s.StartedAt.Format(time.RFC3339), + "uptime": time.Since(s.StartedAt).Round(time.Second).String(), + "messagesIngested": func() int64 { if s.Metrics==nil {return 0}; return atomic.LoadInt64(&s.Metrics.MessagesIngested) }(), + "notificationsSent": func() int64 { if s.Metrics==nil {return 0}; return atomic.LoadInt64(&s.Metrics.NotificationsSent) }(), + "messagesPruned": func() int64 { if s.Metrics==nil {return 0}; return atomic.LoadInt64(&s.Metrics.MessagesPruned) }(), + "connected": func() bool { if s.Metrics==nil {return false}; return atomic.LoadInt64(&s.Metrics.ConnectedGauge)==1 }(), + } + _ = json.NewEncoder(w).Encode(resp) +} + +func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) { + if s.AuthToken != "" && !checkAuth(r, s.AuthToken) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("unauthorized")) + return + } + chs, err := s.Store.ListChannels(r.Context()) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("store error")) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(chs) +} + +func (s *Server) handleTailJSON(w http.ResponseWriter, r *http.Request) { + if s.AuthToken != "" && !checkAuth(r, s.AuthToken) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("unauthorized")) + return + } + channel := r.URL.Query().Get("channel") + if channel == "" { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("missing channel")); return } + limit := getIntQuery(r, "limit", 100) + msgs, err := s.Store.ListRecentMessages(r.Context(), channel, limit) + if err != nil { w.WriteHeader(http.StatusInternalServerError); _, _ = w.Write([]byte("store error")); return } + w.Header().Set("Content-Type", "application/json") + type outMsg struct{ Time string `json:"time"`; Author string `json:"author"`; Body string `json:"body"`; Channel string `json:"channel"` } + arr := make([]outMsg, 0, len(msgs)) + for i := len(msgs)-1; i>=0; i-- { m := msgs[i]; arr = append(arr, outMsg{Time: m.Time.UTC().Format(time.RFC3339), Author: m.Author, Body: m.Body, Channel: channel}) } + _ = json.NewEncoder(w).Encode(arr) +} + +func (s *Server) handleTriggerJSON(w http.ResponseWriter, r *http.Request) { + if s.AuthToken != "" && !checkAuth(r, s.AuthToken) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("unauthorized")) + return + } + channel := r.URL.Query().Get("channel") + if channel == "" { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("missing channel")); return } + win := r.URL.Query().Get("window"); if win=="" { win = "6h" } + push := r.URL.Query().Get("push") == "1" + dur, err := time.ParseDuration(win) + if err != nil { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("bad window")); return } + msgs, err := s.Store.ListMessagesSince(r.Context(), channel, time.Now().Add(-dur)) + if err != nil { w.WriteHeader(http.StatusInternalServerError); _, _ = w.Write([]byte("store error")); return } + if s.Summarizer == nil { w.WriteHeader(http.StatusServiceUnavailable); _, _ = w.Write([]byte("summarizer not configured")); return } + tout := s.SummarizerTimeout; if tout<=0 { tout = 5*time.Minute } + ctx, cancel := context.WithTimeout(r.Context(), tout); defer cancel() + sum, err := s.Summarizer.Summarize(ctx, channel, msgs, dur) + if err != nil { w.WriteHeader(http.StatusBadGateway); _, _ = w.Write([]byte("summarizer error")); return } + if push && s.Notifier != nil { title := fmt.Sprintf("IRC digest %s (%s)", channel, dur); _ = s.Notifier.Notify(r.Context(), title, sum); if s.Metrics != nil { atomic.AddInt64(&s.Metrics.NotificationsSent, 1) } } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"summary": sum}) +} + func checkAuth(r *http.Request, token string) bool { auth := r.Header.Get("Authorization") if strings.HasPrefix(auth, "Bearer ") { diff --git a/internal/store/store.go b/internal/store/store.go index d7269cf..503fc05 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -67,6 +67,24 @@ func (s *Store) InsertMessage(ctx context.Context, m Message) error { return err } +// ListChannels returns distinct channel identifiers seen in the database. +func (s *Store) ListChannels(ctx context.Context) ([]string, error) { + rows, err := s.db.QueryContext(ctx, "SELECT DISTINCT channel FROM messages ORDER BY lower(channel)") + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var ch string + if err := rows.Scan(&ch); err != nil { + return nil, err + } + out = append(out, ch) + } + return out, rows.Err() +} + func nullIfEmpty(s string) any { if s == "" { return nil