feat(dashboard phase1): SSE stream with 15s heartbeat; history paging (50); channel sidebar and nav/footer; OG/Twitter link card endpoint; tail UI rework with infinite scroll and auto-follow pause
This commit is contained in:
parent
a6091b8758
commit
3f94aa7068
3 changed files with 318 additions and 56 deletions
|
|
@ -6,8 +6,11 @@ import (
|
|||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
|
|
@ -42,12 +45,19 @@ type Server struct {
|
|||
StartedAt time.Time
|
||||
// Optional seed list from config for /api/channels when DB is empty
|
||||
KnownChannels []string
|
||||
// SSE subscribers map
|
||||
subs map[string][]chan store.Message
|
||||
subsMu sync.RWMutex
|
||||
// Link card cache
|
||||
cardCache map[string]linkCard
|
||||
cardCacheExp 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)
|
||||
mux.HandleFunc("/login", s.handleLogin)
|
||||
mux.HandleFunc("/auth", s.handleAuth)
|
||||
mux.HandleFunc("/logout", s.handleLogout)
|
||||
|
|
@ -72,6 +82,9 @@ func (s *Server) Start(ctx context.Context) error {
|
|||
mux.HandleFunc("/api/channels", s.handleChannels)
|
||||
mux.HandleFunc("/api/tail", s.handleTailJSON)
|
||||
mux.HandleFunc("/api/trigger", s.handleTriggerJSON)
|
||||
mux.HandleFunc("/api/history", s.handleHistory)
|
||||
mux.HandleFunc("/api/stream", s.handleStream)
|
||||
mux.HandleFunc("/api/linkcard", s.handleLinkCard)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: s.ListenAddr,
|
||||
|
|
@ -183,6 +196,72 @@ 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, Title, Description, Image string }
|
||||
|
||||
// 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 (very lightweight, no full readability here)
|
||||
// For brevity, we only parse a few tags by string search to keep dependencies minimal in this step
|
||||
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
|
||||
limited := http.MaxBytesReader(w, resp.Body, 262144)
|
||||
b, _ := io.ReadAll(limited)
|
||||
html := string(b)
|
||||
// naive meta parsing
|
||||
get := func(names ...string) string {
|
||||
for _, n := range names {
|
||||
// look for content="..."
|
||||
idx := strings.Index(strings.ToLower(html), strings.ToLower(n))
|
||||
if idx >= 0 {
|
||||
// slice forward
|
||||
sfx := html[idx:]
|
||||
ic := strings.Index(strings.ToLower(sfx), "content=")
|
||||
if ic >= 0 {
|
||||
sfx = sfx[ic+8:]
|
||||
// trim quotes
|
||||
if len(sfx) > 0 && (sfx[0] == '"' || sfx[0] == '\'') {
|
||||
q := sfx[0]
|
||||
sfx = sfx[1:]
|
||||
iq := strings.IndexByte(sfx, q)
|
||||
if iq >= 0 { return strings.TrimSpace(sfx[:iq]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
card := linkCard{ URL: raw }
|
||||
card.Title = get("property=\"og:title\"","name=\"og:title\"","name=\"twitter:title\"")
|
||||
card.Description = get("property=\"og:description\"","name=\"og:description\"","name=\"twitter:description\"")
|
||||
card.Image = get("property=\"og:image\"","name=\"og:image\"","name=\"twitter:image\"")
|
||||
// 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)
|
||||
}
|
||||
|
||||
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
|
||||
|
|
@ -226,16 +305,22 @@ func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
|
|||
<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; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; }
|
||||
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||
@media (max-width: 768px) {
|
||||
.grid { grid-template-columns: 1fr !important; }
|
||||
pre { max-height: 40vh; }
|
||||
}
|
||||
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; }
|
||||
#tail { flex: 1; overflow: auto; 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 { 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; } }
|
||||
</style>
|
||||
<script>
|
||||
const st={token: localStorage.getItem('token')||'', tailTimer:null, tailLoading:false};
|
||||
const st={ tailLoading:false, atBottom:true, current:'#', earliest:null, sse:null, channels:[] };
|
||||
function setToken(v){ st.token=v; localStorage.setItem('token', v); }
|
||||
async function api(path, params){
|
||||
const url = new URL(path, window.location.origin);
|
||||
|
|
@ -257,25 +342,23 @@ func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
|
|||
}catch(e){ console.error(e); }
|
||||
}
|
||||
async function loadChannels(){
|
||||
try{ const data = await api('/api/channels');
|
||||
const sel = document.getElementById('channel'); sel.innerHTML = '';
|
||||
data.forEach(function(c){ const o=document.createElement('option'); o.value=c; o.textContent=c; sel.appendChild(o); });
|
||||
// Auto-tail default channel (first) with 25 lines
|
||||
if(data.length>0){ document.getElementById('limit').value = '25'; doTail(); }
|
||||
}catch(e){ console.error(e); }
|
||||
}
|
||||
async function doTail(){
|
||||
if(st.tailLoading) return; st.tailLoading=true;
|
||||
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');
|
||||
const el = document.getElementById('tail');
|
||||
el.textContent = out;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}catch(e){ document.getElementById('tail').textContent = 'error: '+e; }
|
||||
st.tailLoading=false;
|
||||
try{ const data = await api('/api/channels'); st.channels = data; renderChannels(); if(data.length>0){ selectChannel(data[0]); } }
|
||||
catch(e){ console.error(e); }
|
||||
}
|
||||
function renderChannels(){ const list=document.getElementById('chanlist'); 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'); el.textContent=''; // bootstrap 50
|
||||
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'); el.onscroll=async ()=>{ st.atBottom = (el.scrollTop + el.clientHeight + 8) >= el.scrollHeight; if(el.scrollTop === 0 && st.earliest){ // load more
|
||||
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 colorFor(nick){ let h=0; for(let i=0;i<nick.length;i++){ h=(h*31+nick.charCodeAt(i))>>>0 } return 'hsl('+(h%360)+',60%,'+(window.matchMedia('(prefers-color-scheme: dark)').matches? '70%':'35%')+')'; }
|
||||
function lineHTML(m){ const ts = `<span class=ts>[${m.time}]</span>`; const nick = `<b style="color:${colorFor(m.author)}">${m.author}</b>`; const body = escapeHtml(m.body); return `${ts} ${nick}: ${linkify(body)}`; }
|
||||
function escapeHtml(s){ return s.replace(/[&<>"]/g, c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||||
function linkify(t){ return t.replace(/https?:\/\/\S+/g, u=> `<a href="${u}" target="_blank" rel="noopener">${u}</a>`); }
|
||||
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); }); 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); }); el.insertBefore(frag, el.firstChild); if(oldTop){ oldTop.scrollIntoView(); } }
|
||||
function startStream(){ 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 doSumm(){
|
||||
const ch = document.getElementById('channel').value;
|
||||
const win = document.getElementById('window').value || '6h';
|
||||
|
|
@ -303,38 +386,67 @@ func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
|
|||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<main class="container">
|
||||
<h1>sojuboy</h1>
|
||||
<article>
|
||||
<div class="grid">
|
||||
<label>Channel<select id="channel" onchange="onChannelChange()"></select></label>
|
||||
<label>Limit<input type="number" id="limit" value="25"/></label>
|
||||
<label><input type="checkbox" id="follow" onchange="onFollowToggle(this)"/> Follow</label>
|
||||
<a href="/logout" role="button" class="contrast">Logout</a>
|
||||
<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>
|
||||
<div>
|
||||
<button id="summBtn" onclick="doSumm()">Summarize</button>
|
||||
<progress id="summProg" value="0" max="1" style="display:none; vertical-align: middle;"></progress>
|
||||
</div>
|
||||
</div>
|
||||
<pre id="summary"></pre>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Status</h3>
|
||||
<header class="nav">
|
||||
<div><a class="brand" href="/">sojuboy</a></div>
|
||||
<nav>
|
||||
<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>
|
||||
<li><a href="/summarizer">Summarizer</a></li>
|
||||
<li><a href="/logout">Logout</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="container">
|
||||
<aside class="sidebar">
|
||||
<nav id="chanlist"></nav>
|
||||
</aside>
|
||||
<section class="chat">
|
||||
<div id="tail"></div>
|
||||
</section>
|
||||
</main>
|
||||
<footer>
|
||||
<small>` + s.Version + `</small>
|
||||
</footer>
|
||||
</body>
|
||||
</html>`
|
||||
_, _ = w.Write([]byte(page))
|
||||
}
|
||||
|
||||
func (s *Server) handleSummarizerUI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.AuthToken != "" {
|
||||
if !checkAuth(r, s.AuthToken) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte("unauthorized"))
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
page := `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Summarizer · sojuboy</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/@picocss/pico@2/css/pico.min.css">
|
||||
<style>body{padding:1rem;} main{max-width:480px;margin:auto;margin-top:15vh}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="container">
|
||||
<article>
|
||||
<h2>Summarizer</h2>
|
||||
<form id="f" method="post" action="/api/summarize">
|
||||
<label>Channel
|
||||
<input type="text" name="channel" id="channel" required placeholder="irc.example.com"/>
|
||||
</label>
|
||||
<label>Window
|
||||
<input type="text" name="window" id="window" value="6h" placeholder="1h, 12h, 24h"/>
|
||||
</label>
|
||||
<label>Push to Notifier
|
||||
<input type="checkbox" name="push" id="push" checked/>
|
||||
</label>
|
||||
<button type="submit" id="summBtn">Summarize</button>
|
||||
</form>
|
||||
<div id="summary" style="margin-top: 1rem; padding: 1rem; border: 1px solid var(--muted-border-color); border-radius: 0.5rem; background-color: var(--muted-color);"></div>
|
||||
<progress id="summProg" style="display: none;"></progress>
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
|
|
@ -358,6 +470,77 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
|||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// Summarizer simple page placeholder (will reuse existing summarizer flow)
|
||||
func (s *Server) handleSummarizerUI(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 := `<!doctype html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>Summarizer · sojuboy</title><link rel="stylesheet" href="https://unpkg.com/@picocss/pico@2/css/pico.min.css"><style>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{max-width:900px;margin:0 auto;padding:1rem} footer{text-align:center;font-size:.85rem;padding:.5rem 0;opacity:.7}</style><script>async function api(path,params){const url=new URL(path,window.location.origin);if(params&¶ms.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);return res.text();}async function summarize(){const ch=document.getElementById('channel').value;const win=document.getElementById('window').value||'6h';const push=document.getElementById('push').checked?'1':'0';const btn=document.getElementById('btn');const out=document.getElementById('out');btn.disabled=true;out.textContent='';try{const txt=await api('/trigger',{query:{channel:ch,window:win,token:'` + s.AuthToken + `'}});out.textContent=txt;}catch(e){out.textContent='error: '+e;}btn.disabled=false;}</script></head><body><header class="nav"><div><a class="brand" href="/">sojuboy</a></div><nav><ul><li><a href="/summarizer" aria-current="page">Summarizer</a></li><li><a href="/logout">Logout</a></li></ul></nav></header><main><article><h3>On-demand summarization</h3><label>Channel<select id="channel"></select></label><label>Window<input id="window" value="6h"></label><label><input type="checkbox" id="push"> Send via Pushover</label><button id="btn" onclick="summarize()">Summarize</button><pre id="out"></pre></article></main><footer><small>` + s.Version + `</small></footer><script>(async()=>{try{const res=await fetch('/api/channels');const arr=await res.json();const sel=document.getElementById('channel');arr.forEach(c=>{const o=document.createElement('option');o.value=c;o.textContent=c;sel.appendChild(o);});}catch(e){}})();</script></body></html>`
|
||||
_, _ = w.Write([]byte(page))
|
||||
}
|
||||
|
||||
// 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 }
|
||||
|
||||
// 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)
|
||||
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) {
|
||||
if s.AuthToken != "" && !checkAuth(r, s.AuthToken) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
|
|
@ -419,6 +602,28 @@ func (s *Server) handleTriggerJSON(w http.ResponseWriter, r *http.Request) {
|
|||
_ = 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)
|
||||
}
|
||||
|
||||
func checkAuth(r *http.Request, token string) bool {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer ") {
|
||||
|
|
|
|||
|
|
@ -117,6 +117,39 @@ func (s *Store) ListMessagesSince(ctx context.Context, channel string, since tim
|
|||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListMessagesBefore returns up to limit messages for a channel strictly before the given time.
|
||||
// Results are returned in ascending chronological order.
|
||||
func (s *Store) ListMessagesBefore(ctx context.Context, channel string, before time.Time, limit int) ([]Message, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
"SELECT id, channel, author, body, at, msgid FROM messages WHERE lower(channel) = lower(?) AND at < ? ORDER BY at DESC LIMIT ?",
|
||||
channel, before.UTC(), limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var tmp []Message
|
||||
for rows.Next() {
|
||||
var m Message
|
||||
var at time.Time
|
||||
var msgid sql.NullString
|
||||
if err := rows.Scan(&m.ID, &m.Channel, &m.Author, &m.Body, &at, &msgid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Time = at
|
||||
if msgid.Valid { m.MsgID = msgid.String }
|
||||
tmp = append(tmp, m)
|
||||
}
|
||||
// Reverse to ascending order
|
||||
for i, j := 0, len(tmp)-1; i < j; i, j = i+1, j-1 {
|
||||
tmp[i], tmp[j] = tmp[j], tmp[i]
|
||||
}
|
||||
return tmp, rows.Err()
|
||||
}
|
||||
|
||||
// ListRecentMessages returns the most recent N messages for a channel.
|
||||
func (s *Store) ListRecentMessages(ctx context.Context, channel string, limit int) ([]Message, error) {
|
||||
if limit <= 0 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue