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