Turn the status page into a full status page generator

Components can be put in groups and given public names. Public incidents carry
posts written for customers (the internal timeline stays internal), the
components they affect, and a page for each incident plus paged history.
Scheduled maintenance has its own window, shows affected components as under
maintenance, and is announced when scheduled, started, ended or canceled.

Branding (title, logo, accent checked for contrast, contact link, footer) and a
custom domain that serves only the status page. Visitors follow by RSS, by
confirmed email, or by webhook that must answer a test request. Admin UI for
all of it, tests, README and design notes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-27 17:05:38 -06:00
1 parent b4fd31a182
commit cf4d5b3ce2
12 files changed
+2403 -147

No files matched your search

+774
View File
@@ -0,0 +1,774 @@
package sandbar
import (
"context"
"errors"
"fmt"
"html"
"math"
"net"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// The public status page. Monitors marked public are its components, arranged in groups. Incidents
// marked public are posted on it, with the updates written for customers. Scheduled maintenance
// has its own window. People follow all of it by RSS, email or webhook (see statussub.go), and the
// page can be served from its own domain.
// StatusSettings is the page's branding and where it lives. One document, kind "statuspage".
type StatusSettings struct {
Title string `json:"title"`
Description string `json:"description"`
Domain string `json:"domain"` // serve the page from this host name as well, such as status.example.com
LogoURL string `json:"logoUrl"` // https address of an image
Accent string `json:"accent"` // #rrggbb, for buttons and links; white text must stay readable on it
SupportURL string `json:"supportUrl"` // "Contact us" link: http(s) or mailto
Footer string `json:"footer"`
}
// StatusGroup is a heading that components sit under.
type StatusGroup struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Order int `json:"order"`
}
// Maintenance is a planned window. Its components show as under maintenance while it runs.
type Maintenance struct {
ID string `json:"id"`
Title string `json:"title"`
Message string `json:"message"`
Start int64 `json:"start"`
End int64 `json:"end"`
Components []string `json:"components"` // monitor ids
Canceled bool `json:"canceled"`
Created int64 `json:"created"`
StartSent bool `json:"startSent"` // subscribers have been told it began
EndSent bool `json:"endSent"` // and that it is over
}
func (m *Maintenance) state(now time.Time) string {
switch t := now.Unix(); {
case m.Canceled:
return "canceled"
case t < m.Start:
return "scheduled"
case t < m.End:
return "in_progress"
}
return "completed"
}
var (
hostRe = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$`)
accentRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
)
// contrastWithWhite is the WCAG contrast ratio of a #rrggbb color against white.
func contrastWithWhite(hex string) float64 {
v, _ := strconv.ParseUint(hex[1:], 16, 32)
lin := func(c uint64) float64 {
f := float64(c) / 255
if f <= 0.03928 {
return f / 12.92
}
return math.Pow((f+0.055)/1.055, 2.4)
}
l := 0.2126*lin(v>>16&255) + 0.7152*lin(v>>8&255) + 0.0722*lin(v&255)
return 1.05 / (l + 0.05)
}
func validateStatusSettings(c *StatusSettings, ownURL string) error {
c.Title, c.Description, c.Footer = strings.TrimSpace(c.Title), strings.TrimSpace(c.Description), strings.TrimSpace(c.Footer)
c.Domain, c.LogoURL, c.SupportURL, c.Accent = strings.ToLower(strings.TrimSpace(c.Domain)), strings.TrimSpace(c.LogoURL), strings.TrimSpace(c.SupportURL), strings.TrimSpace(c.Accent)
if c.Title == "" {
c.Title = "Status"
}
if len(c.Title) > 80 || len(c.Description) > 300 || len(c.Footer) > 300 {
return errors.New("the title can be 80 characters, the description and footer 300")
}
if c.Domain != "" {
if !hostRe.MatchString(c.Domain) {
return errors.New("the domain must be a host name such as status.example.com, with no https:// or path")
}
if u, err := url.Parse(ownURL); err == nil && strings.EqualFold(u.Hostname(), c.Domain) {
return errors.New("that is Sandbar's own address; use a separate host name for the status page")
}
}
if c.LogoURL != "" {
if u, err := url.Parse(c.LogoURL); err != nil || u.Scheme != "https" || u.Host == "" || len(c.LogoURL) > 500 {
return errors.New("the logo must be an https:// address")
}
}
if c.SupportURL != "" {
u, err := url.Parse(c.SupportURL)
if err != nil || len(c.SupportURL) > 500 || !(((u.Scheme == "http" || u.Scheme == "https") && u.Host != "") || (u.Scheme == "mailto" && u.Opaque != "")) {
return errors.New("the support link must be an http(s):// address or a mailto: link")
}
}
if c.Accent != "" {
if !accentRe.MatchString(c.Accent) {
return errors.New("the accent color must look like #1f5c5c")
}
c.Accent = strings.ToLower(c.Accent)
if r := contrastWithWhite(c.Accent); r < 4.5 {
return fmt.Errorf("white text is hard to read on that color (contrast %.1f to 1, needs 4.5 to 1); pick a darker one", r)
}
}
return nil
}
func (s *Server) statusSettings() StatusSettings {
s.statusMu.Lock()
defer s.statusMu.Unlock()
if s.statusCfg == nil {
c, err := getOf[StatusSettings](s.st, "statuspage", "main")
if err != nil {
c = &StatusSettings{}
}
if c.Title == "" {
c.Title = "Status"
}
s.statusCfg = c
}
return *s.statusCfg
}
func (s *Server) saveStatusSettings(c StatusSettings) error {
if err := s.st.put("statuspage", "main", c); err != nil {
return err
}
s.statusMu.Lock()
s.statusCfg = nil
s.statusMu.Unlock()
return nil
}
// ---------- where the page lives ----------
type statusHostKey struct{}
// statusHost serves the status page, and nothing else, to requests for the custom domain: the
// path is moved under /status, so "/" is the page, "/history" its history, and the admin
// interface and API are not reachable from there.
func (s *Server) statusHost(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
d := s.statusSettings().Domain
if d == "" || !strings.EqualFold(requestHost(r, s.opts.TrustProxy), d) {
next.ServeHTTP(w, r)
return
}
r2 := r.WithContext(context.WithValue(r.Context(), statusHostKey{}, true))
r2.URL = new(url.URL)
*r2.URL = *r.URL
switch p := r.URL.Path; {
case p == "/status.json" || strings.HasPrefix(p, "/status/"):
case p == "/" || p == "/status":
r2.URL.Path = "/status"
default:
r2.URL.Path = "/status" + p
}
next.ServeHTTP(w, r2)
})
}
func requestHost(r *http.Request, trustProxy bool) string {
h := r.Host
if trustProxy {
if f := r.Header.Get("X-Forwarded-Host"); f != "" {
h = strings.TrimSpace(strings.Split(f, ",")[0])
}
}
if host, _, err := net.SplitHostPort(h); err == nil {
h = host
}
return strings.ToLower(h)
}
// pagePrefix is what page links start with: nothing on the custom domain, /status elsewhere.
func pagePrefix(r *http.Request) string {
if on, _ := r.Context().Value(statusHostKey{}).(bool); on {
return ""
}
return "/status"
}
// absStatusURL is a full address for a page path such as "/history", for emails and feeds.
// r may be nil, as in a background notification.
func (s *Server) absStatusURL(r *http.Request, path string) string {
if d := s.statusSettings().Domain; d != "" && (r == nil || pagePrefix(r) == "") {
return "https://" + d + path
}
base := s.opts.URL
if base == "" && r != nil {
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" && s.opts.TrustProxy {
scheme = "https"
}
base = scheme + "://" + r.Host
}
return strings.TrimRight(base, "/") + "/status" + path
}
// ---------- the public view ----------
type pubComponent struct {
ID string `json:"id"`
Name string `json:"name"`
Group string `json:"group"`
Status string `json:"status"` // the monitor: up, down, pending
State string `json:"state"` // what the page says: operational, degraded, partial_outage, major_outage, maintenance, pending
Uptime float64 `json:"uptime"`
Since int64 `json:"since"`
Days [][2]int `json:"days"` // [checks, failed] per UTC day, oldest first, ending today
}
type pubPost struct {
Time int64 `json:"time"`
Status string `json:"status"`
Text string `json:"text"`
}
type pubIncident struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Severity string `json:"severity"`
Created int64 `json:"created"`
Resolved int64 `json:"resolved"`
Components []string `json:"components"` // names
Updates []pubPost `json:"updates"` // newest first
}
type pubMaintenance struct {
ID string `json:"id"`
Title string `json:"title"`
Message string `json:"message"`
Start int64 `json:"start"`
End int64 `json:"end"`
State string `json:"state"`
Components []string `json:"components"` // names
}
// worse ranks component states; a higher number is shown over a lower one.
var stateRank = map[string]int{"operational": 0, "pending": 0, "maintenance": 1, "degraded": 2, "partial_outage": 3, "major_outage": 4}
func (s *Server) publicComponents() (comps []pubComponent, groups []StatusGroup, names map[string]string) {
groups, _ = listOf[StatusGroup](s.st, "sgroup")
sort.SliceStable(groups, func(i, j int) bool {
if groups[i].Order != groups[j].Order {
return groups[i].Order < groups[j].Order
}
return strings.ToLower(groups[i].Name) < strings.ToLower(groups[j].Name)
})
rank := map[string]int{"": -1}
for i, g := range groups {
rank[g.ID] = i
}
mons, _ := listOf[Monitor](s.st, "monitor")
names = map[string]string{}
now := s.now()
for _, m := range mons {
if !m.Public || !m.Enabled {
continue
}
name := firstNonEmpty(strings.TrimSpace(m.PublicName), m.Name)
names[m.ID] = name
if _, ok := rank[m.Group]; !ok {
m.Group = "" // its group was deleted
}
st := s.monitorState(m.ID)
days := s.dailyUptime(m.ID, uptimeDays, now)
var checks, fails int
for _, d := range days {
checks, fails = checks+d[0], fails+d[1]
}
uptime := -1.0 // no checks yet
if checks > 0 {
uptime = float64(checks-fails) * 100 / float64(checks)
}
state := map[string]string{"up": "operational", "down": "major_outage"}[st.Status]
if state == "" {
state = "pending"
}
comps = append(comps, pubComponent{ID: m.ID, Name: name, Group: m.Group, Status: st.Status, State: state, Uptime: uptime, Since: st.Since, Days: days})
}
sort.SliceStable(comps, func(i, j int) bool {
if ri, rj := rank[comps[i].Group], rank[comps[j].Group]; ri != rj {
return ri < rj
}
return strings.ToLower(comps[i].Name) < strings.ToLower(comps[j].Name)
})
return
}
func (s *Server) publicIncident(in Incident, names map[string]string) pubIncident {
out := pubIncident{ID: in.ID, Title: in.Title, Status: in.Status, Severity: in.Severity, Created: in.Created, Resolved: in.Resolved, Components: []string{}, Updates: []pubPost{}}
for _, id := range in.Components {
if n, ok := names[id]; ok {
out.Components = append(out.Components, n)
}
}
for _, e := range s.st.events("incident", in.ID) {
if e.Kind == "post" {
out.Updates = append(out.Updates, pubPost{Time: e.Time, Status: e.Status, Text: e.Text})
}
}
if len(out.Updates) == 0 { // public, but nothing posted yet: show what we know
out.Updates = append(out.Updates, pubPost{Time: in.Created, Status: "investigating", Text: firstNonEmpty(in.Summary, defaultPost("investigating"))})
}
for i, j := 0, len(out.Updates)-1; i < j; i, j = i+1, j-1 { // newest first
out.Updates[i], out.Updates[j] = out.Updates[j], out.Updates[i]
}
return out
}
func (s *Server) publicMaintenance(m Maintenance, names map[string]string, now time.Time) pubMaintenance {
out := pubMaintenance{ID: m.ID, Title: m.Title, Message: m.Message, Start: m.Start, End: m.End, State: m.state(now), Components: []string{}}
for _, id := range m.Components {
if n, ok := names[id]; ok {
out.Components = append(out.Components, n)
}
}
return out
}
func (s *Server) loadMaintenance() []Maintenance {
list, _ := listOf[Maintenance](s.st, "maintenance")
sort.Slice(list, func(i, j int) bool { return list[i].Start > list[j].Start })
return list
}
// statusJSON feeds the public status page: only monitors, incidents and maintenance marked public.
func (s *Server) statusJSON(w http.ResponseWriter, r *http.Request) {
now := s.now()
cfg := s.statusSettings()
comps, groups, names := s.publicComponents()
if groups == nil {
groups = []StatusGroup{}
}
if comps == nil {
comps = []pubComponent{}
}
idx := map[string]int{}
for i, c := range comps {
idx[c.ID] = i
}
raise := func(id, state string) {
if i, ok := idx[id]; ok && stateRank[state] > stateRank[comps[i].State] {
comps[i].State = state
}
}
out := struct {
Settings StatusSettings `json:"settings"`
Monitors []pubComponent `json:"monitors"`
Groups []StatusGroup `json:"groups"`
Incidents []pubIncident `json:"incidents"`
Maintenance []pubMaintenance `json:"maintenance"`
Overall string `json:"overall"`
Today string `json:"today"` // the UTC date of the last bar
Subscribe map[string]bool `json:"subscribe"`
}{Settings: cfg, Groups: groups, Incidents: []pubIncident{}, Maintenance: []pubMaintenance{}, Today: now.UTC().Format("2006-01-02"),
Subscribe: map[string]bool{"email": s.mailEnabled(), "webhook": true, "rss": true}}
out.Settings.Domain = "" // not for visitors
overall := "operational"
lift := func(level string) {
if level == "outage" || overall == "outage" {
overall = "outage"
} else if level == "degraded" || overall == "degraded" {
overall = "degraded"
} else if level == "maintenance" {
overall = "maintenance"
}
}
incs, _ := listOf[Incident](s.st, "incident")
sortIncidents(incs)
cut := now.AddDate(0, 0, -14).Unix()
for _, in := range incs {
if !in.Public || (in.Status == "resolved" && in.Resolved < cut) {
continue
}
out.Incidents = append(out.Incidents, s.publicIncident(in, names))
if in.Status == "resolved" {
continue
}
impact := map[string]string{"sev1": "major_outage", "sev2": "partial_outage"}[in.Severity]
if impact == "" {
impact = "degraded"
}
for _, id := range in.Components {
raise(id, impact)
}
if impact == "major_outage" && len(in.Components) > 0 {
lift("outage")
} else {
lift("degraded")
}
}
for _, m := range s.loadMaintenance() {
st := m.state(now)
if st == "canceled" || st == "completed" {
continue
}
out.Maintenance = append(out.Maintenance, s.publicMaintenance(m, names, now))
if st == "in_progress" {
for _, id := range m.Components {
if i, ok := idx[id]; ok {
comps[i].State = "maintenance" // planned work explains a failing check
}
}
lift("maintenance")
}
}
sort.SliceStable(out.Maintenance, func(i, j int) bool { return out.Maintenance[i].Start < out.Maintenance[j].Start })
for _, c := range comps {
switch c.State {
case "major_outage":
lift("outage")
case "partial_outage", "degraded":
lift("degraded")
}
}
out.Monitors, out.Overall = comps, overall
w.Header().Set("Cache-Control", "max-age=15")
writeJSON(w, 200, out)
}
type historyEntry struct {
Type string `json:"type"` // incident or maintenance
Time int64 `json:"time"`
Incident *pubIncident `json:"incident,omitempty"`
Maintenance *pubMaintenance `json:"maintenance,omitempty"`
}
// historyJSON lists everything that was ever public, newest first, a page at a time.
// before is the time of the last entry of the previous page.
func (s *Server) historyJSON(w http.ResponseWriter, r *http.Request) {
before, _ := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64)
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 100 {
limit = 30
}
now := s.now()
_, _, names := s.publicComponents()
var all []historyEntry
incs, _ := listOf[Incident](s.st, "incident")
for _, in := range incs {
if in.Public {
p := s.publicIncident(in, names)
all = append(all, historyEntry{Type: "incident", Time: in.Created, Incident: &p})
}
}
for _, m := range s.loadMaintenance() {
if st := m.state(now); st == "completed" || st == "in_progress" {
p := s.publicMaintenance(m, names, now)
all = append(all, historyEntry{Type: "maintenance", Time: m.Start, Maintenance: &p})
}
}
sort.SliceStable(all, func(i, j int) bool { return all[i].Time > all[j].Time })
if before > 0 {
k := sort.Search(len(all), func(i int) bool { return all[i].Time < before })
all = all[k:]
}
next := int64(0)
if len(all) > limit {
all = all[:limit]
next = all[limit-1].Time
}
if all == nil {
all = []historyEntry{}
}
w.Header().Set("Cache-Control", "max-age=15")
writeJSON(w, 200, map[string]any{"entries": all, "next": next})
}
func (s *Server) incidentJSON(w http.ResponseWriter, r *http.Request) {
in, err := getOf[Incident](s.st, "incident", r.PathValue("id"))
if err != nil || !in.Public {
writeJSON(w, 404, map[string]string{"error": "not found"})
return
}
_, _, names := s.publicComponents()
w.Header().Set("Cache-Control", "max-age=15")
writeJSON(w, 200, s.publicIncident(*in, names))
}
// servePage fills the page shell with the title and the prefix its links start with.
func (s *Server) serveStatusPage(w http.ResponseWriter, r *http.Request) {
page := strings.NewReplacer("__TITLE__", html.EscapeString(s.statusSettings().Title), "__BASE__", pagePrefix(r)).Replace(string(statusHTML))
servePage(w, []byte(page))
}
func (s *Server) statusRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /status", s.serveStatusPage)
mux.HandleFunc("GET /status/history", s.serveStatusPage)
mux.HandleFunc("GET /status/incidents/{id}", s.serveStatusPage)
mux.HandleFunc("GET /status.json", s.statusJSON)
mux.HandleFunc("GET /status/api/history", s.historyJSON)
mux.HandleFunc("GET /status/api/incidents/{id}", s.incidentJSON)
mux.HandleFunc("GET /status/feed.rss", s.statusFeed)
mux.HandleFunc("POST /status/subscribe", s.followStatusHTTP)
mux.HandleFunc("GET /status/confirm", s.confirmSubscription)
mux.HandleFunc("GET /status/unsubscribe", s.unsubscribeRedirect)
mux.HandleFunc("POST /status/unsubscribe", s.unsubscribeHTTP)
// administration
mux.HandleFunc("GET /api/v1/statuspage", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"settings": s.statusSettings(), "pageUrl": s.absStatusURL(nil, ""), "feedUrl": s.absStatusURL(nil, "/feed.rss"), "email": s.mailEnabled()})
}))
mux.HandleFunc("PUT /api/v1/statuspage", s.auth(true, func(w http.ResponseWriter, r *http.Request) {
var c StatusSettings
if !decode(r, &c) {
bad(w, "invalid request")
return
}
if err := validateStatusSettings(&c, s.opts.URL); err != nil {
bad(w, err.Error())
return
}
if err := s.saveStatusSettings(c); err != nil {
fail(w, err)
return
}
writeJSON(w, 200, map[string]any{"settings": c, "pageUrl": s.absStatusURL(nil, ""), "feedUrl": s.absStatusURL(nil, "/feed.rss"), "email": s.mailEnabled()})
}))
s.crud(mux, "status-groups", "sgroup", true, func() any { return &StatusGroup{} }, func(v any, id string) error {
g := v.(*StatusGroup)
g.ID, g.Name, g.Description = id, strings.TrimSpace(g.Name), strings.TrimSpace(g.Description)
if g.Name == "" {
return errors.New("give the group a name")
}
return nil
}, nil)
s.maintenanceRoutes(mux)
mux.HandleFunc("GET /api/v1/status-subscribers", s.auth(true, s.listSubscribers))
mux.HandleFunc("DELETE /api/v1/status-subscribers/{id}", s.auth(true, func(w http.ResponseWriter, r *http.Request) {
res, err := s.st.db.Exec(`DELETE FROM subscribers WHERE id=?`, r.PathValue("id"))
if n, _ := res.RowsAffected(); err == nil && n == 0 {
err = errNotFound
}
fail2(w, err)
}))
}
// ---------- incident posts ----------
func defaultPost(status string) string {
return map[string]string{
"investigating": "We are investigating this issue.",
"identified": "The cause has been identified and we are working on a fix.",
"monitoring": "A fix has been applied and we are monitoring the results.",
"resolved": "This incident has been resolved.",
}[status]
}
func cleanIDs(ids []string) []string {
out, seen := []string{}, map[string]bool{}
for _, id := range ids {
if id = strings.TrimSpace(id); id != "" && !seen[id] {
seen[id] = true
out = append(out, id)
}
}
return out
}
// postToStatusPage publishes an update of a public incident and tells subscribers. An update is a
// change of status or a note; making an incident public posts what is known so far. The note is
// the only text customers see from an update: the internal timeline stays internal.
func (s *Server) postToStatusPage(in *Incident, wasPublic, statusChanged bool, note string, now time.Time) {
if !in.Public {
return
}
note = strings.TrimSpace(note)
var text string
event := "incident.updated"
switch {
case !wasPublic:
text, event = firstNonEmpty(note, in.Summary, defaultPost(in.Status)), "incident.created"
if in.Status == "resolved" {
event = "incident.resolved"
}
case statusChanged || note != "":
text = firstNonEmpty(note, defaultPost(in.Status))
if statusChanged && in.Status == "resolved" {
event = "incident.resolved"
}
default:
return
}
s.st.addPost(in.ID, in.Status, text, now)
s.notifySubscribers(statusNote{Event: event, Title: in.Title, Status: in.Status, Message: text, URL: s.absStatusURL(nil, "/incidents/"+in.ID), Time: now.Unix()})
}
// ---------- scheduled maintenance ----------
type maintView struct {
Maintenance
State string `json:"state"`
}
func (s *Server) validateMaintenance(m *Maintenance, creating bool) error {
m.Title, m.Message = strings.TrimSpace(m.Title), strings.TrimSpace(m.Message)
if m.Title == "" {
return errors.New("give the maintenance a title")
}
if len(m.Title) > 120 || len(m.Message) > 2000 {
return errors.New("the title can be 120 characters and the details 2000")
}
if m.Start <= 0 || m.End <= m.Start {
return errors.New("the maintenance must end after it starts")
}
if creating && m.End <= s.now().Unix() {
return errors.New("the maintenance window is already over")
}
m.Components = cleanIDs(m.Components)
return nil
}
func (s *Server) maintenanceRoutes(mux *http.ServeMux) {
view := func(m Maintenance) maintView { return maintView{m, m.state(s.now())} }
mux.HandleFunc("GET /api/v1/maintenance", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
out := []maintView{}
for _, m := range s.loadMaintenance() {
out = append(out, view(m))
}
writeJSON(w, 200, out)
}))
mux.HandleFunc("POST /api/v1/maintenance", s.auth(true, func(w http.ResponseWriter, r *http.Request) {
var m Maintenance
if !decode(r, &m) {
bad(w, "invalid request")
return
}
if err := s.validateMaintenance(&m, true); err != nil {
bad(w, err.Error())
return
}
m.ID, m.Created, m.Canceled, m.StartSent, m.EndSent = newID(), s.now().Unix(), false, false, false
if err := s.st.put("maintenance", m.ID, m); err != nil {
fail(w, err)
return
}
if m.Start > s.now().Unix() {
s.announceMaintenance("maintenance.scheduled", m)
}
writeJSON(w, 200, view(m))
}))
mux.HandleFunc("PUT /api/v1/maintenance/{id}", s.auth(true, func(w http.ResponseWriter, r *http.Request) {
old, err := getOf[Maintenance](s.st, "maintenance", r.PathValue("id"))
if err != nil {
fail(w, err)
return
}
m := *old
var in Maintenance
if !decode(r, &in) {
bad(w, "invalid request")
return
}
m.Title, m.Message, m.Start, m.End, m.Components = in.Title, in.Message, in.Start, in.End, in.Components
if err := s.validateMaintenance(&m, false); err != nil {
bad(w, err.Error())
return
}
if m.Canceled || m.state(s.now()) == "completed" && old.EndSent {
bad(w, "that maintenance is over; schedule a new one")
return
}
moved := m.Start != old.Start || m.End != old.End
if moved && m.Start > s.now().Unix() {
m.StartSent, m.EndSent = false, false
}
if err := s.st.put("maintenance", m.ID, m); err != nil {
fail(w, err)
return
}
if moved && !m.StartSent {
s.announceMaintenance("maintenance.rescheduled", m)
}
writeJSON(w, 200, view(m))
}))
mux.HandleFunc("POST /api/v1/maintenance/{id}/cancel", s.auth(true, func(w http.ResponseWriter, r *http.Request) {
m, err := getOf[Maintenance](s.st, "maintenance", r.PathValue("id"))
if err != nil {
fail(w, err)
return
}
if m.Canceled || m.EndSent {
bad(w, "that maintenance is already over")
return
}
m.Canceled = true
if err := s.st.put("maintenance", m.ID, m); err != nil {
fail(w, err)
return
}
s.announceMaintenance("maintenance.canceled", *m)
writeJSON(w, 200, view(*m))
}))
mux.HandleFunc("DELETE /api/v1/maintenance/{id}", s.auth(true, func(w http.ResponseWriter, r *http.Request) {
fail2(w, s.st.del("maintenance", r.PathValue("id")))
}))
}
func (s *Server) announceMaintenance(event string, m Maintenance) {
when := fmtWindow(m.Start, m.End)
msg := map[string]string{
"maintenance.scheduled": "Scheduled maintenance: " + when + ".",
"maintenance.rescheduled": "The maintenance window has changed: " + when + ".",
"maintenance.started": "Maintenance has started and is planned to finish by " + time.Unix(m.End, 0).UTC().Format("15:04 UTC") + ".",
"maintenance.completed": "Maintenance is complete.",
"maintenance.canceled": "This maintenance has been canceled.",
}[event]
if m.Message != "" && event != "maintenance.completed" {
msg += "\n\n" + m.Message
}
status := strings.TrimPrefix(event, "maintenance.")
s.notifySubscribers(statusNote{Event: event, Title: m.Title, Status: status, Message: msg, URL: s.absStatusURL(nil, ""), Time: s.now().Unix()})
}
func fmtWindow(from, to int64) string {
f, t := time.Unix(from, 0).UTC(), time.Unix(to, 0).UTC()
if f.YearDay() == t.YearDay() && f.Year() == t.Year() {
return f.Format("Jan 2, 2006, 15:04") + " to " + t.Format("15:04 UTC")
}
return f.Format("Jan 2, 2006, 15:04") + " to " + t.Format("Jan 2, 2006, 15:04 UTC")
}
// runMaintenance tells subscribers when windows begin and end.
func (s *Server) runMaintenance(now time.Time) {
for _, m := range s.loadMaintenance() {
if m.Canceled || m.EndSent {
continue
}
t, changed := now.Unix(), false
if !m.StartSent && t >= m.Start {
m.StartSent, changed = true, true
if t < m.End { // a window missed entirely, such as while Sandbar was down, is not announced
s.announceMaintenance("maintenance.started", m)
} else {
m.EndSent = true
}
}
if t >= m.End && !m.EndSent {
m.EndSent, changed = true, true
s.announceMaintenance("maintenance.completed", m)
}
if changed {
s.st.put("maintenance", m.ID, m)
}
}
}