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) } } }