Files
Sandbar/internal/sandbar/server.go
T
VeillaxandClaude Sonnet 5.5 cf4d5b3ce2 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>
2026-10-27 17:05:38 -06:00

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