Files
Hammerhead/alerts_api.go
T
VeillaxandClaude Opus 5.5 c585ddbf63
CI / test (push) Successful in 3m20s
Release / release (push) Successful in 5m6s
Review pass on the Lightwell redesign: layout, copy and behavior fixes
Dialog buttons are Cancel then the primary action at the bottom right, and each page has one primary
action. List pages no longer repeat their title. Dates have the month written out. Fields in a row
line up, hints sit under fields instead of inside labels, and long certificate errors are clamped.
Organizations show whether local access is on and name the action; setting a user's password uses a
dialog instead of a browser prompt. Server errors for sign-in and ranges are reworded with a next step,
and the stray scrollbar inside tables is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:38:56 -06:00

204 lines
6.1 KiB
Go

package hammerhead
import (
"errors"
"net/http"
"net/url"
"sort"
"time"
)
// The same handlers serve an organization's channels and the server-level ones
// (for edges, which belong to nobody); only the scope differs.
type alertScope struct {
org string // "" = server level
trusted bool // may allow private webhook targets
actor string
}
type channelView struct {
AlertChannel
Preview string `json:"preview"`
}
func preview(ch AlertChannel) string {
if ch.Kind != "webhook" {
return ch.Target
}
if u, err := url.Parse(ch.Target); err == nil {
return u.Scheme + "://" + u.Host + "/…"
}
return "webhook"
}
type alertRow struct {
Key string `json:"key"`
Kind string `json:"kind"`
Name string `json:"name"`
Message string `json:"message"`
Since time.Time `json:"since"`
Fired bool `json:"fired"`
}
func (s *Server) alertOverview(w http.ResponseWriter, sc alertScope) {
s.mu.Lock()
chans := []channelView{}
for _, c := range s.st.Channels {
if c.OrgID == sc.org {
v := channelView{AlertChannel: *c, Preview: preview(*c)}
v.Target = "" // webhook URLs often contain tokens
if c.Kind == "email" {
v.Target = c.Target
}
chans = append(chans, v)
}
}
active := []alertRow{}
for _, a := range s.alerts {
if a.OrgID == sc.org {
active = append(active, alertRow{a.Key, a.Kind, a.Name, a.Message, a.Since, a.Fired})
}
}
sort.Slice(active, func(i, j int) bool { return active[i].Since.Before(active[j].Since) })
settings := map[string]int{"debounceMin": 2, "repeatHours": 0}
if o := s.findOrg(sc.org); o != nil {
if o.AlertDebounceMin > 0 {
settings["debounceMin"] = o.AlertDebounceMin
}
settings["repeatHours"] = o.AlertRepeatHours
}
mailOK := s.st.Settings.SMTP.Host != ""
s.mu.Unlock()
writeJSON(w, 200, map[string]any{"channels": chans, "active": active, "history": s.db.queryAlertEvents(sc.org, 100), "settings": settings, "emailConfigured": mailOK})
}
func (s *Server) saveChannel(w http.ResponseWriter, r *http.Request, sc alertScope) {
var in AlertChannel
if !decode(w, r, &in) {
return
}
s.mu.Lock()
defer s.mu.Unlock()
id := r.PathValue("id")
var existing *AlertChannel
if id != "" {
for _, c := range s.st.Channels {
if c.ID == id && c.OrgID == sc.org {
existing = c
}
}
if existing == nil {
fail(w, 404, errors.New("no such channel"))
return
}
if in.Target == "" {
in.Target = existing.Target // blank keeps the saved URL/address
}
}
trusted := sc.trusted
if err := s.validateChannel(&in, trusted); err != nil {
fail(w, 400, err)
return
}
in.OrgID = sc.org
if existing != nil {
in.ID = id
*existing = in
} else {
in.ID = newID()
s.st.Channels = append(s.st.Channels, &in)
}
s.save()
s.audit(sc.org, sc.actor, "alert.channel.save", in.Name)
ok(w)
}
func (s *Server) deleteChannel(w http.ResponseWriter, r *http.Request, sc alertScope) {
id := r.PathValue("id")
s.mu.Lock()
defer s.mu.Unlock()
s.st.Channels = filter(s.st.Channels, func(c *AlertChannel) bool { return !(c.ID == id && c.OrgID == sc.org) })
s.save()
ok(w)
}
func (s *Server) testChannel(w http.ResponseWriter, r *http.Request, sc alertScope) {
id := r.PathValue("id")
s.mu.Lock()
var ch *AlertChannel
for _, c := range s.st.Channels {
if c.ID == id && c.OrgID == sc.org {
cp := *c
ch = &cp
}
}
name := "server"
if o := s.findOrg(sc.org); o != nil {
name = o.Name
}
s.mu.Unlock()
if ch == nil {
fail(w, 404, errors.New("no such channel"))
return
}
now := time.Now()
if err := s.send(*ch, alertNote{Status: "test", Org: name, Kind: "test", Name: "Test alert", Message: "test", Since: now, Time: now}); err != nil {
fail(w, 400, err)
return
}
ok(w)
}
func (s *Server) alertSettings(w http.ResponseWriter, r *http.Request, sc alertScope) {
var in struct{ DebounceMin, RepeatHours int }
if !decode(w, r, &in) {
return
}
if in.DebounceMin < 1 || in.DebounceMin > 120 || in.RepeatHours < 0 || in.RepeatHours > 168 {
fail(w, 400, errors.New("Wait 1 to 120 minutes before alerting, and remind every 0 to 168 hours (0 means no reminders)."))
return
}
s.mu.Lock()
defer s.mu.Unlock()
o := s.findOrg(sc.org)
if o == nil {
fail(w, 404, errors.New("organization settings only"))
return
}
o.AlertDebounceMin, o.AlertRepeatHours = in.DebounceMin, in.RepeatHours
s.save()
ok(w)
}
// wrappers: an organization's admins manage its channels; server admins manage the server-level ones
func (s *Server) orgAlertScope(c *orgCtx) alertScope {
return alertScope{org: c.Org.ID, trusted: c.Org.AllowLocal || c.User.ServerAdmin, actor: c.Actor}
}
func (s *Server) withOrgAlerts(fn func(http.ResponseWriter, *http.Request, alertScope)) http.HandlerFunc {
return s.withOrg(true, func(w http.ResponseWriter, r *http.Request, c *orgCtx) { fn(w, r, s.orgAlertScope(c)) })
}
func (s *Server) withServerAlerts(fn func(http.ResponseWriter, *http.Request, alertScope)) http.HandlerFunc {
return s.withServerAdmin(func(w http.ResponseWriter, r *http.Request, p *principal) {
fn(w, r, alertScope{org: "", trusted: true, actor: p.User.Email})
})
}
func (s *Server) registerAlertRoutes(mux *http.ServeMux) {
overview := func(w http.ResponseWriter, r *http.Request, sc alertScope) { s.alertOverview(w, sc) }
mux.HandleFunc("GET /api/orgs/{org}/alerts", s.withOrgAlerts(overview))
mux.HandleFunc("POST /api/orgs/{org}/alerts/channels", s.withOrgAlerts(s.saveChannel))
mux.HandleFunc("PUT /api/orgs/{org}/alerts/channels/{id}", s.withOrgAlerts(s.saveChannel))
mux.HandleFunc("DELETE /api/orgs/{org}/alerts/channels/{id}", s.withOrgAlerts(s.deleteChannel))
mux.HandleFunc("POST /api/orgs/{org}/alerts/channels/{id}/test", s.withOrgAlerts(s.testChannel))
mux.HandleFunc("PUT /api/orgs/{org}/alerts/settings", s.withOrgAlerts(s.alertSettings))
mux.HandleFunc("GET /api/admin/alerts", s.withServerAlerts(overview))
mux.HandleFunc("POST /api/admin/alerts/channels", s.withServerAlerts(s.saveChannel))
mux.HandleFunc("PUT /api/admin/alerts/channels/{id}", s.withServerAlerts(s.saveChannel))
mux.HandleFunc("DELETE /api/admin/alerts/channels/{id}", s.withServerAlerts(s.deleteChannel))
mux.HandleFunc("POST /api/admin/alerts/channels/{id}/test", s.withServerAlerts(s.testChannel))
}