From 8a6111aeb540af2c08a26cb377d74debf2e142cf Mon Sep 17 00:00:00 2001 From: Thomas Cravey Date: Sat, 16 Aug 2025 21:38:49 -0500 Subject: [PATCH 01/32] feat(webui): templates + static assets; SSE broadcast; link cards; link summary endpoint; raw soju client store; UI polish - Switch UI to Go html/templates and embedded /static (Pico.css-compatible) - Add Server.Broadcast and normalize SSE channel keys to lowercase - Implement /api/linkcard (OG/Twitter) and /api/linksummary (24h cache) - Wire Store into raw soju client for CHATHISTORY LATEST fallback --- cmd/sojuboy/main.go | 25 +- internal/httpapi/server.go | 653 ++++++++++++--------- internal/httpapi/static/app.css | 13 + internal/httpapi/static/app.js | 39 ++ internal/httpapi/templates.go | 47 ++ internal/httpapi/templates/dashboard.tmpl | 12 + internal/httpapi/templates/layout.tmpl | 27 + internal/httpapi/templates/summarizer.tmpl | 14 + 8 files changed, 537 insertions(+), 293 deletions(-) create mode 100644 internal/httpapi/static/app.css create mode 100644 internal/httpapi/static/app.js create mode 100644 internal/httpapi/templates.go create mode 100644 internal/httpapi/templates/dashboard.tmpl create mode 100644 internal/httpapi/templates/layout.tmpl create mode 100644 internal/httpapi/templates/summarizer.tmpl diff --git a/cmd/sojuboy/main.go b/cmd/sojuboy/main.go index 45e6117..25c1a0c 100644 --- a/cmd/sojuboy/main.go +++ b/cmd/sojuboy/main.go @@ -223,10 +223,11 @@ func main() { Password: cfg.Password, Channels: cfg.Channels, BackfillLatest: backfill, + Store: st, OnPrivmsg: func(channel, author, text, msgid string, at time.Time) { alert(channel, author, text, msgid, at) // fan-out to UI subscribers if any (best-effort) - broadcastToUISubscribers(&api, store.Message{Channel: channel, Author: author, Body: text, Time: at.UTC(), MsgID: msgid}) + api.Broadcast(strings.ToLower(channel), store.Message{Channel: channel, Author: author, Body: text, Time: at.UTC(), MsgID: msgid}) }, ConnectedGauge: &metrics.ConnectedGauge, } @@ -292,25 +293,3 @@ func main() { <-ctx.Done() logger.Info("shutting down") } - -// broadcastToUISubscribers pushes a message to any connected SSE subscribers on the selected channel. -func broadcastToUISubscribers(api *httpapi.Server, m store.Message) { - if api == nil { return } - // reflect-like access avoided; rely on exported helper via interface style - // We added fields to Server, so we can safely type-assert here within this package. - // Iterate subscribers with internal lock via small helper method. - type subAccess interface{ Broadcast(channel string, m store.Message) } - if b, ok := any(api).(subAccess); ok { b.Broadcast(strings.ToLower(m.Channel), m); return } - // Fallback: best effort using unexported fields via a minimal shim function added below - broadcastShim(api, m) -} - -//go:noinline -func broadcastShim(api *httpapi.Server, m store.Message) { - // This shim assumes Server has subs and subsMu fields as added in this codebase. - // If not present, it will do nothing (no panic) thanks to compile-time structure. - // Since we are in the same module, we can update together. - // WARNING: keep in sync with httpapi.Server struct. - // Using an internal copy of the logic to avoid import cycles. - // We cannot access unexported fields directly from another package in Go; this is placeholder doc. -} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index da279a1..2cfa5a4 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -1,9 +1,10 @@ package httpapi import ( - "encoding/json" "context" + "encoding/json" "fmt" + "io/fs" "log/slog" "net/http" "net/url" @@ -15,7 +16,8 @@ import ( "sojuboy/internal/store" "sojuboy/internal/summarizer" - xhtml "golang.org/x/net/html" + + xhtml "golang.org/x/net/html" ) type Metrics struct { @@ -51,13 +53,20 @@ type Server struct { // Link card cache cardCache map[string]linkCard cardCacheExp map[string]time.Time + // Link summary cache + summaryCache map[string]string + summaryCacheExp map[string]time.Time } func (s *Server) Start(ctx context.Context) error { mux := http.NewServeMux() - // Minimal web UI - mux.HandleFunc("/", s.handleUI) - mux.HandleFunc("/summarizer", s.handleSummarizerUI) + // Minimal web UI (templated) + mux.HandleFunc("/", s.handleUIDash) + mux.HandleFunc("/summarizer", s.handleUISummarizer) + // Serve embedded static files under /static/ + if sub, err := fs.Sub(staticFS, "."); err == nil { + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) + } mux.HandleFunc("/login", s.handleLogin) mux.HandleFunc("/auth", s.handleAuth) mux.HandleFunc("/logout", s.handleLogout) @@ -85,6 +94,7 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("/api/history", s.handleHistory) mux.HandleFunc("/api/stream", s.handleStream) mux.HandleFunc("/api/linkcard", s.handleLinkCard) + mux.HandleFunc("/api/linksummary", s.handleLinkSummary) srv := &http.Server{ Addr: s.ListenAddr, @@ -198,67 +208,158 @@ func (s *Server) handleTail(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(m.Time.UTC().Format(time.RFC3339) + " " + m.Author + " " + channel + " " + m.Body + "\n")) } } + // Simple link card structure -type linkCard struct{ - URL string `json:"url"` - Title string `json:"title"` - Description string `json:"description"` - Image string `json:"image"` +type linkCard struct { + URL string `json:"url"` + Title string `json:"title"` + Description string `json:"description"` + Image string `json:"image"` } // handleLinkCard fetches basic OpenGraph/Twitter card metadata (best-effort) and returns a small card. func (s *Server) handleLinkCard(w http.ResponseWriter, r *http.Request) { - if s.AuthToken != "" && !checkAuth(r, s.AuthToken) { - w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte("unauthorized")) - return - } - raw := r.URL.Query().Get("url") - if raw == "" { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("missing url")); return } - u, err := url.Parse(raw) - if err != nil || (u.Scheme != "http" && u.Scheme != "https") { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("bad url")); return } - // cache lookup - if s.cardCache == nil { s.cardCache = make(map[string]linkCard); s.cardCacheExp = make(map[string]time.Time) } - if exp, ok := s.cardCacheExp[raw]; ok && time.Now().Before(exp) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(s.cardCache[raw]) - return - } - // fetch minimal HTML and extract tags using a tolerant HTML parser - client := &http.Client{ Timeout: 10 * time.Second } - req, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, raw, nil) - resp, err := client.Do(req) - if err != nil { w.WriteHeader(http.StatusBadGateway); _, _ = w.Write([]byte("fetch error")); return } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { w.WriteHeader(http.StatusBadGateway); _, _ = w.Write([]byte("bad status")); return } - // limit to 256KB and parse tokens - limited := http.MaxBytesReader(w, resp.Body, 262144) - doc, err := xhtml.Parse(limited) - if err != nil { w.WriteHeader(http.StatusBadGateway); _, _ = w.Write([]byte("parse error")); return } - var title, desc, img string - var walker func(*xhtml.Node) - walker = func(n *xhtml.Node) { - if n.Type == xhtml.ElementNode && strings.EqualFold(n.Data, "meta") { - // property or name + content - var pn = ""; var nm = ""; var content = "" - for _, a := range n.Attr { if strings.EqualFold(a.Key, "property") { pn = a.Val } else if strings.EqualFold(a.Key, "name") { nm = a.Val } else if strings.EqualFold(a.Key, "content") { content = a.Val } } - key := strings.ToLower(pn) - if key == "" { key = strings.ToLower(nm) } - switch key { - case "og:title", "twitter:title": if title == "" { title = content } - case "og:description", "twitter:description": if desc == "" { desc = content } - case "og:image", "twitter:image": if img == "" { img = content } - } - } - for c := n.FirstChild; c != nil; c = c.NextSibling { walker(c) } - } - walker(doc) - card := linkCard{ URL: raw, Title: strings.TrimSpace(title), Description: strings.TrimSpace(desc), Image: strings.TrimSpace(img) } - // cache for 24h - s.cardCache[raw] = card - s.cardCacheExp[raw] = time.Now().Add(24 * time.Hour) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(card) + if s.AuthToken != "" && !checkAuth(r, s.AuthToken) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("unauthorized")) + return + } + raw := r.URL.Query().Get("url") + if raw == "" { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("missing url")) + return + } + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("bad url")) + return + } + // cache lookup + if s.cardCache == nil { + s.cardCache = make(map[string]linkCard) + s.cardCacheExp = make(map[string]time.Time) + } + if exp, ok := s.cardCacheExp[raw]; ok && time.Now().Before(exp) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(s.cardCache[raw]) + return + } + // fetch minimal HTML and extract tags using a tolerant HTML parser + client := &http.Client{Timeout: 10 * time.Second} + req, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, raw, nil) + resp, err := client.Do(req) + if err != nil { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte("fetch error")) + return + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte("bad status")) + return + } + // limit to 256KB and parse tokens + limited := http.MaxBytesReader(w, resp.Body, 262144) + doc, err := xhtml.Parse(limited) + if err != nil { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte("parse error")) + return + } + var title, desc, img string + var walker func(*xhtml.Node) + walker = func(n *xhtml.Node) { + if n.Type == xhtml.ElementNode && strings.EqualFold(n.Data, "meta") { + // property or name + content + var pn = "" + var nm = "" + var content = "" + for _, a := range n.Attr { + if strings.EqualFold(a.Key, "property") { + pn = a.Val + } else if strings.EqualFold(a.Key, "name") { + nm = a.Val + } else if strings.EqualFold(a.Key, "content") { + content = a.Val + } + } + key := strings.ToLower(pn) + if key == "" { + key = strings.ToLower(nm) + } + switch key { + case "og:title", "twitter:title": + if title == "" { + title = content + } + case "og:description", "twitter:description": + if desc == "" { + desc = content + } + case "og:image", "twitter:image": + if img == "" { + img = content + } + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + walker(c) + } + } + walker(doc) + card := linkCard{URL: raw, Title: strings.TrimSpace(title), Description: strings.TrimSpace(desc), Image: strings.TrimSpace(img)} + // cache for 24h + s.cardCache[raw] = card + s.cardCacheExp[raw] = time.Now().Add(24 * time.Hour) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(card) +} + +// handleLinkSummary returns a brief AI summary for a given URL, cached for 24h. +func (s *Server) handleLinkSummary(w http.ResponseWriter, r *http.Request) { + if s.AuthToken != "" && !checkAuth(r, s.AuthToken) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("unauthorized")) + return + } + raw := r.URL.Query().Get("url") + if raw == "" { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("missing url")) + return + } + if s.Summarizer == nil { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte("summarizer not configured")) + return + } + if s.summaryCache == nil { s.summaryCache = make(map[string]string) } + if s.summaryCacheExp == nil { s.summaryCacheExp = make(map[string]time.Time) } + if exp, ok := s.summaryCacheExp[raw]; ok && time.Now().Before(exp) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"summary": s.summaryCache[raw]}) + return + } + msgs := []store.Message{{Channel: "#links", Author: "link", Body: raw, Time: time.Now().UTC()}} + tout := s.SummarizerTimeout + if tout <= 0 { tout = 5 * time.Minute } + if tout > 2*time.Minute { tout = 2 * time.Minute } + ctx, cancel := context.WithTimeout(r.Context(), tout) + defer cancel() + sum, err := s.Summarizer.Summarize(ctx, "#links", msgs, 0) + if err != nil { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte("summarizer error")) + return + } + if sum == "" { sum = "(no summary)" } + s.summaryCache[raw] = sum + s.summaryCacheExp[raw] = time.Now().Add(24 * time.Hour) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"summary": sum}) } func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) { @@ -281,7 +382,7 @@ func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) { // --- Web UI handlers --- -func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleUIDash(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { w.WriteHeader(http.StatusNotFound) return @@ -293,202 +394,121 @@ func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) { return } } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - // Pico.css from CDN and a tiny app - page := ` - - - - - sojuboy - - - - - - -
- -
-
-
-
- - -` - _, _ = w.Write([]byte(page)) + s.render(w, "dashboard.tmpl", map[string]any{}) } -func (s *Server) handleSummarizerUI(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleUISummarizer(w http.ResponseWriter, r *http.Request) { if s.AuthToken != "" { if c, err := r.Cookie("auth_token"); err != nil || c.Value != s.AuthToken { http.Redirect(w, r, "/login", http.StatusFound) return } } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - page := `Summarizer · sojuboy

On-demand summarization

` - _, _ = w.Write([]byte(page)) + s.render(w, "summarizer.tmpl", map[string]any{}) } 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, + "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 }(), + "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) } -// Summarizer simple page placeholder (will reuse existing summarizer flow) -// removed duplicate handleSummarizerUI definition (consolidated earlier) - // SSE stream of new messages for a channel func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) { - if s.AuthToken != "" && !checkAuth(r, s.AuthToken) { - w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte("unauthorized")) - return - } - ch := strings.TrimSpace(r.URL.Query().Get("channel")) - if ch == "" { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("missing channel")); return } - key := strings.ToLower(ch) - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - flusher, ok := w.(http.Flusher) - if !ok { w.WriteHeader(http.StatusInternalServerError); return } + if s.AuthToken != "" && !checkAuth(r, s.AuthToken) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("unauthorized")) + return + } + ch := strings.TrimSpace(r.URL.Query().Get("channel")) + if ch == "" { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("missing channel")) + return + } + key := strings.ToLower(ch) // Normalize channel name to lowercase - // register subscriber - if s.subs == nil { s.subs = make(map[string][]chan store.Message) } - sub := make(chan store.Message, 64) - s.subsMu.Lock() - s.subs[key] = append(s.subs[key], sub) - s.subsMu.Unlock() - defer func(){ - s.subsMu.Lock() - subs := s.subs[key] - for i := range subs { - if subs[i] == sub { - s.subs[key] = append(subs[:i], subs[i+1:]...) - break - } - } - s.subsMu.Unlock() - close(sub) - }() + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + flusher, ok := w.(http.Flusher) + if !ok { + w.WriteHeader(http.StatusInternalServerError) + return + } - hb := time.NewTicker(15 * time.Second) - defer hb.Stop() - ctx := r.Context() - for { - select { - case <-ctx.Done(): - return - case m := <-sub: - b, _ := json.Marshal(map[string]any{ - "time": m.Time.UTC().Format(time.RFC3339), - "author": m.Author, - "body": m.Body, - "channel": m.Channel, - }) - _, _ = fmt.Fprintf(w, "data: %s\n\n", b) - flusher.Flush() - case <-hb.C: - _, _ = fmt.Fprintf(w, ": ping\n\n") - flusher.Flush() - } - } + // register subscriber + if s.subs == nil { + s.subs = make(map[string][]chan store.Message) + } + sub := make(chan store.Message, 64) + s.subsMu.Lock() + s.subs[key] = append(s.subs[key], sub) + s.subsMu.Unlock() + defer func() { + s.subsMu.Lock() + subs := s.subs[key] + for i := range subs { + if subs[i] == sub { + s.subs[key] = append(subs[:i], subs[i+1:]...) + break + } + } + s.subsMu.Unlock() + close(sub) + }() + + hb := time.NewTicker(15 * time.Second) // SSE heartbeat + defer hb.Stop() + ctx := r.Context() + for { + select { + case <-ctx.Done(): + return + case m := <-sub: + b, _ := json.Marshal(map[string]any{ + "time": m.Time.UTC().Format(time.RFC3339), + "author": m.Author, + "body": m.Body, + "channel": m.Channel, + }) + _, _ = fmt.Fprintf(w, "data: %s\n\n", b) + flusher.Flush() + case <-hb.C: + _, _ = fmt.Fprintf(w, ": ping\n\n") + flusher.Flush() + } + } } func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) { @@ -517,14 +537,30 @@ func (s *Server) handleTailJSON(w http.ResponseWriter, r *http.Request) { return } channel := r.URL.Query().Get("channel") - if channel == "" { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("missing channel")); return } + 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 } + 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"` } + 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}) } + 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) } @@ -535,43 +571,95 @@ func (s *Server) handleTriggerJSON(w http.ResponseWriter, r *http.Request) { 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" } + 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 } + 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() + 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) } } + 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}) } // history paging func (s *Server) handleHistory(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") - beforeStr := r.URL.Query().Get("before") - if channel == "" || beforeStr == "" { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("missing params")); return } - t, err := time.Parse(time.RFC3339, beforeStr) - if err != nil { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("bad before")); return } - limit := getIntQuery(r, "limit", 50) - msgs, err := s.Store.ListMessagesBefore(r.Context(), channel, t, 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 _, m := range msgs { arr = append(arr, outMsg{Time: m.Time.UTC().Format(time.RFC3339), Author: m.Author, Body: m.Body, Channel: m.Channel}) } - _ = json.NewEncoder(w).Encode(arr) + if s.AuthToken != "" && !checkAuth(r, s.AuthToken) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("unauthorized")) + return + } + channel := r.URL.Query().Get("channel") + beforeStr := r.URL.Query().Get("before") + if channel == "" || beforeStr == "" { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("missing params")) + return + } + t, err := time.Parse(time.RFC3339, beforeStr) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("bad before")) + return + } + limit := getIntQuery(r, "limit", 50) + msgs, err := s.Store.ListMessagesBefore(r.Context(), channel, t, 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 _, m := range msgs { + arr = append(arr, outMsg{Time: m.Time.UTC().Format(time.RFC3339), Author: m.Author, Body: m.Body, Channel: m.Channel}) + } + _ = json.NewEncoder(w).Encode(arr) } func checkAuth(r *http.Request, token string) bool { @@ -647,7 +735,11 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("bad request")); return } + if err := r.ParseForm(); err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("bad request")) + return + } tok := r.Form.Get("token") if tok == "" || s.AuthToken == "" || tok != s.AuthToken { w.WriteHeader(http.StatusUnauthorized) @@ -657,12 +749,33 @@ func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) { // set cookie for 7 days maxAge := 7 * 24 * 60 * 60 secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") - http.SetCookie(w, &http.Cookie{Name:"auth_token", Value:tok, Path:"/", MaxAge:maxAge, HttpOnly:true, Secure:secure, SameSite:http.SameSiteLaxMode}) + http.SetCookie(w, &http.Cookie{Name: "auth_token", Value: tok, Path: "/", MaxAge: maxAge, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode}) w.Header().Set("Location", "/") w.WriteHeader(http.StatusFound) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { - http.SetCookie(w, &http.Cookie{Name:"auth_token", Value:"", Path:"/", MaxAge:-1}) + http.SetCookie(w, &http.Cookie{Name: "auth_token", Value: "", Path: "/", MaxAge: -1}) http.Redirect(w, r, "/login", http.StatusFound) } + +// Broadcast sends a message to all SSE subscribers for a given channel. +func (s *Server) Broadcast(channel string, m store.Message) { + key := strings.ToLower(strings.TrimSpace(channel)) + if key == "" { + return + } + s.subsMu.RLock() + subs := s.subs[key] + s.subsMu.RUnlock() + for _, sub := range subs { + select { + case sub <- m: + // ok + default: + if s.Logger != nil { + s.Logger.Warn("sse subscriber buffer full, dropping message", "channel", key) + } + } + } +} diff --git a/internal/httpapi/static/app.css b/internal/httpapi/static/app.css new file mode 100644 index 0000000..b4959c3 --- /dev/null +++ b/internal/httpapi/static/app.css @@ -0,0 +1,13 @@ +body { padding: 0; } +header.nav { position: sticky; top: 0; z-index: 10; padding: .6rem 1rem; border-bottom: 1px solid var(--muted-border-color); display: flex; justify-content: space-between; align-items: center; } +header.nav a.brand { text-decoration: none; font-weight: 600; } +main.container { display: grid; grid-template-columns: 220px 1fr; gap: 0; min-height: calc(100vh - 3rem); } +aside.sidebar { border-right: 1px solid var(--muted-border-color); padding: .75rem; overflow-y: auto; } +aside.sidebar a { display:block; padding:.25rem .5rem; border-radius:.25rem; text-decoration:none; } +aside.sidebar a.active { background: var(--muted-color); color: var(--contrast); } +section.chat { padding: .75rem 1rem; display:flex; flex-direction: column; height: calc(100vh - 3.5rem); } +#tail { flex: 1; overflow: auto; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; height: calc(100vh - 4.5rem); } +.ts { opacity: .66; } +.msg { margin-bottom: .25rem; } +footer { text-align: center; font-size: .85rem; padding: .5rem 0; opacity: .7; } +@media (max-width: 900px) { main.container { grid-template-columns: 1fr; } aside.sidebar { display:none; } } diff --git a/internal/httpapi/static/app.js b/internal/httpapi/static/app.js new file mode 100644 index 0000000..9e396db --- /dev/null +++ b/internal/httpapi/static/app.js @@ -0,0 +1,39 @@ +// Shared state +const st = { tailLoading: false, atBottom: true, current: '#', earliest: null, sse: null, channels: [] }; + +function colorFor(nick){ let h=0; for(let i=0;i>>0 } return 'hsl('+(h%360)+',60%,'+(window.matchMedia('(prefers-color-scheme: dark)').matches? '70%':'35%')+')'; } +function escapeHtml(s){ return s.replace(/[&<>"]/g, c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); } +function linkify(t){ return t.replace(/https?:\/\/\S+/g, function(u){ return '' + u + ''; }); } +function lineHTML(m){ const ts = '[' + m.time + ']'; const nick = '' + m.author + ''; const body = escapeHtml(m.body); return ts + ' ' + nick + ': ' + linkify(body); } + +async function api(path, params){ + const url = new URL(path, window.location.origin); + if(params && params.query){ Object.entries(params.query).forEach(([k,v])=>url.searchParams.set(k,v)); } + const res = await fetch(url); + if(!res.ok) throw new Error('HTTP '+res.status); + const ct = res.headers.get('content-type')||''; + if(ct.includes('application/json')) return res.json(); + return res.text(); +} + +function appendBatch(arr){ const el=document.getElementById('tail'); const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.appendChild(frag); if(st.atBottom){ el.scrollTop = el.scrollHeight; } } +function prependBatch(arr){ const el=document.getElementById('tail'); const oldTop=el.firstChild; const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.insertBefore(frag, el.firstChild); if(oldTop){ oldTop.scrollIntoView(); } } + +function processLinks(scope){ const links = scope.querySelectorAll('a[href]:not([data-card])'); links.forEach(a=>{ a.setAttribute('data-card','1'); fetch('/api/linkcard?url='+encodeURIComponent(a.href)).then(r=>r.json()).then(card=>{ if(!card) return; if(card.title||card.description||card.image){ const c=document.createElement('div'); c.className='card'; var html=''; if(card.image){ html += '
'; } html += '
'; if(card.title){ html += '
'+escapeHtml(card.title)+'
'; } if(card.description){ html += '
'+escapeHtml(card.description)+'
'; } html += '
'; c.innerHTML = '
'+html+'
'; a.parentNode.insertBefore(c, a.nextSibling); } }).catch(()=>{}); }); } + +async function loadChannels(){ try{ const data = await api('/api/channels'); st.channels = data; renderChannels(); if(data.length>0){ selectChannel(data[0]); } } catch(e){} } +function renderChannels(){ const list=document.getElementById('chanlist'); if(!list) return; list.innerHTML=''; st.channels.forEach(c=>{ const a=document.createElement('a'); a.href='#'; a.textContent=c; a.onclick=(ev)=>{ev.preventDefault(); selectChannel(c)}; if(c===st.current) a.className='active'; list.appendChild(a); }); } + +async function selectChannel(ch){ if(st.sse){ st.sse.close(); st.sse=null; } st.current=ch; renderChannels(); const el=document.getElementById('tail'); if(!el) return; el.textContent=''; const data = await api('/api/tail',{query:{channel:ch,limit:50}}); appendBatch(data); el.scrollTop = el.scrollHeight; st.atBottom=true; st.earliest = data.length? data[0].time : null; startStream(); initScrollHandlers(); } +function initScrollHandlers(){ const el=document.getElementById('tail'); if(!el) return; el.onscroll=async ()=>{ st.atBottom = (el.scrollTop + el.clientHeight + 8) >= el.scrollHeight; if(el.scrollTop === 0 && st.earliest){ try{ const older = await api('/api/history',{query:{channel:st.current,before:st.earliest,limit:50}}); if(older.length){ const hBefore = el.scrollHeight; prependBatch(older); st.earliest = older[0].time; const hAfter = el.scrollHeight; el.scrollTop = hAfter - hBefore; } } catch(e){} } } } + +function startStream(){ const el=document.getElementById('tail'); if(!el) return; const url=new URL('/api/stream', window.location.origin); url.searchParams.set('channel', st.current); const es=new EventSource(url); st.sse=es; es.onmessage=(ev)=>{ try{ const m=JSON.parse(ev.data); appendBatch([m]); }catch(e){} }; es.onerror=()=>{ es.close(); st.sse=null; setTimeout(startStream, 3000); } } + +async function summarize(){ const ch=document.getElementById('channel'); const win=document.getElementById('window'); const push=document.getElementById('push'); const btn=document.getElementById('btn'); const out=document.getElementById('out'); if(!ch||!win||!btn||!out) return; btn.disabled=true; out.textContent=''; try{ const data = await api('/api/trigger',{query:{channel:ch.value,window:win.value||'6h',push:push && push.checked? '1':'0'}}); if(typeof data === 'string'){ out.textContent = data; } else { out.textContent = (data.summary||''); } } catch(e){ out.textContent = 'error: '+e; } btn.disabled=false; } + +window.addEventListener('DOMContentLoaded', ()=>{ + if(document.getElementById('chanlist')){ loadChannels(); } + if(document.getElementById('channel')){ + fetch('/api/channels').then(r=>r.json()).then(arr=>{ const sel=document.getElementById('channel'); arr.forEach(c=>{ const o=document.createElement('option'); o.value=c; o.textContent=c; sel.appendChild(o); }); }).catch(()=>{}); + } +}); diff --git a/internal/httpapi/templates.go b/internal/httpapi/templates.go new file mode 100644 index 0000000..8f3fbe8 --- /dev/null +++ b/internal/httpapi/templates.go @@ -0,0 +1,47 @@ +package httpapi + +import ( + "embed" + "html/template" + "net/http" + "path" + "strings" + "sync" +) + +//go:embed templates/*.tmpl +var uiFS embed.FS + +//go:embed static/* +var staticFS embed.FS + +var ( + tplOnce sync.Once + tpl *template.Template +) + +func (s *Server) parseTemplatesOnce() { + tplOnce.Do(func() { + // Parse all templates under templates/ + t := template.New("base").Funcs(template.FuncMap{}) + t = template.Must(t.ParseFS(uiFS, "templates/*.tmpl")) + tpl = t + }) +} + +func (s *Server) render(w http.ResponseWriter, name string, data map[string]any) { + s.parseTemplatesOnce() + if data == nil { + data = map[string]any{} + } + data["Version"] = s.Version + data["Commit"] = s.Commit + base := strings.TrimSuffix(path.Base(name), path.Ext(name)) + if base == "dashboard" { + data["Title"] = "sojuboy" + } else { + data["Title"] = strings.Title(base) + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = tpl.ExecuteTemplate(w, name, data) +} diff --git a/internal/httpapi/templates/dashboard.tmpl b/internal/httpapi/templates/dashboard.tmpl new file mode 100644 index 0000000..a0fa421 --- /dev/null +++ b/internal/httpapi/templates/dashboard.tmpl @@ -0,0 +1,12 @@ +{{ define "content" }} +
+ +
+
+
+
+{{ end }} +{{ template "layout.tmpl" . }} + diff --git a/internal/httpapi/templates/layout.tmpl b/internal/httpapi/templates/layout.tmpl new file mode 100644 index 0000000..d9a8b39 --- /dev/null +++ b/internal/httpapi/templates/layout.tmpl @@ -0,0 +1,27 @@ +{{ define "layout.tmpl" }} + + + + + + {{ .Title }} · sojuboy + + + + + + + {{ block "content" . }}{{ end }} +
{{ .Version }} ({{ .Commit }})
+ + +{{ end }} + diff --git a/internal/httpapi/templates/summarizer.tmpl b/internal/httpapi/templates/summarizer.tmpl new file mode 100644 index 0000000..a22cd37 --- /dev/null +++ b/internal/httpapi/templates/summarizer.tmpl @@ -0,0 +1,14 @@ +{{ define "content" }} +
+
+

On-demand summarization

+ + + + +

+  
+
+{{ end }} +{{ template "layout.tmpl" . }} + From 0e0d0ea8242366045bdccad2bb21af8b70165766 Mon Sep 17 00:00:00 2001 From: Thomas Cravey Date: Sat, 16 Aug 2025 21:43:06 -0500 Subject: [PATCH 02/32] =?UTF-8?q?feat(webui):=20add=20on-demand=20?= =?UTF-8?q?=E2=96=B6=20link=20summary=20UI;=20calls=20/api/linksummary=20a?= =?UTF-8?q?nd=20renders=20inline=20under=20link=20card=20(24h=20cache)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/httpapi/static/app.js | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/internal/httpapi/static/app.js b/internal/httpapi/static/app.js index 9e396db..77a1b59 100644 --- a/internal/httpapi/static/app.js +++ b/internal/httpapi/static/app.js @@ -19,7 +19,37 @@ async function api(path, params){ function appendBatch(arr){ const el=document.getElementById('tail'); const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.appendChild(frag); if(st.atBottom){ el.scrollTop = el.scrollHeight; } } function prependBatch(arr){ const el=document.getElementById('tail'); const oldTop=el.firstChild; const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.insertBefore(frag, el.firstChild); if(oldTop){ oldTop.scrollIntoView(); } } -function processLinks(scope){ const links = scope.querySelectorAll('a[href]:not([data-card])'); links.forEach(a=>{ a.setAttribute('data-card','1'); fetch('/api/linkcard?url='+encodeURIComponent(a.href)).then(r=>r.json()).then(card=>{ if(!card) return; if(card.title||card.description||card.image){ const c=document.createElement('div'); c.className='card'; var html=''; if(card.image){ html += '
'; } html += '
'; if(card.title){ html += '
'+escapeHtml(card.title)+'
'; } if(card.description){ html += '
'+escapeHtml(card.description)+'
'; } html += '
'; c.innerHTML = '
'+html+'
'; a.parentNode.insertBefore(c, a.nextSibling); } }).catch(()=>{}); }); } +function processLinks(scope){ const links = scope.querySelectorAll('a[href]:not([data-card])'); links.forEach(a=>{ a.setAttribute('data-card','1'); + // Fetch and render card + fetch('/api/linkcard?url='+encodeURIComponent(a.href)).then(r=>r.json()).then(card=>{ + if(!card) return; + if(card.title||card.description||card.image){ + const c=document.createElement('div'); c.className='card'; var html=''; + if(card.image){ html += '
'; } + html += '
'; + if(card.title){ html += '
'+escapeHtml(card.title)+'
'; } + if(card.description){ html += '
'+escapeHtml(card.description)+'
'; } + html += '
'; + const row = document.createElement('div'); row.style.display='flex'; row.style.alignItems='flex-start'; row.style.gap='.5rem'; row.innerHTML = html; + c.appendChild(row); + // Summary control row + const ctrl = document.createElement('div'); ctrl.style.marginTop='.25rem'; + const btn = document.createElement('button'); btn.type='button'; btn.title='Summarize this link'; btn.textContent='\u25B6'; btn.style.padding='0 .4rem'; btn.style.fontSize='.9rem'; + const spinner = document.createElement('span'); spinner.textContent=''; spinner.style.marginLeft='.5rem'; + const sum = document.createElement('div'); sum.className='link-summary'; sum.style.whiteSpace='pre-wrap'; sum.style.marginTop='.25rem'; + btn.onclick = async ()=>{ + btn.disabled=true; spinner.textContent='…'; sum.textContent=''; + try{ const data = await api('/api/linksummary',{query:{url:a.href}}); sum.textContent = (data && data.summary) ? data.summary : '(no summary)'; } + catch(e){ sum.textContent = 'error: '+e; } + spinner.textContent=''; btn.disabled=false; + }; + ctrl.appendChild(btn); ctrl.appendChild(spinner); + c.appendChild(ctrl); + c.appendChild(sum); + a.parentNode.insertBefore(c, a.nextSibling); + } + }).catch(()=>{}); +}); } async function loadChannels(){ try{ const data = await api('/api/channels'); st.channels = data; renderChannels(); if(data.length>0){ selectChannel(data[0]); } } catch(e){} } function renderChannels(){ const list=document.getElementById('chanlist'); if(!list) return; list.innerHTML=''; st.channels.forEach(c=>{ const a=document.createElement('a'); a.href='#'; a.textContent=c; a.onclick=(ev)=>{ev.preventDefault(); selectChannel(c)}; if(c===st.current) a.className='active'; list.appendChild(a); }); } From b993482b852eccd6684f14ea1d7dd3b2df150c7c Mon Sep 17 00:00:00 2001 From: Thomas Cravey Date: Sun, 17 Aug 2025 13:12:29 -0500 Subject: [PATCH 03/32] fix(webui): render correct page at / (dashboard), use base template execution; footer centering, navbar layout via CSS --- internal/httpapi/templates.go | 2 +- internal/httpapi/templates/layout.tmpl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/httpapi/templates.go b/internal/httpapi/templates.go index 8f3fbe8..c28f46e 100644 --- a/internal/httpapi/templates.go +++ b/internal/httpapi/templates.go @@ -43,5 +43,5 @@ func (s *Server) render(w http.ResponseWriter, name string, data map[string]any) data["Title"] = strings.Title(base) } w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = tpl.ExecuteTemplate(w, name, data) + _ = tpl.ExecuteTemplate(w, base, data) } diff --git a/internal/httpapi/templates/layout.tmpl b/internal/httpapi/templates/layout.tmpl index d9a8b39..ce2f8bd 100644 --- a/internal/httpapi/templates/layout.tmpl +++ b/internal/httpapi/templates/layout.tmpl @@ -19,7 +19,7 @@ - {{ block "content" . }}{{ end }} + {{ template .Content . }}
{{ .Version }} ({{ .Commit }})
From 3ed38031bed7b2b9ec36b90b2b099a761faee61a Mon Sep 17 00:00:00 2001 From: Thomas Cravey Date: Sun, 17 Aug 2025 13:20:17 -0500 Subject: [PATCH 04/32] fix(webui): blank page regression; execute named templates (dashboard.tmpl, summarizer.tmpl) in render() --- internal/httpapi/templates.go | 2 +- internal/httpapi/templates/layout.tmpl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/httpapi/templates.go b/internal/httpapi/templates.go index c28f46e..8f3fbe8 100644 --- a/internal/httpapi/templates.go +++ b/internal/httpapi/templates.go @@ -43,5 +43,5 @@ func (s *Server) render(w http.ResponseWriter, name string, data map[string]any) data["Title"] = strings.Title(base) } w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = tpl.ExecuteTemplate(w, base, data) + _ = tpl.ExecuteTemplate(w, name, data) } diff --git a/internal/httpapi/templates/layout.tmpl b/internal/httpapi/templates/layout.tmpl index ce2f8bd..d9a8b39 100644 --- a/internal/httpapi/templates/layout.tmpl +++ b/internal/httpapi/templates/layout.tmpl @@ -19,7 +19,7 @@ - {{ template .Content . }} + {{ block "content" . }}{{ end }}
{{ .Version }} ({{ .Commit }})
From ab114908079d3c7519560386c45615400635b48e Mon Sep 17 00:00:00 2001 From: Thomas Cravey Date: Sun, 17 Aug 2025 13:31:18 -0500 Subject: [PATCH 05/32] fix(webui): correct layout rendering; select content template by name and include via layout; fix navbar/footer appearance --- internal/httpapi/templates.go | 4 +++- internal/httpapi/templates/dashboard.tmpl | 4 ++-- internal/httpapi/templates/layout.tmpl | 6 +++++- internal/httpapi/templates/summarizer.tmpl | 4 ++-- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/internal/httpapi/templates.go b/internal/httpapi/templates.go index 8f3fbe8..39d1b53 100644 --- a/internal/httpapi/templates.go +++ b/internal/httpapi/templates.go @@ -42,6 +42,8 @@ func (s *Server) render(w http.ResponseWriter, name string, data map[string]any) } else { data["Title"] = strings.Title(base) } + // Tell layout which content template to include + data["Content"] = base w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = tpl.ExecuteTemplate(w, name, data) + _ = tpl.ExecuteTemplate(w, "layout.tmpl", data) } diff --git a/internal/httpapi/templates/dashboard.tmpl b/internal/httpapi/templates/dashboard.tmpl index a0fa421..b7f5111 100644 --- a/internal/httpapi/templates/dashboard.tmpl +++ b/internal/httpapi/templates/dashboard.tmpl @@ -1,4 +1,4 @@ -{{ define "content" }} +{{ define "dashboard" }}
{{ end }} -{{ template "layout.tmpl" . }} +{{/* layout executes us via .Content */}} diff --git a/internal/httpapi/templates/layout.tmpl b/internal/httpapi/templates/layout.tmpl index d9a8b39..3bfc76e 100644 --- a/internal/httpapi/templates/layout.tmpl +++ b/internal/httpapi/templates/layout.tmpl @@ -19,7 +19,11 @@ - {{ block "content" . }}{{ end }} + {{ if eq .Content "dashboard" }} + {{ template "dashboard" . }} + {{ else if eq .Content "summarizer" }} + {{ template "summarizer" . }} + {{ end }}
{{ .Version }} ({{ .Commit }})
diff --git a/internal/httpapi/templates/summarizer.tmpl b/internal/httpapi/templates/summarizer.tmpl index a22cd37..c7b9e5a 100644 --- a/internal/httpapi/templates/summarizer.tmpl +++ b/internal/httpapi/templates/summarizer.tmpl @@ -1,4 +1,4 @@ -{{ define "content" }} +{{ define "summarizer" }}

On-demand summarization

@@ -10,5 +10,5 @@
{{ end }} -{{ template "layout.tmpl" . }} +{{/* layout executes us via .Content */}} From fb92930e7a04911e55800143fa21332b73e41cd1 Mon Sep 17 00:00:00 2001 From: Thomas Cravey Date: Sun, 17 Aug 2025 13:36:37 -0500 Subject: [PATCH 06/32] fix(webui): serve embedded static from /static/* correctly (fs.Sub(staticFS, "static")) --- internal/httpapi/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 2cfa5a4..c31419a 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -64,7 +64,7 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("/", s.handleUIDash) mux.HandleFunc("/summarizer", s.handleUISummarizer) // Serve embedded static files under /static/ - if sub, err := fs.Sub(staticFS, "."); err == nil { + if sub, err := fs.Sub(staticFS, "static"); err == nil { mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) } mux.HandleFunc("/login", s.handleLogin) From f7d97db56934715c77717949215b69f66d3b6c33 Mon Sep 17 00:00:00 2001 From: Thomas Cravey Date: Sun, 17 Aug 2025 13:50:38 -0500 Subject: [PATCH 07/32] fix(webui): summarizer wrapping and width; scope dashboard grid to .dash; ensure #out wraps and fills; adjust dashboard main class --- internal/httpapi/static/app.css | 15 +++++++++------ internal/httpapi/templates/dashboard.tmpl | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/internal/httpapi/static/app.css b/internal/httpapi/static/app.css index b4959c3..e0c8ea7 100644 --- a/internal/httpapi/static/app.css +++ b/internal/httpapi/static/app.css @@ -1,13 +1,16 @@ body { padding: 0; } header.nav { position: sticky; top: 0; z-index: 10; padding: .6rem 1rem; border-bottom: 1px solid var(--muted-border-color); display: flex; justify-content: space-between; align-items: center; } header.nav a.brand { text-decoration: none; font-weight: 600; } -main.container { display: grid; grid-template-columns: 220px 1fr; gap: 0; min-height: calc(100vh - 3rem); } -aside.sidebar { border-right: 1px solid var(--muted-border-color); padding: .75rem; overflow-y: auto; } -aside.sidebar a { display:block; padding:.25rem .5rem; border-radius:.25rem; text-decoration:none; } -aside.sidebar a.active { background: var(--muted-color); color: var(--contrast); } -section.chat { padding: .75rem 1rem; display:flex; flex-direction: column; height: calc(100vh - 3.5rem); } +/* Dashboard-only grid layout */ +.dash { display: grid; grid-template-columns: 220px 1fr; gap: 0; min-height: calc(100vh - 3rem); } +.dash aside.sidebar { border-right: 1px solid var(--muted-border-color); padding: .75rem; overflow-y: auto; } +.dash aside.sidebar a { display:block; padding:.25rem .5rem; border-radius:.25rem; text-decoration:none; } +.dash aside.sidebar a.active { background: var(--muted-color); color: var(--contrast); } +.dash section.chat { padding: .75rem 1rem; display:flex; flex-direction: column; height: calc(100vh - 3.5rem); } #tail { flex: 1; overflow: auto; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; height: calc(100vh - 4.5rem); } .ts { opacity: .66; } .msg { margin-bottom: .25rem; } footer { text-align: center; font-size: .85rem; padding: .5rem 0; opacity: .7; } -@media (max-width: 900px) { main.container { grid-template-columns: 1fr; } aside.sidebar { display:none; } } +@media (max-width: 900px) { .dash { grid-template-columns: 1fr; } .dash aside.sidebar { display:none; } } +/* Summarizer output wrapping */ +#out { white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; } diff --git a/internal/httpapi/templates/dashboard.tmpl b/internal/httpapi/templates/dashboard.tmpl index b7f5111..c6ea560 100644 --- a/internal/httpapi/templates/dashboard.tmpl +++ b/internal/httpapi/templates/dashboard.tmpl @@ -1,5 +1,5 @@ {{ define "dashboard" }} -
+
From 0dd9b072e8e0be7df73beabcec897ac98d11f95e Mon Sep 17 00:00:00 2001 From: Thomas Cravey Date: Sun, 17 Aug 2025 14:06:34 -0500 Subject: [PATCH 08/32] feat(webui): sticky opaque navbar/footer; scope grid to dashboard; single-scroll main with autoscroll; summarizer wraps --- internal/httpapi/static/app.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/httpapi/static/app.css b/internal/httpapi/static/app.css index e0c8ea7..0f9977f 100644 --- a/internal/httpapi/static/app.css +++ b/internal/httpapi/static/app.css @@ -1,5 +1,5 @@ body { padding: 0; } -header.nav { position: sticky; top: 0; z-index: 10; padding: .6rem 1rem; border-bottom: 1px solid var(--muted-border-color); display: flex; justify-content: space-between; align-items: center; } +header.nav { position: sticky; top: 0; z-index: 10; padding: .6rem 1rem; border-bottom: 1px solid var(--muted-border-color); display: flex; justify-content: space-between; align-items: center; background: var(--background-color); } header.nav a.brand { text-decoration: none; font-weight: 600; } /* Dashboard-only grid layout */ .dash { display: grid; grid-template-columns: 220px 1fr; gap: 0; min-height: calc(100vh - 3rem); } From d3ab367f6c0d8e7d67583a0ca5a718b29b323fe2 Mon Sep 17 00:00:00 2001 From: Thomas Cravey Date: Sun, 17 Aug 2025 14:12:33 -0500 Subject: [PATCH 09/32] fix(webui): eliminate double scroll; lock footer; opaque header/footer; use CSS grid body layout and height:100% for content --- internal/httpapi/static/app.css | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/httpapi/static/app.css b/internal/httpapi/static/app.css index 0f9977f..775654f 100644 --- a/internal/httpapi/static/app.css +++ b/internal/httpapi/static/app.css @@ -1,16 +1,17 @@ -body { padding: 0; } +html, body { height: 100%; } +body { padding: 0; display: grid; grid-template-rows: auto 1fr auto; min-height: 100vh; overflow: hidden; } header.nav { position: sticky; top: 0; z-index: 10; padding: .6rem 1rem; border-bottom: 1px solid var(--muted-border-color); display: flex; justify-content: space-between; align-items: center; background: var(--background-color); } header.nav a.brand { text-decoration: none; font-weight: 600; } /* Dashboard-only grid layout */ -.dash { display: grid; grid-template-columns: 220px 1fr; gap: 0; min-height: calc(100vh - 3rem); } +.dash { display: grid; grid-template-columns: 220px 1fr; gap: 0; height: 100%; min-height: 0; } .dash aside.sidebar { border-right: 1px solid var(--muted-border-color); padding: .75rem; overflow-y: auto; } .dash aside.sidebar a { display:block; padding:.25rem .5rem; border-radius:.25rem; text-decoration:none; } .dash aside.sidebar a.active { background: var(--muted-color); color: var(--contrast); } -.dash section.chat { padding: .75rem 1rem; display:flex; flex-direction: column; height: calc(100vh - 3.5rem); } -#tail { flex: 1; overflow: auto; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; height: calc(100vh - 4.5rem); } +.dash section.chat { padding: .75rem 1rem; display:flex; flex-direction: column; height: 100%; min-height: 0; } +#tail { flex: 1; overflow: auto; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; height: 100%; } .ts { opacity: .66; } .msg { margin-bottom: .25rem; } -footer { text-align: center; font-size: .85rem; padding: .5rem 0; opacity: .7; } +footer { text-align: center; font-size: .85rem; padding: .5rem 0; opacity: .7; background: var(--background-color); } @media (max-width: 900px) { .dash { grid-template-columns: 1fr; } .dash aside.sidebar { display:none; } } /* Summarizer output wrapping */ #out { white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; } From e4f58281fb18aa28c8e988a6dcaf92dbaf803f4d Mon Sep 17 00:00:00 2001 From: Thomas Cravey Date: Sun, 17 Aug 2025 14:18:16 -0500 Subject: [PATCH 10/32] feat(webui): switch to page scrollbar; adjust autopin to bottom and infinite scroll using window scroll; keep at-bottom behavior --- internal/httpapi/static/app.css | 4 ++-- internal/httpapi/static/app.js | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/httpapi/static/app.css b/internal/httpapi/static/app.css index 775654f..d1daaed 100644 --- a/internal/httpapi/static/app.css +++ b/internal/httpapi/static/app.css @@ -1,5 +1,5 @@ html, body { height: 100%; } -body { padding: 0; display: grid; grid-template-rows: auto 1fr auto; min-height: 100vh; overflow: hidden; } +body { padding: 0; display: grid; grid-template-rows: auto 1fr auto; min-height: 100vh; } header.nav { position: sticky; top: 0; z-index: 10; padding: .6rem 1rem; border-bottom: 1px solid var(--muted-border-color); display: flex; justify-content: space-between; align-items: center; background: var(--background-color); } header.nav a.brand { text-decoration: none; font-weight: 600; } /* Dashboard-only grid layout */ @@ -8,7 +8,7 @@ header.nav a.brand { text-decoration: none; font-weight: 600; } .dash aside.sidebar a { display:block; padding:.25rem .5rem; border-radius:.25rem; text-decoration:none; } .dash aside.sidebar a.active { background: var(--muted-color); color: var(--contrast); } .dash section.chat { padding: .75rem 1rem; display:flex; flex-direction: column; height: 100%; min-height: 0; } -#tail { flex: 1; overflow: auto; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; height: 100%; } +#tail { flex: 1; overflow: visible; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; height: auto; } .ts { opacity: .66; } .msg { margin-bottom: .25rem; } footer { text-align: center; font-size: .85rem; padding: .5rem 0; opacity: .7; background: var(--background-color); } diff --git a/internal/httpapi/static/app.js b/internal/httpapi/static/app.js index 77a1b59..490f020 100644 --- a/internal/httpapi/static/app.js +++ b/internal/httpapi/static/app.js @@ -16,8 +16,8 @@ async function api(path, params){ return res.text(); } -function appendBatch(arr){ const el=document.getElementById('tail'); const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.appendChild(frag); if(st.atBottom){ el.scrollTop = el.scrollHeight; } } -function prependBatch(arr){ const el=document.getElementById('tail'); const oldTop=el.firstChild; const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.insertBefore(frag, el.firstChild); if(oldTop){ oldTop.scrollIntoView(); } } +function appendBatch(arr){ const el=document.getElementById('tail'); const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.appendChild(frag); if(st.atBottom){ window.scrollTo({top: document.body.scrollHeight, behavior: 'instant'}); } } +function prependBatch(arr){ const el=document.getElementById('tail'); const oldTop=el.firstChild; const prevPageOffset = window.pageYOffset; const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.insertBefore(frag, el.firstChild); if(oldTop){ oldTop.scrollIntoView(); window.scrollTo({top: oldTop.getBoundingClientRect().top + window.pageYOffset - 80, behavior: 'instant'}); } else { window.scrollTo({top: prevPageOffset, behavior: 'instant'}); } } function processLinks(scope){ const links = scope.querySelectorAll('a[href]:not([data-card])'); links.forEach(a=>{ a.setAttribute('data-card','1'); // Fetch and render card @@ -55,7 +55,7 @@ async function loadChannels(){ try{ const data = await api('/api/channels'); st. function renderChannels(){ const list=document.getElementById('chanlist'); if(!list) return; list.innerHTML=''; st.channels.forEach(c=>{ const a=document.createElement('a'); a.href='#'; a.textContent=c; a.onclick=(ev)=>{ev.preventDefault(); selectChannel(c)}; if(c===st.current) a.className='active'; list.appendChild(a); }); } async function selectChannel(ch){ if(st.sse){ st.sse.close(); st.sse=null; } st.current=ch; renderChannels(); const el=document.getElementById('tail'); if(!el) return; el.textContent=''; const data = await api('/api/tail',{query:{channel:ch,limit:50}}); appendBatch(data); el.scrollTop = el.scrollHeight; st.atBottom=true; st.earliest = data.length? data[0].time : null; startStream(); initScrollHandlers(); } -function initScrollHandlers(){ const el=document.getElementById('tail'); if(!el) return; el.onscroll=async ()=>{ st.atBottom = (el.scrollTop + el.clientHeight + 8) >= el.scrollHeight; if(el.scrollTop === 0 && st.earliest){ try{ const older = await api('/api/history',{query:{channel:st.current,before:st.earliest,limit:50}}); if(older.length){ const hBefore = el.scrollHeight; prependBatch(older); st.earliest = older[0].time; const hAfter = el.scrollHeight; el.scrollTop = hAfter - hBefore; } } catch(e){} } } } +function initScrollHandlers(){ const el=document.getElementById('tail'); if(!el) return; const onScroll = async ()=>{ const nearBottom = (window.innerHeight + window.pageYOffset + 16) >= document.body.offsetHeight; st.atBottom = nearBottom; if(window.pageYOffset === 0 && st.earliest){ try{ const older = await api('/api/history',{query:{channel:st.current,before:st.earliest,limit:50}}); if(older.length){ prependBatch(older); st.earliest = older[0].time; } } catch(e){} } }; window.removeEventListener('scroll', st._scrollHandler || (()=>{})); st._scrollHandler = onScroll; window.addEventListener('scroll', onScroll, {passive:true}); } function startStream(){ const el=document.getElementById('tail'); if(!el) return; const url=new URL('/api/stream', window.location.origin); url.searchParams.set('channel', st.current); const es=new EventSource(url); st.sse=es; es.onmessage=(ev)=>{ try{ const m=JSON.parse(ev.data); appendBatch([m]); }catch(e){} }; es.onerror=()=>{ es.close(); st.sse=null; setTimeout(startStream, 3000); } } From 21a49c18a0d15ad8f6d5b00690e8e45fbc8b8fcc Mon Sep 17 00:00:00 2001 From: Thomas Cravey Date: Sun, 17 Aug 2025 14:25:04 -0500 Subject: [PATCH 11/32] fix(webui): lock footer/header visibly using sticky + high z-index and solid background; preserve scroll position on history prepend; robust at-bottom detection --- internal/httpapi/static/app.css | 4 ++-- internal/httpapi/static/app.js | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/httpapi/static/app.css b/internal/httpapi/static/app.css index d1daaed..c289444 100644 --- a/internal/httpapi/static/app.css +++ b/internal/httpapi/static/app.css @@ -1,6 +1,6 @@ html, body { height: 100%; } body { padding: 0; display: grid; grid-template-rows: auto 1fr auto; min-height: 100vh; } -header.nav { position: sticky; top: 0; z-index: 10; padding: .6rem 1rem; border-bottom: 1px solid var(--muted-border-color); display: flex; justify-content: space-between; align-items: center; background: var(--background-color); } +header.nav { position: sticky; top: 0; z-index: 1000; padding: .6rem 1rem; border-bottom: 1px solid var(--muted-border-color); display: flex; justify-content: space-between; align-items: center; background-color: var(--pico-background-color, #fff); } header.nav a.brand { text-decoration: none; font-weight: 600; } /* Dashboard-only grid layout */ .dash { display: grid; grid-template-columns: 220px 1fr; gap: 0; height: 100%; min-height: 0; } @@ -11,7 +11,7 @@ header.nav a.brand { text-decoration: none; font-weight: 600; } #tail { flex: 1; overflow: visible; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; height: auto; } .ts { opacity: .66; } .msg { margin-bottom: .25rem; } -footer { text-align: center; font-size: .85rem; padding: .5rem 0; opacity: .7; background: var(--background-color); } +footer { position: sticky; bottom: 0; z-index: 1000; text-align: center; font-size: .85rem; padding: .5rem 0; opacity: .7; background-color: var(--pico-background-color, #fff); border-top: 1px solid var(--muted-border-color); } @media (max-width: 900px) { .dash { grid-template-columns: 1fr; } .dash aside.sidebar { display:none; } } /* Summarizer output wrapping */ #out { white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; } diff --git a/internal/httpapi/static/app.js b/internal/httpapi/static/app.js index 490f020..5ec1f5d 100644 --- a/internal/httpapi/static/app.js +++ b/internal/httpapi/static/app.js @@ -17,7 +17,8 @@ async function api(path, params){ } function appendBatch(arr){ const el=document.getElementById('tail'); const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.appendChild(frag); if(st.atBottom){ window.scrollTo({top: document.body.scrollHeight, behavior: 'instant'}); } } -function prependBatch(arr){ const el=document.getElementById('tail'); const oldTop=el.firstChild; const prevPageOffset = window.pageYOffset; const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.insertBefore(frag, el.firstChild); if(oldTop){ oldTop.scrollIntoView(); window.scrollTo({top: oldTop.getBoundingClientRect().top + window.pageYOffset - 80, behavior: 'instant'}); } else { window.scrollTo({top: prevPageOffset, behavior: 'instant'}); } } +function prependBatch(arr){ const el=document.getElementById('tail'); const oldTop=el.firstChild; const beforeTop = oldTop ? oldTop.getBoundingClientRect().top : 0; const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.insertBefore(frag, el.firstChild); // preserve visual position + if(oldTop){ const afterTop = oldTop.getBoundingClientRect().top; const delta = afterTop - beforeTop; window.scrollBy({top: delta, left: 0, behavior: 'instant'}); } } function processLinks(scope){ const links = scope.querySelectorAll('a[href]:not([data-card])'); links.forEach(a=>{ a.setAttribute('data-card','1'); // Fetch and render card @@ -55,7 +56,7 @@ async function loadChannels(){ try{ const data = await api('/api/channels'); st. function renderChannels(){ const list=document.getElementById('chanlist'); if(!list) return; list.innerHTML=''; st.channels.forEach(c=>{ const a=document.createElement('a'); a.href='#'; a.textContent=c; a.onclick=(ev)=>{ev.preventDefault(); selectChannel(c)}; if(c===st.current) a.className='active'; list.appendChild(a); }); } async function selectChannel(ch){ if(st.sse){ st.sse.close(); st.sse=null; } st.current=ch; renderChannels(); const el=document.getElementById('tail'); if(!el) return; el.textContent=''; const data = await api('/api/tail',{query:{channel:ch,limit:50}}); appendBatch(data); el.scrollTop = el.scrollHeight; st.atBottom=true; st.earliest = data.length? data[0].time : null; startStream(); initScrollHandlers(); } -function initScrollHandlers(){ const el=document.getElementById('tail'); if(!el) return; const onScroll = async ()=>{ const nearBottom = (window.innerHeight + window.pageYOffset + 16) >= document.body.offsetHeight; st.atBottom = nearBottom; if(window.pageYOffset === 0 && st.earliest){ try{ const older = await api('/api/history',{query:{channel:st.current,before:st.earliest,limit:50}}); if(older.length){ prependBatch(older); st.earliest = older[0].time; } } catch(e){} } }; window.removeEventListener('scroll', st._scrollHandler || (()=>{})); st._scrollHandler = onScroll; window.addEventListener('scroll', onScroll, {passive:true}); } +function initScrollHandlers(){ const el=document.getElementById('tail'); if(!el) return; const onScroll = async ()=>{ const nearBottom = (window.innerHeight + window.pageYOffset + 16) >= document.documentElement.scrollHeight; st.atBottom = nearBottom; const nearTop = window.pageYOffset <= 4; if(nearTop && st.earliest){ try{ const older = await api('/api/history',{query:{channel:st.current,before:st.earliest,limit:50}}); if(older.length){ prependBatch(older); st.earliest = older[0].time; } } catch(e){} } }; window.removeEventListener('scroll', st._scrollHandler || (()=>{})); st._scrollHandler = onScroll; window.addEventListener('scroll', onScroll, {passive:true}); } function startStream(){ const el=document.getElementById('tail'); if(!el) return; const url=new URL('/api/stream', window.location.origin); url.searchParams.set('channel', st.current); const es=new EventSource(url); st.sse=es; es.onmessage=(ev)=>{ try{ const m=JSON.parse(ev.data); appendBatch([m]); }catch(e){} }; es.onerror=()=>{ es.close(); st.sse=null; setTimeout(startStream, 3000); } } From c125d05f267463d18bd350e2a518e00f06a6f131 Mon Sep 17 00:00:00 2001 From: Thomas Cravey Date: Sun, 17 Aug 2025 14:37:22 -0500 Subject: [PATCH 12/32] fix(webui): fully fixed header/footer (position: fixed) with page padding; snap to bottom on initial tail load; remove faded footer --- internal/httpapi/static/app.css | 12 ++++++------ internal/httpapi/static/app.js | 4 +++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/internal/httpapi/static/app.css b/internal/httpapi/static/app.css index c289444..5a653dc 100644 --- a/internal/httpapi/static/app.css +++ b/internal/httpapi/static/app.css @@ -1,17 +1,17 @@ html, body { height: 100%; } -body { padding: 0; display: grid; grid-template-rows: auto 1fr auto; min-height: 100vh; } -header.nav { position: sticky; top: 0; z-index: 1000; padding: .6rem 1rem; border-bottom: 1px solid var(--muted-border-color); display: flex; justify-content: space-between; align-items: center; background-color: var(--pico-background-color, #fff); } +body { padding: 56px 0 44px 0; min-height: 100vh; } +header.nav { position: fixed; top: 0; left: 0; right: 0; z-index: 1000; padding: .6rem 1rem; border-bottom: 1px solid var(--muted-border-color); display: flex; justify-content: space-between; align-items: center; background-color: var(--pico-background-color, #fff); } header.nav a.brand { text-decoration: none; font-weight: 600; } /* Dashboard-only grid layout */ -.dash { display: grid; grid-template-columns: 220px 1fr; gap: 0; height: 100%; min-height: 0; } +.dash { display: grid; grid-template-columns: 220px 1fr; gap: 0; } .dash aside.sidebar { border-right: 1px solid var(--muted-border-color); padding: .75rem; overflow-y: auto; } .dash aside.sidebar a { display:block; padding:.25rem .5rem; border-radius:.25rem; text-decoration:none; } .dash aside.sidebar a.active { background: var(--muted-color); color: var(--contrast); } -.dash section.chat { padding: .75rem 1rem; display:flex; flex-direction: column; height: 100%; min-height: 0; } -#tail { flex: 1; overflow: visible; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; height: auto; } +.dash section.chat { padding: .75rem 1rem; display:flex; flex-direction: column; } +#tail { flex: 1; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } .ts { opacity: .66; } .msg { margin-bottom: .25rem; } -footer { position: sticky; bottom: 0; z-index: 1000; text-align: center; font-size: .85rem; padding: .5rem 0; opacity: .7; background-color: var(--pico-background-color, #fff); border-top: 1px solid var(--muted-border-color); } +footer { position: fixed; left: 0; right: 0; bottom: 0; z-index: 1000; text-align: center; font-size: .85rem; padding: .5rem 0; background-color: var(--pico-background-color, #fff); border-top: 1px solid var(--muted-border-color); } @media (max-width: 900px) { .dash { grid-template-columns: 1fr; } .dash aside.sidebar { display:none; } } /* Summarizer output wrapping */ #out { white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; } diff --git a/internal/httpapi/static/app.js b/internal/httpapi/static/app.js index 5ec1f5d..30979ba 100644 --- a/internal/httpapi/static/app.js +++ b/internal/httpapi/static/app.js @@ -55,7 +55,9 @@ function processLinks(scope){ const links = scope.querySelectorAll('a[href]:not( async function loadChannels(){ try{ const data = await api('/api/channels'); st.channels = data; renderChannels(); if(data.length>0){ selectChannel(data[0]); } } catch(e){} } function renderChannels(){ const list=document.getElementById('chanlist'); if(!list) return; list.innerHTML=''; st.channels.forEach(c=>{ const a=document.createElement('a'); a.href='#'; a.textContent=c; a.onclick=(ev)=>{ev.preventDefault(); selectChannel(c)}; if(c===st.current) a.className='active'; list.appendChild(a); }); } -async function selectChannel(ch){ if(st.sse){ st.sse.close(); st.sse=null; } st.current=ch; renderChannels(); const el=document.getElementById('tail'); if(!el) return; el.textContent=''; const data = await api('/api/tail',{query:{channel:ch,limit:50}}); appendBatch(data); el.scrollTop = el.scrollHeight; st.atBottom=true; st.earliest = data.length? data[0].time : null; startStream(); initScrollHandlers(); } +async function selectChannel(ch){ if(st.sse){ st.sse.close(); st.sse=null; } st.current=ch; renderChannels(); const el=document.getElementById('tail'); if(!el) return; el.textContent=''; const data = await api('/api/tail',{query:{channel:ch,limit:50}}); appendBatch(data); // snap to bottom after initial load + setTimeout(()=>{ window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'instant' }); st.atBottom=true; }, 0); + st.earliest = data.length? data[0].time : null; startStream(); initScrollHandlers(); } function initScrollHandlers(){ const el=document.getElementById('tail'); if(!el) return; const onScroll = async ()=>{ const nearBottom = (window.innerHeight + window.pageYOffset + 16) >= document.documentElement.scrollHeight; st.atBottom = nearBottom; const nearTop = window.pageYOffset <= 4; if(nearTop && st.earliest){ try{ const older = await api('/api/history',{query:{channel:st.current,before:st.earliest,limit:50}}); if(older.length){ prependBatch(older); st.earliest = older[0].time; } } catch(e){} } }; window.removeEventListener('scroll', st._scrollHandler || (()=>{})); st._scrollHandler = onScroll; window.addEventListener('scroll', onScroll, {passive:true}); } function startStream(){ const el=document.getElementById('tail'); if(!el) return; const url=new URL('/api/stream', window.location.origin); url.searchParams.set('channel', st.current); const es=new EventSource(url); st.sse=es; es.onmessage=(ev)=>{ try{ const m=JSON.parse(ev.data); appendBatch([m]); }catch(e){} }; es.onerror=()=>{ es.close(); st.sse=null; setTimeout(startStream, 3000); } } From 645ea9f2b92aaecdfdb22a89a1d04b8876955af4 Mon Sep 17 00:00:00 2001 From: Thomas Cravey Date: Sun, 17 Aug 2025 14:42:32 -0500 Subject: [PATCH 13/32] fix(webui): correct initial snap-to-bottom using rAF; add top/bottom margins under fixed bars; ensure page shows last lines on load --- internal/httpapi/static/app.css | 3 ++- internal/httpapi/static/app.js | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/httpapi/static/app.css b/internal/httpapi/static/app.css index 5a653dc..6cd2359 100644 --- a/internal/httpapi/static/app.css +++ b/internal/httpapi/static/app.css @@ -1,6 +1,7 @@ html, body { height: 100%; } -body { padding: 56px 0 44px 0; min-height: 100vh; } +body { padding: 0; min-height: 100vh; } header.nav { position: fixed; top: 0; left: 0; right: 0; z-index: 1000; padding: .6rem 1rem; border-bottom: 1px solid var(--muted-border-color); display: flex; justify-content: space-between; align-items: center; background-color: var(--pico-background-color, #fff); } +main, article, .dash { margin-top: 56px; margin-bottom: 44px; } header.nav a.brand { text-decoration: none; font-weight: 600; } /* Dashboard-only grid layout */ .dash { display: grid; grid-template-columns: 220px 1fr; gap: 0; } diff --git a/internal/httpapi/static/app.js b/internal/httpapi/static/app.js index 30979ba..e461d1d 100644 --- a/internal/httpapi/static/app.js +++ b/internal/httpapi/static/app.js @@ -16,7 +16,7 @@ async function api(path, params){ return res.text(); } -function appendBatch(arr){ const el=document.getElementById('tail'); const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.appendChild(frag); if(st.atBottom){ window.scrollTo({top: document.body.scrollHeight, behavior: 'instant'}); } } +function appendBatch(arr){ const el=document.getElementById('tail'); const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.appendChild(frag); if(st.atBottom){ requestAnimationFrame(()=>{ window.scrollTo({top: document.documentElement.scrollHeight, behavior: 'instant'}); }); } } function prependBatch(arr){ const el=document.getElementById('tail'); const oldTop=el.firstChild; const beforeTop = oldTop ? oldTop.getBoundingClientRect().top : 0; const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.insertBefore(frag, el.firstChild); // preserve visual position if(oldTop){ const afterTop = oldTop.getBoundingClientRect().top; const delta = afterTop - beforeTop; window.scrollBy({top: delta, left: 0, behavior: 'instant'}); } } @@ -56,7 +56,7 @@ async function loadChannels(){ try{ const data = await api('/api/channels'); st. function renderChannels(){ const list=document.getElementById('chanlist'); if(!list) return; list.innerHTML=''; st.channels.forEach(c=>{ const a=document.createElement('a'); a.href='#'; a.textContent=c; a.onclick=(ev)=>{ev.preventDefault(); selectChannel(c)}; if(c===st.current) a.className='active'; list.appendChild(a); }); } async function selectChannel(ch){ if(st.sse){ st.sse.close(); st.sse=null; } st.current=ch; renderChannels(); const el=document.getElementById('tail'); if(!el) return; el.textContent=''; const data = await api('/api/tail',{query:{channel:ch,limit:50}}); appendBatch(data); // snap to bottom after initial load - setTimeout(()=>{ window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'instant' }); st.atBottom=true; }, 0); + requestAnimationFrame(()=>{ window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'instant' }); st.atBottom=true; }); st.earliest = data.length? data[0].time : null; startStream(); initScrollHandlers(); } function initScrollHandlers(){ const el=document.getElementById('tail'); if(!el) return; const onScroll = async ()=>{ const nearBottom = (window.innerHeight + window.pageYOffset + 16) >= document.documentElement.scrollHeight; st.atBottom = nearBottom; const nearTop = window.pageYOffset <= 4; if(nearTop && st.earliest){ try{ const older = await api('/api/history',{query:{channel:st.current,before:st.earliest,limit:50}}); if(older.length){ prependBatch(older); st.earliest = older[0].time; } } catch(e){} } }; window.removeEventListener('scroll', st._scrollHandler || (()=>{})); st._scrollHandler = onScroll; window.addEventListener('scroll', onScroll, {passive:true}); } From 0e3ccac6a92527219fd508b0760e3d90e97e8015 Mon Sep 17 00:00:00 2001 From: Thomas Cravey Date: Sun, 17 Aug 2025 14:53:57 -0500 Subject: [PATCH 14/32] fix(webui): compute dynamic header/footer heights; use CSS vars for margins; re-scroll after images/cards via rAF --- internal/httpapi/static/app.css | 2 +- internal/httpapi/static/app.js | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/httpapi/static/app.css b/internal/httpapi/static/app.css index 6cd2359..f10eb61 100644 --- a/internal/httpapi/static/app.css +++ b/internal/httpapi/static/app.css @@ -1,7 +1,7 @@ html, body { height: 100%; } body { padding: 0; min-height: 100vh; } header.nav { position: fixed; top: 0; left: 0; right: 0; z-index: 1000; padding: .6rem 1rem; border-bottom: 1px solid var(--muted-border-color); display: flex; justify-content: space-between; align-items: center; background-color: var(--pico-background-color, #fff); } -main, article, .dash { margin-top: 56px; margin-bottom: 44px; } +main, article, .dash { margin-top: var(--headerH, 56px); margin-bottom: var(--footerH, 44px); } header.nav a.brand { text-decoration: none; font-weight: 600; } /* Dashboard-only grid layout */ .dash { display: grid; grid-template-columns: 220px 1fr; gap: 0; } diff --git a/internal/httpapi/static/app.js b/internal/httpapi/static/app.js index e461d1d..b9735d3 100644 --- a/internal/httpapi/static/app.js +++ b/internal/httpapi/static/app.js @@ -65,6 +65,11 @@ function startStream(){ const el=document.getElementById('tail'); if(!el) return async function summarize(){ const ch=document.getElementById('channel'); const win=document.getElementById('window'); const push=document.getElementById('push'); const btn=document.getElementById('btn'); const out=document.getElementById('out'); if(!ch||!win||!btn||!out) return; btn.disabled=true; out.textContent=''; try{ const data = await api('/api/trigger',{query:{channel:ch.value,window:win.value||'6h',push:push && push.checked? '1':'0'}}); if(typeof data === 'string'){ out.textContent = data; } else { out.textContent = (data.summary||''); } } catch(e){ out.textContent = 'error: '+e; } btn.disabled=false; } window.addEventListener('DOMContentLoaded', ()=>{ + // measure header/footer and set margins so last lines are visible + const hdr = document.querySelector('header.nav'); + const ftr = document.querySelector('footer'); + if(hdr){ document.documentElement.style.setProperty('--headerH', hdr.getBoundingClientRect().height+'px'); } + if(ftr){ document.documentElement.style.setProperty('--footerH', ftr.getBoundingClientRect().height+'px'); } if(document.getElementById('chanlist')){ loadChannels(); } if(document.getElementById('channel')){ fetch('/api/channels').then(r=>r.json()).then(arr=>{ const sel=document.getElementById('channel'); arr.forEach(c=>{ const o=document.createElement('option'); o.value=c; o.textContent=c; sel.appendChild(o); }); }).catch(()=>{}); From b70cf17713fa6cce292ae562dac12586f76f4b1f Mon Sep 17 00:00:00 2001 From: Thomas Cravey Date: Sun, 17 Aug 2025 14:58:25 -0500 Subject: [PATCH 15/32] fix(webui): multi-pass snap to bottom; image onload re-pin; dynamic header/footer height measurement; disable native scrollRestoration --- internal/httpapi/static/app.js | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/internal/httpapi/static/app.js b/internal/httpapi/static/app.js index b9735d3..99e7a32 100644 --- a/internal/httpapi/static/app.js +++ b/internal/httpapi/static/app.js @@ -1,11 +1,21 @@ // Shared state const st = { tailLoading: false, atBottom: true, current: '#', earliest: null, sse: null, channels: [] }; +function measureBars(){ + const hdr = document.querySelector('header.nav'); + const ftr = document.querySelector('footer'); + if(hdr){ document.documentElement.style.setProperty('--headerH', hdr.getBoundingClientRect().height+'px'); } + if(ftr){ document.documentElement.style.setProperty('--footerH', ftr.getBoundingClientRect().height+'px'); } +} + function colorFor(nick){ let h=0; for(let i=0;i>>0 } return 'hsl('+(h%360)+',60%,'+(window.matchMedia('(prefers-color-scheme: dark)').matches? '70%':'35%')+')'; } function escapeHtml(s){ return s.replace(/[&<>"]/g, c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); } function linkify(t){ return t.replace(/https?:\/\/\S+/g, function(u){ return '' + u + ''; }); } function lineHTML(m){ const ts = '[' + m.time + ']'; const nick = '' + m.author + ''; const body = escapeHtml(m.body); return ts + ' ' + nick + ': ' + linkify(body); } +function snapBottom(){ window.scrollTo(0, document.documentElement.scrollHeight); } +function pinBottomMulti(){ if(!st.atBottom) return; [0,16,64,200].forEach(d=> setTimeout(()=>requestAnimationFrame(snapBottom), d)); } + async function api(path, params){ const url = new URL(path, window.location.origin); if(params && params.query){ Object.entries(params.query).forEach(([k,v])=>url.searchParams.set(k,v)); } @@ -16,9 +26,8 @@ async function api(path, params){ return res.text(); } -function appendBatch(arr){ const el=document.getElementById('tail'); const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.appendChild(frag); if(st.atBottom){ requestAnimationFrame(()=>{ window.scrollTo({top: document.documentElement.scrollHeight, behavior: 'instant'}); }); } } -function prependBatch(arr){ const el=document.getElementById('tail'); const oldTop=el.firstChild; const beforeTop = oldTop ? oldTop.getBoundingClientRect().top : 0; const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.insertBefore(frag, el.firstChild); // preserve visual position - if(oldTop){ const afterTop = oldTop.getBoundingClientRect().top; const delta = afterTop - beforeTop; window.scrollBy({top: delta, left: 0, behavior: 'instant'}); } } +function appendBatch(arr){ const el=document.getElementById('tail'); const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.appendChild(frag); pinBottomMulti(); } +function prependBatch(arr){ const el=document.getElementById('tail'); const oldTop=el.firstChild; const beforeTop = oldTop ? oldTop.getBoundingClientRect().top : 0; const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.insertBefore(frag, el.firstChild); if(oldTop){ const afterTop = oldTop.getBoundingClientRect().top; const delta = afterTop - beforeTop; window.scrollBy(0, delta); } } function processLinks(scope){ const links = scope.querySelectorAll('a[href]:not([data-card])'); links.forEach(a=>{ a.setAttribute('data-card','1'); // Fetch and render card @@ -33,6 +42,7 @@ function processLinks(scope){ const links = scope.querySelectorAll('a[href]:not( html += ''; const row = document.createElement('div'); row.style.display='flex'; row.style.alignItems='flex-start'; row.style.gap='.5rem'; row.innerHTML = html; c.appendChild(row); + c.querySelectorAll('img').forEach(img=> img.addEventListener('load', ()=> pinBottomMulti())); // Summary control row const ctrl = document.createElement('div'); ctrl.style.marginTop='.25rem'; const btn = document.createElement('button'); btn.type='button'; btn.title='Summarize this link'; btn.textContent='\u25B6'; btn.style.padding='0 .4rem'; btn.style.fontSize='.9rem'; @@ -55,9 +65,7 @@ function processLinks(scope){ const links = scope.querySelectorAll('a[href]:not( async function loadChannels(){ try{ const data = await api('/api/channels'); st.channels = data; renderChannels(); if(data.length>0){ selectChannel(data[0]); } } catch(e){} } function renderChannels(){ const list=document.getElementById('chanlist'); if(!list) return; list.innerHTML=''; st.channels.forEach(c=>{ const a=document.createElement('a'); a.href='#'; a.textContent=c; a.onclick=(ev)=>{ev.preventDefault(); selectChannel(c)}; if(c===st.current) a.className='active'; list.appendChild(a); }); } -async function selectChannel(ch){ if(st.sse){ st.sse.close(); st.sse=null; } st.current=ch; renderChannels(); const el=document.getElementById('tail'); if(!el) return; el.textContent=''; const data = await api('/api/tail',{query:{channel:ch,limit:50}}); appendBatch(data); // snap to bottom after initial load - requestAnimationFrame(()=>{ window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'instant' }); st.atBottom=true; }); - st.earliest = data.length? data[0].time : null; startStream(); initScrollHandlers(); } +async function selectChannel(ch){ if(st.sse){ st.sse.close(); st.sse=null; } st.current=ch; renderChannels(); const el=document.getElementById('tail'); if(!el) return; el.textContent=''; const data = await api('/api/tail',{query:{channel:ch,limit:50}}); appendBatch(data); requestAnimationFrame(()=>{ snapBottom(); st.atBottom=true; }); st.earliest = data.length? data[0].time : null; startStream(); initScrollHandlers(); } function initScrollHandlers(){ const el=document.getElementById('tail'); if(!el) return; const onScroll = async ()=>{ const nearBottom = (window.innerHeight + window.pageYOffset + 16) >= document.documentElement.scrollHeight; st.atBottom = nearBottom; const nearTop = window.pageYOffset <= 4; if(nearTop && st.earliest){ try{ const older = await api('/api/history',{query:{channel:st.current,before:st.earliest,limit:50}}); if(older.length){ prependBatch(older); st.earliest = older[0].time; } } catch(e){} } }; window.removeEventListener('scroll', st._scrollHandler || (()=>{})); st._scrollHandler = onScroll; window.addEventListener('scroll', onScroll, {passive:true}); } function startStream(){ const el=document.getElementById('tail'); if(!el) return; const url=new URL('/api/stream', window.location.origin); url.searchParams.set('channel', st.current); const es=new EventSource(url); st.sse=es; es.onmessage=(ev)=>{ try{ const m=JSON.parse(ev.data); appendBatch([m]); }catch(e){} }; es.onerror=()=>{ es.close(); st.sse=null; setTimeout(startStream, 3000); } } @@ -65,13 +73,12 @@ function startStream(){ const el=document.getElementById('tail'); if(!el) return async function summarize(){ const ch=document.getElementById('channel'); const win=document.getElementById('window'); const push=document.getElementById('push'); const btn=document.getElementById('btn'); const out=document.getElementById('out'); if(!ch||!win||!btn||!out) return; btn.disabled=true; out.textContent=''; try{ const data = await api('/api/trigger',{query:{channel:ch.value,window:win.value||'6h',push:push && push.checked? '1':'0'}}); if(typeof data === 'string'){ out.textContent = data; } else { out.textContent = (data.summary||''); } } catch(e){ out.textContent = 'error: '+e; } btn.disabled=false; } window.addEventListener('DOMContentLoaded', ()=>{ - // measure header/footer and set margins so last lines are visible - const hdr = document.querySelector('header.nav'); - const ftr = document.querySelector('footer'); - if(hdr){ document.documentElement.style.setProperty('--headerH', hdr.getBoundingClientRect().height+'px'); } - if(ftr){ document.documentElement.style.setProperty('--footerH', ftr.getBoundingClientRect().height+'px'); } + if('scrollRestoration' in history){ history.scrollRestoration = 'manual'; } + measureBars(); if(document.getElementById('chanlist')){ loadChannels(); } if(document.getElementById('channel')){ fetch('/api/channels').then(r=>r.json()).then(arr=>{ const sel=document.getElementById('channel'); arr.forEach(c=>{ const o=document.createElement('option'); o.value=c; o.textContent=c; sel.appendChild(o); }); }).catch(()=>{}); } }); +window.addEventListener('load', measureBars); +window.addEventListener('resize', ()=>{ measureBars(); if(st.atBottom) pinBottomMulti(); }); From 6e64969bb6b83acec97bae072a3b68acefd931e8 Mon Sep 17 00:00:00 2001 From: Thomas Cravey Date: Sun, 17 Aug 2025 15:30:49 -0500 Subject: [PATCH 16/32] feat(webui): move channel list into navbar dropdown with checkmark; remove sidebar and footer; show short commit centered in navbar --- internal/httpapi/static/app.css | 7 +++++-- internal/httpapi/static/app.js | 4 ++-- internal/httpapi/templates.go | 5 +++++ internal/httpapi/templates/dashboard.tmpl | 7 ++----- internal/httpapi/templates/layout.tmpl | 9 ++++++++- 5 files changed, 22 insertions(+), 10 deletions(-) diff --git a/internal/httpapi/static/app.css b/internal/httpapi/static/app.css index f10eb61..41e6100 100644 --- a/internal/httpapi/static/app.css +++ b/internal/httpapi/static/app.css @@ -1,10 +1,13 @@ html, body { height: 100%; } body { padding: 0; min-height: 100vh; } -header.nav { position: fixed; top: 0; left: 0; right: 0; z-index: 1000; padding: .6rem 1rem; border-bottom: 1px solid var(--muted-border-color); display: flex; justify-content: space-between; align-items: center; background-color: var(--pico-background-color, #fff); } +header.nav { position: fixed; top: 0; left: 0; right: 0; z-index: 1000; padding: .6rem 1rem; border-bottom: 1px solid var(--muted-border-color); display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; background-color: var(--pico-background-color, #fff); } +header.nav .brand { justify-self: start; } +header.nav .commit { justify-self: center; font-size: .75rem; opacity: .75; } +header.nav nav { justify-self: end; } main, article, .dash { margin-top: var(--headerH, 56px); margin-bottom: var(--footerH, 44px); } header.nav a.brand { text-decoration: none; font-weight: 600; } /* Dashboard-only grid layout */ -.dash { display: grid; grid-template-columns: 220px 1fr; gap: 0; } +.dash { display: grid; grid-template-columns: 1fr; gap: 0; } .dash aside.sidebar { border-right: 1px solid var(--muted-border-color); padding: .75rem; overflow-y: auto; } .dash aside.sidebar a { display:block; padding:.25rem .5rem; border-radius:.25rem; text-decoration:none; } .dash aside.sidebar a.active { background: var(--muted-color); color: var(--contrast); } diff --git a/internal/httpapi/static/app.js b/internal/httpapi/static/app.js index 99e7a32..a12ead3 100644 --- a/internal/httpapi/static/app.js +++ b/internal/httpapi/static/app.js @@ -63,7 +63,7 @@ function processLinks(scope){ const links = scope.querySelectorAll('a[href]:not( }); } async function loadChannels(){ try{ const data = await api('/api/channels'); st.channels = data; renderChannels(); if(data.length>0){ selectChannel(data[0]); } } catch(e){} } -function renderChannels(){ const list=document.getElementById('chanlist'); if(!list) return; list.innerHTML=''; st.channels.forEach(c=>{ const a=document.createElement('a'); a.href='#'; a.textContent=c; a.onclick=(ev)=>{ev.preventDefault(); selectChannel(c)}; if(c===st.current) a.className='active'; list.appendChild(a); }); } +function renderChannels(){ const list=document.getElementById('nav-chans'); if(!list) return; list.innerHTML=''; st.channels.forEach(c=>{ const li=document.createElement('li'); const a=document.createElement('a'); a.href='#'; a.textContent=c + (c===st.current? ' ✓':''); a.onclick=(ev)=>{ev.preventDefault(); selectChannel(c);}; li.appendChild(a); list.appendChild(li); }); } async function selectChannel(ch){ if(st.sse){ st.sse.close(); st.sse=null; } st.current=ch; renderChannels(); const el=document.getElementById('tail'); if(!el) return; el.textContent=''; const data = await api('/api/tail',{query:{channel:ch,limit:50}}); appendBatch(data); requestAnimationFrame(()=>{ snapBottom(); st.atBottom=true; }); st.earliest = data.length? data[0].time : null; startStream(); initScrollHandlers(); } function initScrollHandlers(){ const el=document.getElementById('tail'); if(!el) return; const onScroll = async ()=>{ const nearBottom = (window.innerHeight + window.pageYOffset + 16) >= document.documentElement.scrollHeight; st.atBottom = nearBottom; const nearTop = window.pageYOffset <= 4; if(nearTop && st.earliest){ try{ const older = await api('/api/history',{query:{channel:st.current,before:st.earliest,limit:50}}); if(older.length){ prependBatch(older); st.earliest = older[0].time; } } catch(e){} } }; window.removeEventListener('scroll', st._scrollHandler || (()=>{})); st._scrollHandler = onScroll; window.addEventListener('scroll', onScroll, {passive:true}); } @@ -75,7 +75,7 @@ async function summarize(){ const ch=document.getElementById('channel'); const w window.addEventListener('DOMContentLoaded', ()=>{ if('scrollRestoration' in history){ history.scrollRestoration = 'manual'; } measureBars(); - if(document.getElementById('chanlist')){ loadChannels(); } + loadChannels(); if(document.getElementById('channel')){ fetch('/api/channels').then(r=>r.json()).then(arr=>{ const sel=document.getElementById('channel'); arr.forEach(c=>{ const o=document.createElement('option'); o.value=c; o.textContent=c; sel.appendChild(o); }); }).catch(()=>{}); } diff --git a/internal/httpapi/templates.go b/internal/httpapi/templates.go index 39d1b53..88195f5 100644 --- a/internal/httpapi/templates.go +++ b/internal/httpapi/templates.go @@ -36,6 +36,11 @@ func (s *Server) render(w http.ResponseWriter, name string, data map[string]any) } data["Version"] = s.Version data["Commit"] = s.Commit + if s.Commit != "" { + c := s.Commit + if len(c) > 12 { c = c[:12] } + data["CommitShort"] = c + } base := strings.TrimSuffix(path.Base(name), path.Ext(name)) if base == "dashboard" { data["Title"] = "sojuboy" diff --git a/internal/httpapi/templates/dashboard.tmpl b/internal/httpapi/templates/dashboard.tmpl index c6ea560..06601cd 100644 --- a/internal/httpapi/templates/dashboard.tmpl +++ b/internal/httpapi/templates/dashboard.tmpl @@ -1,9 +1,6 @@ {{ define "dashboard" }} -
- -
+
+
diff --git a/internal/httpapi/templates/layout.tmpl b/internal/httpapi/templates/layout.tmpl index 3bfc76e..367d281 100644 --- a/internal/httpapi/templates/layout.tmpl +++ b/internal/httpapi/templates/layout.tmpl @@ -12,8 +12,15 @@