Files
uptop/internal/monitor/monitor.go
T
lerko 8f16a09da1
CI / test (pull_request) Successful in 1m44s
CI / lint (pull_request) Successful in 1m11s
CI / vulncheck (pull_request) Successful in 56s
refactor: decompose monitor.go and sqlstore.go into focused files
Split monitor.go (1131→350 lines) into alerts.go, checks.go,
sites.go, and maintenance.go by concern. Split sqlstore.go
(833→470 lines) into sqlstore_alerts.go, sqlstore_history.go,
and sqlstore_maintenance.go by domain. Tests move with their
implementation. Pure reorganization — no behavioral changes.
2026-06-27 19:23:34 -04:00

352 lines
8.9 KiB
Go

package monitor
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"regexp"
"strings"
"sync"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
)
const (
maxLogEntries = 100
pollInterval = 5 * time.Second
minCheckInterval = 5
minPushGrace = 60 * time.Second
maintPruneInterval = 15 * time.Minute
defaultMaintRetention = 7 * 24 * time.Hour
dbWriteBuffer = 4096
alertSendTimeout = 30 * time.Second
dbPruneInterval = 10 * time.Minute
)
type Engine struct {
mu sync.RWMutex
liveState map[int]models.Site
logMu sync.RWMutex
logStore []models.LogEntry
activeMu sync.RWMutex
isActive bool
histMu sync.RWMutex
histories map[int]*SiteHistory
tokenIndex map[string]int // protected by mu
probeResultsMu sync.RWMutex
probeResults map[int]map[string]NodeResult
aggStrategy AggregationStrategy
alertHealthMu sync.RWMutex
alertHealth map[int]AlertHealth
recheckMu sync.RWMutex
recheck map[int]chan struct{}
maintCacheMu sync.RWMutex
maintCache map[int]bool
db store.Store
insecureSkipVerify bool
allowPrivateTargets bool
maintRetention time.Duration
strictClient *http.Client
insecureClient *http.Client
dbWrites chan dbWrite
writerWG sync.WaitGroup
checkerWG sync.WaitGroup
cancel context.CancelFunc
stopOnce sync.Once
}
func NewEngine(s store.Store) *Engine {
return newEngine(s, false)
}
func NewEngineWithOpts(s store.Store, allowPrivateTargets bool) *Engine {
return newEngine(s, allowPrivateTargets)
}
func newEngine(s store.Store, allowPrivateTargets bool) *Engine {
dial := SafeDialContext(allowPrivateTargets)
return &Engine{
liveState: make(map[int]models.Site),
histories: make(map[int]*SiteHistory),
tokenIndex: make(map[string]int),
recheck: make(map[int]chan struct{}),
probeResults: make(map[int]map[string]NodeResult),
alertHealth: make(map[int]AlertHealth),
aggStrategy: AggAnyDown,
isActive: true,
allowPrivateTargets: allowPrivateTargets,
maintRetention: defaultMaintRetention,
dbWrites: make(chan dbWrite, dbWriteBuffer),
db: s,
strictClient: &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
DialContext: dial,
},
},
insecureClient: &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // intentional for IgnoreTLS sites
DialContext: dial,
},
},
}
}
// SetInsecureSkipVerify must be called before Start: the field is read by
// checker goroutines without synchronization.
func (e *Engine) SetInsecureSkipVerify(skip bool) {
e.insecureSkipVerify = skip
}
// SetMaintRetention must be called before Start: the field is read by the
// maintenance prune goroutine without synchronization.
func (e *Engine) SetMaintRetention(d time.Duration) {
e.maintRetention = d
}
var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
func sanitizeLog(s string) string {
s = ansiRe.ReplaceAllString(s, "")
s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, "\r", "")
return s
}
func fmtDurationShort(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
if d < time.Hour {
return fmt.Sprintf("%dm", int(d.Minutes()))
}
if d < 24*time.Hour {
return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
}
return fmt.Sprintf("%dd %dh", int(d.Hours())/24, int(d.Hours())%24)
}
// appendLog adds a timestamped entry to the in-memory ring buffer and returns
// it. It never touches the database, so it is safe to call from the db-write
// drop/error path without recursing back through the write queue.
func (e *Engine) appendLog(msg string) models.LogEntry {
entry := models.LogEntry{
Message: sanitizeLog(msg),
CreatedAt: time.Now(),
}
e.logMu.Lock()
e.logStore = append([]models.LogEntry{entry}, e.logStore...)
if len(e.logStore) > maxLogEntries {
e.logStore = e.logStore[:maxLogEntries]
}
e.logMu.Unlock()
return entry
}
func (e *Engine) AddLog(msg string) {
entry := e.appendLog(msg)
e.enqueueWrite(writeLog{message: entry.Message})
}
// enqueueWrite hands a persistence task to the writer goroutine without
// blocking the caller. If the queue is saturated the write is dropped and noted
// in the in-memory log only (never re-enqueued, to avoid recursion via AddLog).
func (e *Engine) enqueueWrite(w dbWrite) {
select {
case e.dbWrites <- w:
default:
e.appendLog(fmt.Sprintf("db write queue full, dropped %s", w.desc()))
}
}
// dbWriter is the single goroutine that owns all writes. Serializing writes
// through one path removes the fire-and-forget goroutine pile-up, surfaces
// errors, and lets retention run on a timer instead of per-insert. It drains
// any buffered writes on shutdown before returning.
func (e *Engine) dbWriter(ctx context.Context) {
defer e.writerWG.Done()
pruneTicker := time.NewTicker(dbPruneInterval)
defer pruneTicker.Stop()
e.prune(ctx)
for {
select {
case w := <-e.dbWrites:
if err := w.exec(ctx, e.db); err != nil {
e.appendLog(fmt.Sprintf("db %s write failed: %v", w.desc(), err))
}
case <-pruneTicker.C:
e.prune(ctx)
case <-ctx.Done():
e.drainWrites()
return
}
}
}
// drainWrites flushes everything still buffered, best-effort, at shutdown.
func (e *Engine) drainWrites() {
for {
select {
case w := <-e.dbWrites:
if err := w.exec(context.Background(), e.db); err != nil {
e.appendLog(fmt.Sprintf("db %s write failed (drain): %v", w.desc(), err))
}
default:
return
}
}
}
func (e *Engine) prune(ctx context.Context) {
if err := e.db.PruneLogs(ctx); err != nil {
e.appendLog(fmt.Sprintf("log prune failed: %v", err))
}
if err := e.db.PruneCheckHistory(ctx); err != nil {
e.appendLog(fmt.Sprintf("check-history prune failed: %v", err))
}
if err := e.db.PruneStateChanges(ctx); err != nil {
e.appendLog(fmt.Sprintf("state-change prune failed: %v", err))
}
}
// Stop signals the writer goroutine to drain and exit, then blocks until it
// has. Call it before closing the store so no write races a closed DB.
func (e *Engine) Stop() {
e.stopOnce.Do(func() {
if e.cancel != nil {
e.cancel()
}
e.checkerWG.Wait()
e.writerWG.Wait()
e.drainWrites()
})
}
func (e *Engine) InitLogs() {
entries, err := e.db.LoadLogs(context.Background(), maxLogEntries)
if err != nil {
return
}
if len(entries) == 0 {
return
}
e.logMu.Lock()
defer e.logMu.Unlock()
e.logStore = entries
}
func (e *Engine) GetLogs() []models.LogEntry {
e.logMu.RLock()
defer e.logMu.RUnlock()
logs := make([]models.LogEntry, len(e.logStore))
copy(logs, e.logStore)
return logs
}
func (e *Engine) Start(ctx context.Context) {
// e.cancel is invoked by Stop() to drain and halt the writer; gosec can't
// trace the cross-method call, and cancelling the parent reaps this child
// regardless, so the leak it warns about can't occur.
ctx, e.cancel = context.WithCancel(ctx) //nolint:gosec // cancel is called in Stop()
e.writerWG.Add(1)
go e.dbWriter(ctx)
e.checkerWG.Add(1)
go func() {
defer e.checkerWG.Done()
for {
select {
case <-ctx.Done():
return
default:
}
e.refreshMaintenanceCache(ctx)
configs, err := e.db.GetSites(ctx)
if err != nil {
e.AddLog(fmt.Sprintf("Failed to load sites: %v", err))
select {
case <-time.After(pollInterval):
case <-ctx.Done():
return
}
continue
}
dbIDs := make(map[int]bool, len(configs))
for _, cfg := range configs {
dbIDs[cfg.ID] = true
e.mu.RLock()
existing, exists := e.liveState[cfg.ID]
e.mu.RUnlock()
if !exists {
e.mu.Lock()
site := models.Site{SiteConfig: cfg, SiteState: models.SiteState{Status: models.StatusPending}}
if h, ok := e.GetHistory(cfg.ID); ok && len(h.Statuses) > 0 {
if h.Statuses[len(h.Statuses)-1] {
site.Status = models.StatusUp
} else {
site.Status = models.StatusDown
}
if len(h.Latencies) > 0 {
site.Latency = h.Latencies[len(h.Latencies)-1]
}
}
e.liveState[cfg.ID] = site
e.addToTokenIndex(site)
e.mu.Unlock()
e.checkerWG.Add(1)
go func(id int) {
defer e.checkerWG.Done()
e.monitorRoutine(ctx, id)
}(cfg.ID)
} else if existing.SiteConfig != cfg {
e.UpdateSiteConfig(cfg)
}
}
e.mu.RLock()
var vanished []int
for id := range e.liveState {
if !dbIDs[id] {
vanished = append(vanished, id)
}
}
e.mu.RUnlock()
for _, id := range vanished {
e.RemoveSite(id)
e.AddLog(fmt.Sprintf("Monitor removed (no longer in DB): ID %d", id))
}
select {
case <-time.After(pollInterval):
case <-ctx.Done():
return
}
}
}()
e.checkerWG.Add(1)
go func() {
defer e.checkerWG.Done()
e.maintenancePruner(ctx)
}()
}