refactor(monitor): encapsulate engine state, add graceful shutdown and tests

Replace all monitor package-level mutable state with Engine struct.
All state (liveState, logStore, histories, tokenIndex, HTTP clients)
is now encapsulated in Engine, created via NewEngine(store).

Key changes:
- Engine struct holds all monitor state with proper mutex protection
- Engine.Start(ctx) and monitorRoutine respect context cancellation
  for graceful shutdown — no more leaked goroutines
- cluster.runFollowerLoop also respects context for clean exit
- Token index (map[string]int) for O(1) push heartbeat lookup,
  replacing O(n) linear scan through LiveState
- UpdateSiteConfig preserves 8 runtime fields instead of copying
  17 config fields individually
- triggerAlert goroutines get 30s timeout context
- All consumers (TUI, server, cluster, main) receive *Engine via
  constructor/parameter — no package-level state access
- main.go creates context.WithCancel, passes to engine and cluster

First test suite: 12 tests across store and alert packages
- Store: CRUD for sites/alerts/users, push token generation,
  import/export round-trip, check history persistence
- Alert: Discord/Slack/Webhook payload format, HTTP 4xx error
  propagation, Ntfy headers, unknown provider returns nil
This commit is contained in:
2026-05-15 08:21:17 -04:00
parent 0e6dc774cb
commit f023e38fdc
11 changed files with 705 additions and 315 deletions
+2 -3
View File
@@ -2,7 +2,6 @@ package tui
import (
"fmt"
"go-upkeep/internal/monitor"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
@@ -237,11 +236,11 @@ func (m *Model) submitAlertForm() {
if m.editID > 0 {
if err := m.store.UpdateAlert(m.editID, d.Name, d.AlertType, settings); err != nil {
monitor.AddLog("Update alert failed: " + err.Error())
m.engine.AddLog("Update alert failed: " + err.Error())
}
} else {
if err := m.store.AddAlert(d.Name, d.AlertType, settings); err != nil {
monitor.AddLog("Add alert failed: " + err.Error())
m.engine.AddLog("Add alert failed: " + err.Error())
}
}
m.state = stateDashboard
+4 -5
View File
@@ -3,7 +3,6 @@ package tui
import (
"fmt"
"go-upkeep/internal/models"
"go-upkeep/internal/monitor"
"net/url"
"strconv"
"strings"
@@ -243,7 +242,7 @@ func (m Model) viewSitesTab() string {
name = limitStr(name, 13)
}
hist, _ := monitor.GetHistory(site.ID)
hist, _ := m.engine.GetHistory(site.ID)
var spark string
if site.Type == "push" {
spark = heartbeatSparkline(hist.Statuses, sparkWidth)
@@ -508,12 +507,12 @@ func (m *Model) submitSiteForm() {
if m.editID > 0 {
if err := m.store.UpdateSite(site); err != nil {
monitor.AddLog("Update site failed: " + err.Error())
m.engine.AddLog("Update site failed: " + err.Error())
}
monitor.UpdateSiteConfig(site)
m.engine.UpdateSiteConfig(site)
} else {
if err := m.store.AddSite(site); err != nil {
monitor.AddLog("Add site failed: " + err.Error())
m.engine.AddLog("Add site failed: " + err.Error())
}
}
m.state = stateDashboard
+2 -3
View File
@@ -2,7 +2,6 @@ package tui
import (
"fmt"
"go-upkeep/internal/monitor"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
@@ -104,11 +103,11 @@ func (m *Model) submitUserForm() {
d := m.userFormData
if m.editID > 0 {
if err := m.store.UpdateUser(m.editID, d.Username, d.PublicKey, d.Role); err != nil {
monitor.AddLog("Update user failed: " + err.Error())
m.engine.AddLog("Update user failed: " + err.Error())
}
} else {
if err := m.store.AddUser(d.Username, d.PublicKey, d.Role); err != nil {
monitor.AddLog("Add user failed: " + err.Error())
m.engine.AddLog("Add user failed: " + err.Error())
}
}
m.state = stateUsers
+10 -13
View File
@@ -69,6 +69,7 @@ type Model struct {
collapsed map[int]bool
store store.Store
engine *monitor.Engine
// harmonica animation state
pulseSpring harmonica.Spring
@@ -81,7 +82,7 @@ type Model struct {
users []models.User
}
func InitialModel(isAdmin bool, s store.Store) Model {
func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
vpLogs := viewport.New(100, 20)
vpLogs.SetContent("Waiting for logs...")
z := zone.New()
@@ -92,6 +93,7 @@ func InitialModel(isAdmin bool, s store.Store) Model {
maxTableRows: 5,
isAdmin: isAdmin,
store: s,
engine: eng,
zones: z,
pulseSpring: spring,
collapsed: make(map[int]bool),
@@ -112,18 +114,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch m.deleteTab {
case 0:
if err := m.store.DeleteSite(m.deleteID); err != nil {
monitor.AddLog("Delete site failed: " + err.Error())
m.engine.AddLog("Delete site failed: " + err.Error())
}
monitor.RemoveSite(m.deleteID)
m.engine.RemoveSite(m.deleteID)
m.adjustCursor(len(m.sites) - 1)
case 1:
if err := m.store.DeleteAlert(m.deleteID); err != nil {
monitor.AddLog("Delete alert failed: " + err.Error())
m.engine.AddLog("Delete alert failed: " + err.Error())
}
m.adjustCursor(len(m.alerts) - 1)
case 3:
if err := m.store.DeleteUser(m.deleteID); err != nil {
monitor.AddLog("Delete user failed: " + err.Error())
m.engine.AddLog("Delete user failed: " + err.Error())
}
m.adjustCursor(len(m.users) - 1)
}
@@ -317,7 +319,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "p":
if m.currentTab == 0 && len(m.sites) > 0 {
site := m.sites[m.cursor]
monitor.ToggleSitePause(site.ID)
m.engine.ToggleSitePause(site.ID)
site.Paused = !site.Paused
_ = m.store.UpdateSitePaused(site.ID, site.Paused)
m.refreshData()
@@ -433,12 +435,7 @@ func (m *Model) adjustCursor(newLen int) {
}
func (m *Model) refreshData() {
monitor.Mutex.RLock()
var allSites []models.Site
for _, s := range monitor.LiveState {
allSites = append(allSites, s)
}
monitor.Mutex.RUnlock()
allSites := m.engine.GetAllSites()
var groups, ungrouped []models.Site
children := make(map[int][]models.Site)
@@ -476,7 +473,7 @@ func (m *Model) refreshData() {
m.users = users
}
}
m.logViewport.SetContent(strings.Join(monitor.GetLogs(), "\n"))
m.logViewport.SetContent(strings.Join(m.engine.GetLogs(), "\n"))
listLen := len(m.sites)
if m.currentTab == 1 {