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
+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 {