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>
149 lines
3.8 KiB
Go
149 lines
3.8 KiB
Go
package sandbar
|
|
|
|
import (
|
|
_ "embed"
|
|
"log"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
//go:embed web/index.html
|
|
var indexHTML []byte
|
|
|
|
//go:embed web/status.html
|
|
var statusHTML []byte
|
|
|
|
type SMTPConfig struct{ Host, Port, User, Password, From string }
|
|
|
|
// Options configures a Server. Zero values are sensible defaults.
|
|
type Options struct {
|
|
DataDir string
|
|
URL string // public URL, used for links in notifications
|
|
MasterKey string
|
|
MetricsToken string
|
|
SMTP SMTPConfig
|
|
MessageDays int // how long topic messages are kept (default 30)
|
|
CheckDays int // how long monitor results are kept (default 30)
|
|
HitDays int // how long website analytics are kept (default 365)
|
|
TrustProxy bool // take the client address from X-Forwarded-For, for use behind a reverse proxy
|
|
|
|
// Self-update (see update.go). Version is the running release, such as v0.2.0; anything else
|
|
// ("dev") never updates. Restart is called after a new binary is installed and should close
|
|
// what the process holds open before re-executing it.
|
|
Version string
|
|
AutoUpdate bool
|
|
UpdateServer string // default https://git.veillax.com
|
|
UpdateRepo string // default Veillax/sandbar
|
|
Restart func(exe string) error
|
|
|
|
// Tests replace these.
|
|
Now func() time.Time
|
|
HTTPClient *http.Client
|
|
Mailer func(to, subject, body string) error
|
|
}
|
|
|
|
type Server struct {
|
|
opts Options
|
|
st *Store
|
|
hub *hub
|
|
|
|
// monitor runtime state
|
|
mu sync.Mutex
|
|
running map[string]bool // monitor ids with a check in flight
|
|
|
|
loginMu sync.Mutex
|
|
loginTry map[string][]time.Time
|
|
|
|
upd updateState
|
|
|
|
anaMu sync.Mutex // website analytics: the visitor salt and a per-address rate limit
|
|
anaSecret string
|
|
anaTry map[string][]time.Time
|
|
|
|
statusMu sync.Mutex // the status page settings cache and the signing secret
|
|
statusCfg *StatusSettings
|
|
statusKey []byte
|
|
subMu sync.Mutex // per-address limit on public subscribe requests
|
|
subTry map[string][]time.Time
|
|
notifyWG sync.WaitGroup // status page notifications in flight
|
|
|
|
stop chan struct{}
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
func New(opts Options, st *Store) *Server {
|
|
if opts.MessageDays <= 0 {
|
|
opts.MessageDays = 30
|
|
}
|
|
if opts.CheckDays <= 0 {
|
|
opts.CheckDays = 30
|
|
}
|
|
if opts.HitDays <= 0 {
|
|
opts.HitDays = 365
|
|
}
|
|
return &Server{opts: opts, st: st, hub: newHub(), running: map[string]bool{}, loginTry: map[string][]time.Time{}, anaTry: map[string][]time.Time{}, subTry: map[string][]time.Time{}, stop: make(chan struct{})}
|
|
}
|
|
|
|
func (s *Server) now() time.Time {
|
|
if s.opts.Now != nil {
|
|
return s.opts.Now()
|
|
}
|
|
return time.Now()
|
|
}
|
|
|
|
// Start runs the background workers: monitor scheduler, escalation, pruning.
|
|
func (s *Server) Start() {
|
|
s.every(time.Second, func() { s.runDueMonitors(s.now()) })
|
|
s.every(5*time.Second, func() { s.escalate(s.now()) })
|
|
s.every(30*time.Second, func() { s.runMaintenance(s.now()) })
|
|
s.every(time.Hour, func() { s.st.prune(s.now(), s.opts.MessageDays, s.opts.CheckDays, s.opts.HitDays) })
|
|
}
|
|
|
|
func (s *Server) Close() {
|
|
close(s.stop)
|
|
s.wg.Wait()
|
|
s.notifyWG.Wait()
|
|
}
|
|
|
|
func (s *Server) every(d time.Duration, f func()) {
|
|
s.wg.Add(1)
|
|
go func() {
|
|
defer s.wg.Done()
|
|
t := time.NewTicker(d)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-s.stop:
|
|
return
|
|
case <-t.C:
|
|
func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("worker panic: %v", r)
|
|
}
|
|
}()
|
|
f()
|
|
}()
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (s *Server) link(path string) string {
|
|
if s.opts.URL == "" {
|
|
return ""
|
|
}
|
|
return s.opts.URL + path
|
|
}
|
|
|
|
// BootstrapAdmin creates the first administrator from the environment when there are no users yet.
|
|
func (s *Server) BootstrapAdmin(email, password string) {
|
|
if email == "" || password == "" || s.countUsers() > 0 {
|
|
return
|
|
}
|
|
if _, err := s.createUser("Admin", email, password, "admin", ""); err != nil {
|
|
log.Printf("SANDBAR_ADMIN_EMAIL: %v", err)
|
|
}
|
|
}
|