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>
204 lines
6.1 KiB
Go
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))
|
|
}
|