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
114 lines
3.5 KiB
Go
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)
|
|
}
|