feat(webui): add minimal dashboard (Pico.css) with tail, trigger, status; JSON endpoints: /api/info,/api/channels,/api/tail,/api/trigger; default compose/UI auth via HTTP_TOKEN
This commit is contained in:
parent
26ae405e9b
commit
45d1d98e56
3 changed files with 212 additions and 0 deletions
|
|
@ -118,6 +118,10 @@ func main() {
|
||||||
Metrics: metrics,
|
Metrics: metrics,
|
||||||
Ready: func() bool { return atomic.LoadInt64(&metrics.ConnectedGauge) == 1 },
|
Ready: func() bool { return atomic.LoadInt64(&metrics.ConnectedGauge) == 1 },
|
||||||
SummarizerTimeout: cfg.SummarizerTimeout,
|
SummarizerTimeout: cfg.SummarizerTimeout,
|
||||||
|
Version: version,
|
||||||
|
Commit: commit,
|
||||||
|
BuiltAt: builtAt,
|
||||||
|
StartedAt: time.Now(),
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
if err := api.Start(ctx); err != nil && err != http.ErrServerClosed {
|
if err := api.Start(ctx); err != nil && err != http.ErrServerClosed {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
@ -34,10 +35,17 @@ type Server struct {
|
||||||
Ready func() bool
|
Ready func() bool
|
||||||
// Optional timeout override for summarizer
|
// Optional timeout override for summarizer
|
||||||
SummarizerTimeout time.Duration
|
SummarizerTimeout time.Duration
|
||||||
|
// Build/runtime info for UI
|
||||||
|
Version string
|
||||||
|
Commit string
|
||||||
|
BuiltAt string
|
||||||
|
StartedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Start(ctx context.Context) error {
|
func (s *Server) Start(ctx context.Context) error {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
// Minimal web UI
|
||||||
|
mux.HandleFunc("/", s.handleUI)
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte("ok"))
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
|
@ -54,6 +62,11 @@ func (s *Server) Start(ctx context.Context) error {
|
||||||
mux.HandleFunc("/trigger", s.handleTrigger)
|
mux.HandleFunc("/trigger", s.handleTrigger)
|
||||||
mux.HandleFunc("/tail", s.handleTail)
|
mux.HandleFunc("/tail", s.handleTail)
|
||||||
mux.HandleFunc("/metrics", s.handleMetrics)
|
mux.HandleFunc("/metrics", s.handleMetrics)
|
||||||
|
// JSON endpoints for UI
|
||||||
|
mux.HandleFunc("/api/info", s.handleInfo)
|
||||||
|
mux.HandleFunc("/api/channels", s.handleChannels)
|
||||||
|
mux.HandleFunc("/api/tail", s.handleTailJSON)
|
||||||
|
mux.HandleFunc("/api/trigger", s.handleTriggerJSON)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: s.ListenAddr,
|
Addr: s.ListenAddr,
|
||||||
|
|
@ -184,6 +197,183 @@ func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
_, _ = fmt.Fprintf(w, "sojuboy_connected %d\n", conn)
|
_, _ = fmt.Fprintf(w, "sojuboy_connected %d\n", conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Web UI handlers ---
|
||||||
|
|
||||||
|
func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/" {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
// Pico.css from CDN and a tiny app
|
||||||
|
page := `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>sojuboy</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/@picocss/pico@2/css/pico.min.css">
|
||||||
|
<style>
|
||||||
|
body { padding: 1rem; }
|
||||||
|
pre { max-height: 50vh; overflow: auto; }
|
||||||
|
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
const st={token: localStorage.getItem('token')||''};
|
||||||
|
function setToken(v){ st.token=v; localStorage.setItem('token', v); }
|
||||||
|
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 opts = { headers: {} };
|
||||||
|
if(st.token){ opts.headers['Authorization'] = 'Bearer '+st.token; }
|
||||||
|
const res = await fetch(url, opts);
|
||||||
|
if(!res.ok){ throw new Error('HTTP '+res.status); }
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
async function loadInfo(){
|
||||||
|
try{ const data = await api('/api/info');
|
||||||
|
document.getElementById('version').textContent = data.version+ ' ('+data.commit+')';
|
||||||
|
document.getElementById('built').textContent = data.builtAt;
|
||||||
|
document.getElementById('uptime').textContent = data.uptime;
|
||||||
|
document.getElementById('connected').textContent = data.connected? 'yes':'no';
|
||||||
|
document.getElementById('counts').textContent = `ingested ${data.messagesIngested}, notified ${data.notificationsSent}, pruned ${data.messagesPruned}`;
|
||||||
|
}catch(e){ console.error(e); }
|
||||||
|
}
|
||||||
|
async function loadChannels(){
|
||||||
|
try{ const data = await api('/api/channels');
|
||||||
|
const sel = document.getElementById('channel'); sel.innerHTML = '';
|
||||||
|
data.forEach(c=>{ const o=document.createElement('option'); o.value=c; o.textContent=c; sel.appendChild(o); });
|
||||||
|
}catch(e){ console.error(e); }
|
||||||
|
}
|
||||||
|
async function doTail(){
|
||||||
|
const ch = document.getElementById('channel').value;
|
||||||
|
const lim = document.getElementById('limit').value || '100';
|
||||||
|
try{ const data = await api('/api/tail',{query:{channel:ch,limit:lim}});
|
||||||
|
const out = data.map(m=>`${m.time} ${m.author}: ${m.body}`).join('\n');
|
||||||
|
document.getElementById('tail').textContent = out;
|
||||||
|
}catch(e){ document.getElementById('tail').textContent = 'error: '+e; }
|
||||||
|
}
|
||||||
|
async function doSumm(){
|
||||||
|
const ch = document.getElementById('channel').value;
|
||||||
|
const win = document.getElementById('window').value || '6h';
|
||||||
|
const push = document.getElementById('push').checked ? '1' : '0';
|
||||||
|
try{ const data = await api('/api/trigger',{query:{channel:ch,window:win,push:push}});
|
||||||
|
document.getElementById('summary').textContent = data.summary || '(empty)';
|
||||||
|
}catch(e){ document.getElementById('summary').textContent = 'error: '+e; }
|
||||||
|
}
|
||||||
|
window.addEventListener('DOMContentLoaded', ()=>{ loadInfo(); loadChannels(); });
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="container">
|
||||||
|
<h1>sojuboy</h1>
|
||||||
|
<article>
|
||||||
|
<div class="grid">
|
||||||
|
<label>Auth token<input type="password" id="tok" placeholder="HTTP_TOKEN" oninput="setToken(this.value)"/></label>
|
||||||
|
<label>Channel<select id="channel"></select></label>
|
||||||
|
<label>Limit<input type="number" id="limit" value="100"/></label>
|
||||||
|
<button onclick="doTail()">Refresh tail</button>
|
||||||
|
</div>
|
||||||
|
<pre id="tail"></pre>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<div class="grid">
|
||||||
|
<label>Window<input type="text" id="window" value="6h"/></label>
|
||||||
|
<label><input type="checkbox" id="push"/> Send via Pushover</label>
|
||||||
|
<button onclick="doSumm()">Summarize</button>
|
||||||
|
</div>
|
||||||
|
<pre id="summary"></pre>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h3>Status</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Version: <span id="version"></span></li>
|
||||||
|
<li>Built: <span id="built"></span></li>
|
||||||
|
<li>Uptime: <span id="uptime"></span></li>
|
||||||
|
<li>Connected: <span id="connected"></span></li>
|
||||||
|
<li>Counters: <span id="counts"></span></li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
_, _ = w.Write([]byte(page))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
resp := map[string]any{
|
||||||
|
"version": s.Version,
|
||||||
|
"commit": s.Commit,
|
||||||
|
"builtAt": s.BuiltAt,
|
||||||
|
"startedAt": s.StartedAt.Format(time.RFC3339),
|
||||||
|
"uptime": time.Since(s.StartedAt).Round(time.Second).String(),
|
||||||
|
"messagesIngested": func() int64 { if s.Metrics==nil {return 0}; return atomic.LoadInt64(&s.Metrics.MessagesIngested) }(),
|
||||||
|
"notificationsSent": func() int64 { if s.Metrics==nil {return 0}; return atomic.LoadInt64(&s.Metrics.NotificationsSent) }(),
|
||||||
|
"messagesPruned": func() int64 { if s.Metrics==nil {return 0}; return atomic.LoadInt64(&s.Metrics.MessagesPruned) }(),
|
||||||
|
"connected": func() bool { if s.Metrics==nil {return false}; return atomic.LoadInt64(&s.Metrics.ConnectedGauge)==1 }(),
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.AuthToken != "" && !checkAuth(r, s.AuthToken) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte("unauthorized"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chs, err := s.Store.ListChannels(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte("store error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(chs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleTailJSON(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.AuthToken != "" && !checkAuth(r, s.AuthToken) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte("unauthorized"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
channel := r.URL.Query().Get("channel")
|
||||||
|
if channel == "" { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("missing channel")); return }
|
||||||
|
limit := getIntQuery(r, "limit", 100)
|
||||||
|
msgs, err := s.Store.ListRecentMessages(r.Context(), channel, limit)
|
||||||
|
if err != nil { w.WriteHeader(http.StatusInternalServerError); _, _ = w.Write([]byte("store error")); return }
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
type outMsg struct{ Time string `json:"time"`; Author string `json:"author"`; Body string `json:"body"`; Channel string `json:"channel"` }
|
||||||
|
arr := make([]outMsg, 0, len(msgs))
|
||||||
|
for i := len(msgs)-1; i>=0; i-- { m := msgs[i]; arr = append(arr, outMsg{Time: m.Time.UTC().Format(time.RFC3339), Author: m.Author, Body: m.Body, Channel: channel}) }
|
||||||
|
_ = json.NewEncoder(w).Encode(arr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleTriggerJSON(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.AuthToken != "" && !checkAuth(r, s.AuthToken) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte("unauthorized"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
channel := r.URL.Query().Get("channel")
|
||||||
|
if channel == "" { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("missing channel")); return }
|
||||||
|
win := r.URL.Query().Get("window"); if win=="" { win = "6h" }
|
||||||
|
push := r.URL.Query().Get("push") == "1"
|
||||||
|
dur, err := time.ParseDuration(win)
|
||||||
|
if err != nil { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("bad window")); return }
|
||||||
|
msgs, err := s.Store.ListMessagesSince(r.Context(), channel, time.Now().Add(-dur))
|
||||||
|
if err != nil { w.WriteHeader(http.StatusInternalServerError); _, _ = w.Write([]byte("store error")); return }
|
||||||
|
if s.Summarizer == nil { w.WriteHeader(http.StatusServiceUnavailable); _, _ = w.Write([]byte("summarizer not configured")); return }
|
||||||
|
tout := s.SummarizerTimeout; if tout<=0 { tout = 5*time.Minute }
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), tout); defer cancel()
|
||||||
|
sum, err := s.Summarizer.Summarize(ctx, channel, msgs, dur)
|
||||||
|
if err != nil { w.WriteHeader(http.StatusBadGateway); _, _ = w.Write([]byte("summarizer error")); return }
|
||||||
|
if push && s.Notifier != nil { title := fmt.Sprintf("IRC digest %s (%s)", channel, dur); _ = s.Notifier.Notify(r.Context(), title, sum); if s.Metrics != nil { atomic.AddInt64(&s.Metrics.NotificationsSent, 1) } }
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"summary": sum})
|
||||||
|
}
|
||||||
|
|
||||||
func checkAuth(r *http.Request, token string) bool {
|
func checkAuth(r *http.Request, token string) bool {
|
||||||
auth := r.Header.Get("Authorization")
|
auth := r.Header.Get("Authorization")
|
||||||
if strings.HasPrefix(auth, "Bearer ") {
|
if strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,24 @@ func (s *Store) InsertMessage(ctx context.Context, m Message) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListChannels returns distinct channel identifiers seen in the database.
|
||||||
|
func (s *Store) ListChannels(ctx context.Context) ([]string, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, "SELECT DISTINCT channel FROM messages ORDER BY lower(channel)")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var ch string
|
||||||
|
if err := rows.Scan(&ch); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, ch)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func nullIfEmpty(s string) any {
|
func nullIfEmpty(s string) any {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue