Files
Alexandru MacocianandCopilot 0cca3fd796 Enrich firing alerts only and roll the feed daily
Drop non-firing alerts at the webhook, serve a rolling daily window from
the store, populate verdict/confidence on entries, and render a verdict
banner in the entry HTML. Add firing-only and daily-window tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5749c447-6ecd-46bd-946d-21b4d101d084
2026-07-30 17:25:28 +02:00

114 lines
3.5 KiB
Go

package server
import (
"encoding/json"
"log/slog"
"net/http"
"strings"
"time"
"gitea.alexandru.macocian.me/amacocian/jarvis/internal/alert"
"gitea.alexandru.macocian.me/amacocian/jarvis/internal/store"
)
// Server is jarvis's HTTP surface.
type Server struct {
cfg Config
store *store.Store
worker *Worker
log *slog.Logger
}
// New builds a Server. The worker must be started separately via Worker.Run.
func New(cfg Config, st *store.Store, w *Worker, log *slog.Logger) *Server {
if cfg.FeedLimit <= 0 {
cfg.FeedLimit = 50
}
return &Server{cfg: cfg, store: st, worker: w, log: log}
}
// Handler returns the fully-wired HTTP handler.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.handleHealth)
mux.HandleFunc("POST /webhook/grafana", bearerAuth(s.cfg.WebhookToken, s.handleWebhook))
mux.HandleFunc("GET /feed.atom", basicAuth(s.cfg.FeedUser, s.cfg.FeedPassword, s.handleFeed))
return mux
}
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok\n"))
}
// handleWebhook decodes a Grafana notification and enqueues one job per alert.
func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) {
defer func() { _ = r.Body.Close() }()
var n alert.Notification
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<20)).Decode(&n); err != nil {
s.log.Warn("webhook decode failed", "err", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
accepted, dropped, skipped := 0, 0, 0
for _, a := range n.Alerts {
// Only firing alerts are enriched. Resolved notifications are
// acknowledged and dropped: the value is the first-pass triage of an
// active problem, and the reader already holds the firing entry.
status := a.Status
if status == "" {
status = n.Status
}
if !strings.EqualFold(status, "firing") {
skipped++
continue
}
j := job{
alert: a,
groupLabels: n.GroupLabels,
externalURL: n.ExternalURL,
raw: marshalAlert(a),
}
if s.worker.enqueue(j) {
accepted++
} else {
dropped++
}
}
if dropped > 0 {
s.log.Warn("webhook queue full, dropped alerts", "dropped", dropped, "accepted", accepted)
}
s.log.Info("webhook received", "alerts", len(n.Alerts), "accepted", accepted, "skipped_resolved", skipped, "status", n.Status)
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte("accepted\n"))
}
// handleFeed renders the Atom feed from the current day's entries. The feed
// is a rolling daily window: it serves entries created since local midnight
// and resets each day. miniflux is the durable store (it caches every entry
// it has fetched), so jarvis keeps only what a fresh reader needs to catch up.
func (s *Server) handleFeed(w http.ResponseWriter, r *http.Request) {
startOfDay := time.Now().Truncate(24 * time.Hour)
if loc := time.Local; loc != nil {
now := time.Now().In(loc)
startOfDay = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
}
entries, err := s.store.Since(r.Context(), startOfDay, s.cfg.FeedLimit)
if err != nil {
s.log.Error("feed query failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
body, err := renderAtom(s.cfg, entries)
if err != nil {
s.log.Error("feed render failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")
_, _ = w.Write(body)
}