Files
Sandbar/internal/sandbar/api.go
T
VeillaxandClaude Sonnet 5.5 955e01333d Make the topic feeds work with the ntfy apps
The JSON stream now carries the event field the apps look for, sends open and keepalive
events, and supports ?poll=1 and ?since= with a duration or a message id, which is how the
apps catch up. Adds a walkthrough for the phone app to the README.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 04:22:53 -06:00

897 lines
26 KiB
Go

package sandbar
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
)
// Handler returns the HTTP handler for the whole app.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
// pages
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { servePage(w, indexHTML) })
mux.HandleFunc("GET /status", func(w http.ResponseWriter, r *http.Request) { servePage(w, statusHTML) })
mux.HandleFunc("GET /status.json", s.statusJSON)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") })
mux.HandleFunc("GET /metrics", s.metrics)
mux.HandleFunc("GET /api/v1/hb/{token}", s.heartbeat)
mux.HandleFunc("POST /api/v1/hb/{token}", s.heartbeat)
// session
mux.HandleFunc("GET /api/v1/state", s.getState)
mux.HandleFunc("POST /api/v1/setup", s.setup)
mux.HandleFunc("POST /api/v1/login", s.apiLogin)
mux.HandleFunc("POST /api/v1/logout", func(w http.ResponseWriter, r *http.Request) { s.logout(w, r); writeJSON(w, 200, ok) })
// topics, ntfy-style
mux.HandleFunc("POST /{topic}", s.auth(false, s.publishHTTP))
mux.HandleFunc("PUT /{topic}", s.auth(false, s.publishHTTP))
mux.HandleFunc("GET /{topic}/sse", s.auth(false, s.subscribeHTTP))
mux.HandleFunc("GET /{topic}/json", s.auth(false, s.subscribeHTTP))
mux.HandleFunc("GET /api/v1/topics/{topic}/messages", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64)
writeJSON(w, 200, s.MessagesSince(r.PathValue("topic"), since, 200))
}))
// people
mux.HandleFunc("GET /api/v1/users", s.auth(false, func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, s.listUsers()) }))
mux.HandleFunc("POST /api/v1/users", s.auth(true, s.createUserHTTP))
mux.HandleFunc("DELETE /api/v1/users/{id}", s.auth(true, s.deleteUser))
mux.HandleFunc("POST /api/v1/users/{id}/password", s.auth(false, s.passwordHTTP))
mux.HandleFunc("GET /api/v1/apikeys", s.auth(false, s.listKeys))
mux.HandleFunc("POST /api/v1/apikeys", s.auth(false, s.createKeyHTTP))
mux.HandleFunc("DELETE /api/v1/apikeys/{id}", s.auth(false, s.deleteKey))
// configuration documents
s.crud(mux, "channels", "channel", true, func() any { return &Channel{} }, func(v any, id string) error {
ch := v.(*Channel)
ch.ID = id
if ch.Target == redactedTarget { // an edit that left the hidden secret alone keeps the stored one
old, err := getOf[Channel](s.st, "channel", id)
if err != nil {
return err
}
ch.Target = s.st.open(old.Target)
}
if err := validateChannel(ch); err != nil {
return err
}
ch.Target = s.st.seal(ch.Target)
return nil
}, redactChannel)
s.crud(mux, "monitors", "monitor", true, func() any { return &Monitor{} }, func(v any, id string) error {
m := v.(*Monitor)
m.ID = id
return validateMonitor(m)
}, nil)
s.crud(mux, "schedules", "schedule", true, func() any { return &Schedule{} }, func(v any, id string) error {
sc := v.(*Schedule)
sc.ID = id
if strings.TrimSpace(sc.Name) == "" {
return errors.New("give the schedule a name")
}
if sc.RotationHours < 1 {
sc.RotationHours = 24 * 7
}
if sc.Start == 0 {
sc.Start = s.now().Unix()
}
for _, o := range sc.Overrides {
if o.To <= o.From {
return errors.New("an override must end after it starts")
}
}
return nil
}, nil)
s.crud(mux, "policies", "policy", true, func() any { return &Policy{} }, func(v any, id string) error {
p := v.(*Policy)
p.ID = id
if strings.TrimSpace(p.Name) == "" {
return errors.New("give the policy a name")
}
if len(p.Steps) == 0 {
return errors.New("a policy needs at least one step")
}
return nil
}, nil)
mux.HandleFunc("POST /api/v1/channels/{id}/test", s.auth(true, s.testChannel))
mux.HandleFunc("GET /api/v1/oncall", s.auth(false, s.onCallNow))
// monitors
mux.HandleFunc("GET /api/v1/monitors/{id}/checks", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, s.recentChecks(r.PathValue("id"), 100))
}))
mux.HandleFunc("GET /api/v1/monitor-states", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
out := map[string]MonitorState{}
mons, _ := listOf[Monitor](s.st, "monitor")
for _, m := range mons {
out[m.ID] = s.monitorState(m.ID)
}
writeJSON(w, 200, out)
}))
// alerts
mux.HandleFunc("GET /api/v1/alerts", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 500 {
limit = 100
}
writeJSON(w, 200, s.listAlerts(r.URL.Query().Get("status"), limit))
}))
mux.HandleFunc("POST /api/v1/alerts", s.auth(false, s.inboundAlert))
mux.HandleFunc("POST /api/v1/integrations/alertmanager", s.auth(false, s.alertmanager))
mux.HandleFunc("GET /api/v1/alerts/{id}", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
a, err := s.getAlert(r.PathValue("id"))
if err != nil {
fail(w, err)
return
}
a.Timeline = s.st.events("alert", a.ID)
writeJSON(w, 200, a)
}))
mux.HandleFunc("POST /api/v1/alerts/{id}/ack", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
fail2(w, s.AckAlert(r.PathValue("id"), userFrom(r.Context()).Name))
}))
mux.HandleFunc("POST /api/v1/alerts/{id}/resolve", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
fail2(w, s.ResolveAlert(r.PathValue("id"), userFrom(r.Context()).Name))
}))
mux.HandleFunc("POST /api/v1/alerts/{id}/snooze", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
var in struct {
Minutes int `json:"minutes"`
}
decode(r, &in)
if in.Minutes < 1 || in.Minutes > 24*60 {
in.Minutes = 30
}
fail2(w, s.SnoozeAlert(r.PathValue("id"), userFrom(r.Context()).Name, time.Duration(in.Minutes)*time.Minute))
}))
// incidents
mux.HandleFunc("GET /api/v1/incidents", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
list, err := listOf[Incident](s.st, "incident")
if err != nil {
fail(w, err)
return
}
sortIncidents(list) // ids are random, so order by time
writeJSON(w, 200, list)
}))
mux.HandleFunc("POST /api/v1/incidents", s.auth(false, s.declareHTTP))
mux.HandleFunc("GET /api/v1/incidents/{id}", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
in, err := getOf[Incident](s.st, "incident", r.PathValue("id"))
if err != nil {
fail(w, err)
return
}
in.Timeline = s.st.events("incident", in.ID)
writeJSON(w, 200, in)
}))
mux.HandleFunc("PATCH /api/v1/incidents/{id}", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
var p IncidentPatch
if !decode(r, &p) {
bad(w, "invalid request")
return
}
in, err := s.UpdateIncident(r.PathValue("id"), userFrom(r.Context()).Name, p)
if err != nil {
fail(w, err)
return
}
writeJSON(w, 200, in)
}))
mux.HandleFunc("POST /api/v1/incidents/{id}/comments", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
var in struct {
Text string `json:"text"`
}
decode(r, &in)
fail2(w, s.CommentIncident(r.PathValue("id"), userFrom(r.Context()).Name, in.Text))
}))
mux.HandleFunc("POST /api/v1/incidents/{id}/postmortem-template", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
in, err := getOf[Incident](s.st, "incident", r.PathValue("id"))
if err != nil {
fail(w, err)
return
}
writeJSON(w, 200, map[string]string{"template": s.postmortemTemplate(in)})
}))
mux.HandleFunc("DELETE /api/v1/incidents/{id}", s.auth(true, func(w http.ResponseWriter, r *http.Request) {
fail2(w, s.st.del("incident", r.PathValue("id")))
}))
return secureHeaders(mux)
}
var ok = map[string]bool{"ok": true}
func servePage(w http.ResponseWriter, page []byte) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(page)
}
func secureHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
h.Set("X-Frame-Options", "DENY")
h.Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:")
next.ServeHTTP(w, r)
})
}
// ---------- helpers ----------
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
// bad answers 400. Messages are capitalized here, in one place, so errors read as sentences.
func bad(w http.ResponseWriter, msg string) {
if msg != "" {
r, n := utf8.DecodeRuneInString(msg)
msg = string(unicode.ToUpper(r)) + msg[n:]
}
writeJSON(w, 400, map[string]string{"error": msg})
}
func fail(w http.ResponseWriter, err error) {
if errors.Is(err, errNotFound) {
writeJSON(w, 404, map[string]string{"error": "not found"})
return
}
bad(w, err.Error())
}
// fail2 answers with an error, or with ok when err is nil.
func fail2(w http.ResponseWriter, err error) {
if err != nil {
fail(w, err)
return
}
writeJSON(w, 200, ok)
}
func decode(r *http.Request, v any) bool {
return json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20)).Decode(v) == nil
}
// auth wraps a handler: it needs a signed-in user (admin if adminOnly). Requests
// that change things with a cookie must be JSON, which a cross-site form can't send.
func (s *Server) auth(adminOnly bool, h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u := s.authenticate(r)
if u == nil {
w.Header().Set("WWW-Authenticate", `Basic realm="sandbar"`)
writeJSON(w, 401, map[string]string{"error": "sign in or send an API key"})
return
}
if adminOnly && u.Role != "admin" {
writeJSON(w, 403, map[string]string{"error": "administrators only"})
return
}
_, noCookie := r.Cookie(sessionCookie)
if noCookie == nil && r.Method != "GET" && strings.HasPrefix(r.URL.Path, "/api/") &&
!strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
bad(w, "send application/json")
return
}
h(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, u)))
}
}
// crud registers list/create/update/delete for a kind of configuration document.
func (s *Server) crud(mux *http.ServeMux, path, kind string, adminWrite bool, newItem func() any, prepare func(v any, id string) error, redact func(any) any) {
base := "/api/v1/" + path
read := func(v any) any {
if redact != nil {
return redact(v)
}
return v
}
mux.HandleFunc("GET "+base, s.auth(false, func(w http.ResponseWriter, r *http.Request) {
var out []any
s.st.list(kind, func(raw []byte) error {
v := newItem()
if err := json.Unmarshal(raw, v); err != nil {
return err
}
out = append(out, read(v))
return nil
})
if out == nil {
out = []any{}
}
writeJSON(w, 200, out)
}))
save := func(w http.ResponseWriter, r *http.Request, id string) {
v := newItem()
if !decode(r, v) {
bad(w, "invalid request")
return
}
if err := prepare(v, id); err != nil {
bad(w, err.Error())
return
}
if err := s.st.put(kind, id, v); err != nil {
fail(w, err)
return
}
writeJSON(w, 200, read(v))
}
mux.HandleFunc("POST "+base, s.auth(adminWrite, func(w http.ResponseWriter, r *http.Request) { save(w, r, newID()) }))
mux.HandleFunc("PUT "+base+"/{id}", s.auth(adminWrite, func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.st.get(kind, id, newItem()); err != nil {
fail(w, err)
return
}
// A channel's secret target is never sent to the browser; an unchanged form sends the placeholder.
save(w, r, id)
}))
mux.HandleFunc("DELETE "+base+"/{id}", s.auth(adminWrite, func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if kind == "monitor" {
s.deleteMonitorData(id)
}
fail2(w, s.st.del(kind, id))
}))
}
func redactChannel(v any) any {
c := *(v.(*Channel))
c.Target = redactedTarget // webhook URLs and addresses are secrets; re-enter to change
return c
}
const redactedTarget = "••••••••"
// ---------- session ----------
func (s *Server) getState(w http.ResponseWriter, r *http.Request) {
out := map[string]any{"needsSetup": s.countUsers() == 0, "user": nil, "url": s.opts.URL}
if u := s.authenticate(r); u != nil {
out["user"] = u
}
writeJSON(w, 200, out)
}
func (s *Server) setup(w http.ResponseWriter, r *http.Request) {
var in struct{ Name, Email, Password string }
if !decode(r, &in) {
bad(w, "invalid request")
return
}
if s.countUsers() > 0 {
writeJSON(w, 403, map[string]string{"error": "already set up"})
return
}
if _, err := s.createUser(in.Name, in.Email, in.Password, "admin", ""); err != nil {
bad(w, err.Error())
return
}
u, err := s.login(w, r, in.Email, in.Password)
if err != nil {
bad(w, err.Error())
return
}
writeJSON(w, 200, u)
}
func (s *Server) apiLogin(w http.ResponseWriter, r *http.Request) {
var in struct{ Email, Password string }
if !decode(r, &in) {
bad(w, "invalid request")
return
}
u, err := s.login(w, r, in.Email, in.Password)
if err != nil {
code := 401
if errors.Is(err, errThrottled) {
code = 429
}
writeJSON(w, code, map[string]string{"error": err.Error()})
return
}
writeJSON(w, 200, u)
}
// ---------- people ----------
func (s *Server) createUserHTTP(w http.ResponseWriter, r *http.Request) {
var in struct{ Name, Email, Password, Role, Phone string }
if !decode(r, &in) {
bad(w, "invalid request")
return
}
if in.Role == "" {
in.Role = "member"
}
u, err := s.createUser(in.Name, in.Email, in.Password, in.Role, in.Phone)
if err != nil {
bad(w, err.Error())
return
}
writeJSON(w, 200, u)
}
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == userFrom(r.Context()).ID {
bad(w, "you can't delete yourself")
return
}
res, err := s.st.db.Exec(`DELETE FROM users WHERE id=?`, id)
if err != nil {
fail(w, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
fail(w, errNotFound)
return
}
writeJSON(w, 200, ok)
}
func (s *Server) passwordHTTP(w http.ResponseWriter, r *http.Request) {
me := userFrom(r.Context())
id := r.PathValue("id")
if id != me.ID && me.Role != "admin" {
writeJSON(w, 403, map[string]string{"error": "you can only change your own password"})
return
}
var in struct{ Password string }
if !decode(r, &in) {
bad(w, "invalid request")
return
}
fail2(w, s.setPassword(id, in.Password))
}
func (s *Server) listKeys(w http.ResponseWriter, r *http.Request) {
out := []map[string]any{}
rows, err := s.st.db.Query(`SELECT id,name,created FROM apikeys WHERE user_id=? ORDER BY created`, userFrom(r.Context()).ID)
if err == nil {
defer rows.Close()
for rows.Next() {
var id, name string
var c int64
if rows.Scan(&id, &name, &c) == nil {
out = append(out, map[string]any{"id": id, "name": name, "created": c})
}
}
}
writeJSON(w, 200, out)
}
func (s *Server) createKeyHTTP(w http.ResponseWriter, r *http.Request) {
var in struct{ Name string }
decode(r, &in)
id, tok, err := s.createAPIKey(userFrom(r.Context()).ID, in.Name)
if err != nil {
fail(w, err)
return
}
writeJSON(w, 200, map[string]string{"id": id, "token": tok}) // shown once
}
func (s *Server) deleteKey(w http.ResponseWriter, r *http.Request) {
s.st.db.Exec(`DELETE FROM apikeys WHERE id=? AND user_id=?`, r.PathValue("id"), userFrom(r.Context()).ID)
writeJSON(w, 200, ok)
}
// ---------- topics ----------
func (s *Server) publishHTTP(w http.ResponseWriter, r *http.Request) {
topic := r.PathValue("topic")
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10))
if err != nil {
bad(w, "message too large (limit 64 KB)")
return
}
h := func(names ...string) string {
for _, n := range names {
if v := r.Header.Get(n); v != "" {
return v
}
if v := r.URL.Query().Get(strings.ToLower(n)); v != "" {
return v
}
}
return ""
}
m, err := s.Publish(topic, h("Title", "X-Title", "t"), string(body), parsePriority(h("Priority", "X-Priority", "p")), parseTags(h("Tags", "X-Tags", "ta")))
if err != nil {
bad(w, err.Error())
return
}
m.Event = "message"
writeJSON(w, 200, m)
}
// subscribeHTTP streams a topic as server-sent events (/sse) or JSON lines (/json).
// ?since= replays stored messages first: "all", unix seconds, a duration such as 10m, or the id
// of the last message the client saw. ?poll=1 sends only that replay and then ends, which is
// how the ntfy apps catch up.
func (s *Server) subscribeHTTP(w http.ResponseWriter, r *http.Request) {
topic := r.PathValue("topic")
if !validTopic(topic) {
bad(w, "invalid topic")
return
}
fl, canFlush := w.(http.Flusher)
if !canFlush {
http.Error(w, "streaming not supported", 500)
return
}
sse := strings.HasSuffix(r.URL.Path, "/sse")
if sse {
w.Header().Set("Content-Type", "text/event-stream")
} else {
w.Header().Set("Content-Type", "application/x-ndjson")
}
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
bw := bufio.NewWriter(w)
event := func(kind string) {
b, _ := json.Marshal(map[string]any{"id": newID(), "time": s.now().Unix(), "event": kind, "topic": topic})
fmt.Fprintf(bw, "%s\n", b)
bw.Flush()
fl.Flush()
}
write := func(m Message) bool {
m.Event = "message"
b, _ := json.Marshal(m)
var err error
if sse {
_, err = fmt.Fprintf(bw, "id: %s\ndata: %s\n\n", m.ID, b)
} else {
_, err = fmt.Fprintf(bw, "%s\n", b)
}
bw.Flush()
fl.Flush()
return err == nil
}
// Subscribe before replaying so nothing published in between is missed; duplicates are skipped by id.
ch, unsub := s.hub.subscribe(topic)
defer unsub()
seen := map[string]bool{}
if q := r.URL.Query().Get("since"); q != "" {
for _, m := range s.replaySince(topic, q) {
seen[m.ID] = true
if !write(m) {
return
}
}
}
if r.URL.Query().Get("poll") == "1" {
return
}
if sse {
fmt.Fprint(bw, ": connected\n\n")
bw.Flush()
fl.Flush()
} else {
event("open")
}
keepalive := time.NewTicker(30 * time.Second)
defer keepalive.Stop()
for {
select {
case <-r.Context().Done():
return
case <-s.stop:
return
case m := <-ch:
if seen[m.ID] {
continue
}
if !write(m) {
return
}
case <-keepalive.C:
if sse {
fmt.Fprint(bw, ": keepalive\n\n")
bw.Flush()
fl.Flush()
} else {
event("keepalive")
}
}
}
}
// replaySince returns the stored messages a subscriber should see first for a ?since= value.
func (s *Server) replaySince(topic, q string) []Message {
if q == "all" {
return s.MessagesSince(topic, 0, 500)
}
if n, err := strconv.ParseInt(q, 10, 64); err == nil {
return s.MessagesSince(topic, n, 500)
}
if d, err := time.ParseDuration(q); err == nil {
return s.MessagesSince(topic, s.now().Add(-d).Unix(), 500)
}
// Otherwise it is the id of the last message the client has: send what came after it.
var at int64
if s.st.db.QueryRow(`SELECT time FROM messages WHERE topic=? AND id=?`, topic, q).Scan(&at) != nil {
return nil
}
all := s.MessagesSince(topic, at, 500)
for i, m := range all {
if m.ID == q {
return all[i+1:]
}
}
return all
}
// ---------- alerts in ----------
func (s *Server) inboundAlert(w http.ResponseWriter, r *http.Request) {
var in struct {
Key string `json:"key"`
Summary string `json:"summary"`
Detail string `json:"detail"`
Severity string `json:"severity"`
Policy string `json:"policy"`
Status string `json:"status"` // firing (default) or resolved
Source string `json:"source"`
}
if !decode(r, &in) {
bad(w, "invalid request")
return
}
if in.Key == "" {
in.Key = in.Summary
}
if in.Key == "" {
bad(w, "send a key or a summary")
return
}
if in.Source == "" {
in.Source = "api"
}
in.Key = "ext:" + in.Key
if in.Status == "resolved" {
s.ResolveAlertKey(in.Key, in.Source)
writeJSON(w, 200, ok)
return
}
if in.Summary == "" {
in.Summary = in.Key
}
if in.Policy != "" {
if _, err := getOf[Policy](s.st, "policy", in.Policy); err != nil {
bad(w, "unknown escalation policy")
return
}
}
a, isNew, err := s.RaiseAlert(in.Key, in.Source, in.Summary, in.Detail, in.Severity, in.Policy)
if err != nil {
fail(w, err)
return
}
writeJSON(w, 200, map[string]any{"id": a.ID, "new": isNew})
}
// alertmanager accepts Prometheus Alertmanager's webhook (also Grafana's, which uses the same shape).
// ?policy=<id> sets the escalation policy.
func (s *Server) alertmanager(w http.ResponseWriter, r *http.Request) {
var in struct {
Alerts []struct {
Status string `json:"status"`
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
Fingerprint string `json:"fingerprint"`
} `json:"alerts"`
}
if !decode(r, &in) {
bad(w, "invalid request")
return
}
policy := r.URL.Query().Get("policy")
if policy != "" {
if _, err := getOf[Policy](s.st, "policy", policy); err != nil {
bad(w, "unknown escalation policy")
return
}
}
n := 0
for _, a := range in.Alerts {
key := a.Fingerprint
if key == "" {
key = a.Labels["alertname"] + "/" + a.Labels["instance"]
}
key = "am:" + key
if a.Status == "resolved" {
s.ResolveAlertKey(key, "alertmanager")
continue
}
sev := map[string]string{"critical": "critical", "warning": "warning", "info": "info"}[a.Labels["severity"]]
summary := firstNonEmpty(a.Annotations["summary"], a.Labels["alertname"], key)
if _, _, err := s.RaiseAlert(key, "alertmanager", summary, a.Annotations["description"], sev, policy); err == nil {
n++
}
}
writeJSON(w, 200, map[string]int{"processed": n})
}
// ---------- misc ----------
func (s *Server) declareHTTP(w http.ResponseWriter, r *http.Request) {
var in struct {
Title string `json:"title"`
Severity string `json:"severity"`
Summary string `json:"summary"`
Lead string `json:"lead"`
AlertIDs []string `json:"alertIds"`
}
if !decode(r, &in) {
bad(w, "invalid request")
return
}
me := userFrom(r.Context())
if in.Lead == "" {
in.Lead = me.ID
}
inc, err := s.DeclareIncident(in.Title, in.Severity, in.Summary, in.Lead, me.Name, in.AlertIDs)
if err != nil {
bad(w, err.Error())
return
}
writeJSON(w, 200, inc)
}
func (s *Server) testChannel(w http.ResponseWriter, r *http.Request) {
ch, err := getOf[Channel](s.st, "channel", r.PathValue("id"))
if err != nil {
fail(w, err)
return
}
if err := s.sendChannel(*ch, Note{Status: "test", Kind: "alert", Title: "test", Time: s.now().Unix()}); err != nil {
bad(w, err.Error())
return
}
writeJSON(w, 200, ok)
}
func (s *Server) onCallNow(w http.ResponseWriter, r *http.Request) {
type row struct {
Schedule string `json:"schedule"`
ID string `json:"id"`
UserID string `json:"userId"`
User string `json:"user"`
Next string `json:"next"` // who is on call after the current handover
}
out := []row{}
scs, _ := listOf[Schedule](s.st, "schedule")
now := s.now()
for _, sc := range scs {
sc := sc
uid := sc.OnCall(now)
nx := sc.OnCall(now.Add(time.Duration(sc.RotationHours) * time.Hour))
out = append(out, row{sc.Name, sc.ID, uid, s.userName(uid), s.userName(nx)})
}
writeJSON(w, 200, out)
}
func (s *Server) heartbeat(w http.ResponseWriter, r *http.Request) {
if !s.Heartbeat(r.PathValue("token")) {
http.NotFound(w, r)
return
}
io.WriteString(w, "ok")
}
// statusJSON feeds the public status page: only monitors and incidents marked public.
func (s *Server) statusJSON(w http.ResponseWriter, r *http.Request) {
type mon struct {
Name string `json:"name"`
Status string `json:"status"`
Uptime float64 `json:"uptime"`
Since int64 `json:"since"`
}
type inc struct {
Title string `json:"title"`
Status string `json:"status"`
Severity string `json:"severity"`
Created int64 `json:"created"`
Resolved int64 `json:"resolved"`
Updates []Event `json:"updates"`
}
out := struct {
Monitors []mon `json:"monitors"`
Incidents []inc `json:"incidents"`
Overall string `json:"overall"`
}{[]mon{}, []inc{}, "operational"}
mons, _ := listOf[Monitor](s.st, "monitor")
for _, m := range mons {
if !m.Public || !m.Enabled {
continue
}
st := s.monitorState(m.ID)
out.Monitors = append(out.Monitors, mon{m.Name, st.Status, st.Uptime30, st.Since})
if st.Status == "down" {
out.Overall = "outage"
}
}
incs, _ := listOf[Incident](s.st, "incident")
sortIncidents(incs)
cut := s.now().AddDate(0, 0, -14).Unix()
for _, in := range incs {
if !in.Public || (in.Status == "resolved" && in.Resolved < cut) {
continue
}
var ups []Event
for _, e := range s.st.events("incident", in.ID) {
if e.Kind == "update" || e.Kind == "declared" {
e.Actor = "" // staff names stay off the public page
ups = append(ups, e)
}
}
out.Incidents = append(out.Incidents, inc{in.Title, in.Status, in.Severity, in.Created, in.Resolved, ups})
if in.Status != "resolved" && out.Overall == "operational" {
out.Overall = "degraded"
}
}
w.Header().Set("Cache-Control", "max-age=15")
writeJSON(w, 200, out)
}
func sortIncidents(list []Incident) {
for i := 1; i < len(list); i++ { // newest first; lists are short
for j := i; j > 0 && list[j].Created > list[j-1].Created; j-- {
list[j], list[j-1] = list[j-1], list[j]
}
}
}
func (s *Server) metrics(w http.ResponseWriter, r *http.Request) {
if s.opts.MetricsToken == "" {
http.NotFound(w, r)
return
}
if h := r.Header.Get("Authorization"); h != "Bearer "+s.opts.MetricsToken {
w.Header().Set("WWW-Authenticate", `Bearer realm="metrics"`)
http.Error(w, "unauthorized", 401)
return
}
var b strings.Builder
b.WriteString("# TYPE sandbar_monitor_up gauge\n")
mons, _ := listOf[Monitor](s.st, "monitor")
for _, m := range mons {
v := 0
if s.monitorState(m.ID).Status == "up" {
v = 1
}
fmt.Fprintf(&b, "sandbar_monitor_up{monitor=%q} %d\n", m.Name, v)
}
var firing, acked, open int
s.st.db.QueryRow(`SELECT COUNT(*) FROM alerts WHERE status='firing'`).Scan(&firing)
s.st.db.QueryRow(`SELECT COUNT(*) FROM alerts WHERE status='acked'`).Scan(&acked)
incs, _ := listOf[Incident](s.st, "incident")
for _, in := range incs {
if in.Status != "resolved" {
open++
}
}
fmt.Fprintf(&b, "# TYPE sandbar_alerts gauge\nsandbar_alerts{status=\"firing\"} %d\nsandbar_alerts{status=\"acked\"} %d\n", firing, acked)
fmt.Fprintf(&b, "# TYPE sandbar_incidents_open gauge\nsandbar_incidents_open %d\n", open)
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
io.WriteString(w, b.String())
}