From a5fd3aec90128804c355eb8d72c93ded0eeaf17c Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 27 Jun 2026 19:43:44 -0400 Subject: [PATCH] refactor: propagate context.Context through call chains Thread context.Context from callers instead of creating context.Background() at every call site. Engine stores its lifecycle ctx for use by triggerAlert goroutines. TUI Model carries ctx for store operations in Cmd closures. CLI commands create a root ctx and thread it through openStore, seed functions, and init methods. Only two intentional context.Background() remain in non-root positions: engine constructor default (overridden by Start) and drainWrites (parent already cancelled at shutdown). --- cmd/uptop/keycache_test.go | 16 ++++---- cmd/uptop/main.go | 63 +++++++++++++++++--------------- internal/monitor/alerts.go | 14 +++---- internal/monitor/history.go | 4 +- internal/monitor/monitor.go | 9 ++++- internal/monitor/monitor_test.go | 4 +- internal/monitor/sites.go | 8 ++-- internal/tui/data.go | 17 +++++---- internal/tui/tab_alerts.go | 6 +-- internal/tui/tab_maint.go | 3 +- internal/tui/tab_sites.go | 6 +-- internal/tui/tab_users.go | 6 +-- internal/tui/tui.go | 10 +++-- internal/tui/update.go | 31 ++++++++++------ 14 files changed, 108 insertions(+), 89 deletions(-) diff --git a/cmd/uptop/keycache_test.go b/cmd/uptop/keycache_test.go index 09af153..6a7ca39 100644 --- a/cmd/uptop/keycache_test.go +++ b/cmd/uptop/keycache_test.go @@ -43,10 +43,10 @@ func TestKeyCache_AllowsKnownDeniesUnknown(t *testing.T) { _, unknown := testKey(t) kc := newKeyCache(&kcMockStore{users: []models.User{{PublicKey: authorized}}}) - if !kc.IsAllowed(known) { + if !kc.IsAllowed(context.Background(), known) { t.Error("known key denied") } - if kc.IsAllowed(unknown) { + if kc.IsAllowed(context.Background(), unknown) { t.Error("unknown key allowed") } } @@ -56,7 +56,7 @@ func TestKeyCache_RetainsKeysOnRefreshError(t *testing.T) { ms := &kcMockStore{users: []models.User{{PublicKey: authorized}}} kc := newKeyCache(ms) - if !kc.IsAllowed(known) { + if !kc.IsAllowed(context.Background(), known) { t.Fatal("known key denied on first refresh") } @@ -67,7 +67,7 @@ func TestKeyCache_RetainsKeysOnRefreshError(t *testing.T) { kc.updated = time.Now().Add(-time.Hour) kc.mu.Unlock() - if !kc.IsAllowed(known) { + if !kc.IsAllowed(context.Background(), known) { t.Error("transient refresh error locked out a previously valid key") } } @@ -77,7 +77,7 @@ func TestKeyCache_FailsClosedAfterInvalidate(t *testing.T) { ms := &kcMockStore{users: []models.User{{PublicKey: authorized}}} kc := newKeyCache(ms) - if !kc.IsAllowed(known) { + if !kc.IsAllowed(context.Background(), known) { t.Fatal("known key denied on first refresh") } @@ -86,7 +86,7 @@ func TestKeyCache_FailsClosedAfterInvalidate(t *testing.T) { ms.err = errors.New("db down") kc.Invalidate() - if kc.IsAllowed(known) { + if kc.IsAllowed(context.Background(), known) { t.Error("revoked key still allowed while DB is down โ€” fails open") } } @@ -97,7 +97,7 @@ func TestUserInvalidatingStore_DeleteDropsKeyCache(t *testing.T) { kc := newKeyCache(ms) s := &userInvalidatingStore{Store: ms, kc: kc} - if !kc.IsAllowed(known) { + if !kc.IsAllowed(context.Background(), known) { t.Fatal("known key denied on first refresh") } @@ -109,7 +109,7 @@ func TestUserInvalidatingStore_DeleteDropsKeyCache(t *testing.T) { ms.users = nil ms.err = errors.New("db down") - if kc.IsAllowed(known) { + if kc.IsAllowed(context.Background(), known) { t.Error("deleted user's key still allowed from stale cache") } } diff --git a/cmd/uptop/main.go b/cmd/uptop/main.go index ffc0835..f330120 100644 --- a/cmd/uptop/main.go +++ b/cmd/uptop/main.go @@ -179,7 +179,7 @@ func parseTrustedProxies(raw string) []*net.IPNet { return cidrs } -func openStore(dbType, dsn string) store.Store { +func openStore(ctx context.Context, dbType, dsn string) store.Store { var ss *store.SQLStore var err error if dbType == "postgres" { @@ -201,7 +201,7 @@ func openStore(dbType, dsn string) store.Store { } else { slog.Warn("no UPTOP_ENCRYPTION_KEY set, alert credentials stored unencrypted") } - if err := ss.Init(context.Background()); err != nil { + if err := ss.Init(ctx); err != nil { slog.Error("database init failed", "err", err) os.Exit(1) } @@ -223,7 +223,8 @@ func runApply(args []string) { os.Exit(1) } - s := openStore(*dbType, *dsn) + ctx := context.Background() + s := openStore(ctx, *dbType, *dsn) f, err := config.LoadFile(*filePath) if err != nil { @@ -231,7 +232,7 @@ func runApply(args []string) { os.Exit(1) } - changes, err := config.Apply(context.Background(), s, f, config.ApplyOpts{ + changes, err := config.Apply(ctx, s, f, config.ApplyOpts{ DryRun: *dryRun, Prune: *prune, }) @@ -250,9 +251,10 @@ func runExport(args []string) { dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN") _ = fs.Parse(args) // ExitOnError: parse errors exit before returning - s := openStore(*dbType, *dsn) + ctx := context.Background() + s := openStore(ctx, *dbType, *dsn) - f, err := config.Export(context.Background(), s) + f, err := config.Export(ctx, s) if err != nil { slog.Error("export failed", "err", err) os.Exit(1) @@ -281,6 +283,8 @@ func runMigrateSecrets(args []string) { os.Exit(1) } + ctx := context.Background() + var ss *store.SQLStore if *dbType == "postgres" { ss, err = store.NewPostgresStore(*dsn) @@ -291,21 +295,21 @@ func runMigrateSecrets(args []string) { slog.Error("database connection failed", "err", err) os.Exit(1) } - if err := ss.Init(context.Background()); err != nil { + if err := ss.Init(ctx); err != nil { slog.Error("database init failed", "err", err) os.Exit(1) } ss.SetEncryptor(enc) - alerts, err := ss.GetAllAlerts(context.Background()) + alerts, err := ss.GetAllAlerts(ctx) if err != nil { slog.Error("failed to load alerts", "err", err) os.Exit(1) } migrated := 0 for _, a := range alerts { - if err := ss.UpdateAlert(context.Background(), a.ID, a.Name, a.Type, a.Settings); err != nil { + if err := ss.UpdateAlert(ctx, a.ID, a.Name, a.Type, a.Settings); err != nil { slog.Error("alert migration failed", "alert", a.Name, "err", err) os.Exit(1) } @@ -392,15 +396,19 @@ func runServe(args []string) { kc := newKeyCache(ss) var s store.Store = &userInvalidatingStore{Store: ss, kc: kc} - if err := s.Init(context.Background()); err != nil { + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := s.Init(ctx); err != nil { slog.Error("database init failed", "err", err) os.Exit(1) } if *demo { - seedDemoData(s) + seedDemoData(ctx, s) } - seedKeysFromEnv(s) + seedKeysFromEnv(ctx, s) if *importKuma != "" { kb, err := importer.LoadKumaFile(*importKuma) @@ -409,7 +417,7 @@ func runServe(args []string) { os.Exit(1) } backup := importer.ConvertKuma(kb) - if err := s.ImportData(context.Background(), backup); err != nil { + if err := s.ImportData(ctx, backup); err != nil { slog.Error("import failed", "err", err) os.Exit(1) } @@ -429,12 +437,9 @@ func runServe(args []string) { } eng.SetMaintRetention(cfg.MaintRetention) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - eng.InitHistory() - eng.InitLogs() - eng.InitAlertHealth() + eng.InitHistory(ctx) + eng.InitLogs(ctx) + eng.InitAlertHealth(ctx) eng.Start(ctx) localTUI := isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) @@ -450,7 +455,7 @@ func runServe(args []string) { sshSrv := startSSHServer(*port, s, eng, kc) if localTUI { - p := tea.NewProgram(tui.InitialModel(true, s, eng, version), tea.WithAltScreen(), tea.WithMouseCellMotion()) + p := tea.NewProgram(tui.InitialModel(ctx, true, s, eng, version), tea.WithAltScreen(), tea.WithMouseCellMotion()) if _, err := p.Run(); err != nil { slog.Error("TUI failed", "err", err) } @@ -484,11 +489,11 @@ func startSSHServer(port int, db store.Store, eng *monitor.Engine, kc *keyCache) wish.WithAddress(fmt.Sprintf(":%d", port)), wish.WithHostKeyPath(envOrDefault("UPTOP_SSH_HOST_KEY", ".ssh/id_ed25519")), wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { - return kc.IsAllowed(key) + return kc.IsAllowed(ctx, key) }), wish.WithMiddleware( bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) { - return tui.InitialModel(false, db, eng, version), []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()} + return tui.InitialModel(s.Context(), false, db, eng, version), []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()} }), ), ) @@ -504,8 +509,7 @@ func startSSHServer(port int, db store.Store, eng *monitor.Engine, kc *keyCache) return s } -func seedDemoData(s store.Store) { - ctx := context.Background() +func seedDemoData(ctx context.Context, s store.Store) { existing, _ := s.GetSites(ctx) if len(existing) > 0 { return @@ -566,8 +570,8 @@ func newKeyCache(db store.Store) *keyCache { return &keyCache{db: db, ttl: 30 * time.Second} } -func (c *keyCache) refresh() { - users, err := c.db.GetAllUsers(context.Background()) +func (c *keyCache) refresh(ctx context.Context) { + users, err := c.db.GetAllUsers(ctx) if err != nil { // Keep the previous key set: a transient DB error must not lock every // admin out. Revocation still fails closed because Invalidate clears @@ -600,13 +604,13 @@ func (c *keyCache) Invalidate() { c.mu.Unlock() } -func (c *keyCache) IsAllowed(incomingKey ssh.PublicKey) bool { +func (c *keyCache) IsAllowed(ctx context.Context, incomingKey ssh.PublicKey) bool { c.mu.RLock() stale := time.Since(c.updated) > c.ttl c.mu.RUnlock() if stale { - c.refresh() + c.refresh(ctx) } c.mu.RLock() @@ -652,8 +656,7 @@ func (s *userInvalidatingStore) ImportData(ctx context.Context, data models.Back return err } -func seedKeysFromEnv(s store.Store) { - ctx := context.Background() +func seedKeysFromEnv(ctx context.Context, s store.Store) { var keys []string if v := os.Getenv("UPTOP_ADMIN_KEY"); v != "" { diff --git a/internal/monitor/alerts.go b/internal/monitor/alerts.go index e17eaa1..5703051 100644 --- a/internal/monitor/alerts.go +++ b/internal/monitor/alerts.go @@ -19,8 +19,8 @@ type AlertHealth struct { // InitAlertHealth restores persisted alert send health so the dashboard shows real // "last sent" / health state on startup instead of resetting every channel to "never". -func (e *Engine) InitAlertHealth() { - records, err := e.db.LoadAlertHealth(context.Background()) +func (e *Engine) InitAlertHealth(ctx context.Context) { + records, err := e.db.LoadAlertHealth(ctx) if err != nil { return } @@ -198,7 +198,7 @@ func (e *Engine) triggerAlert(alertID int, title, message string) { if alertID <= 0 { return } - cfg, err := e.db.GetAlert(context.Background(), alertID) + cfg, err := e.db.GetAlert(e.ctx, alertID) if err != nil { e.AddLog(fmt.Sprintf("Failed to load alert config %d: %v", alertID, err)) return @@ -206,7 +206,7 @@ func (e *Engine) triggerAlert(alertID int, title, message string) { provider := alert.GetProvider(cfg) if provider != nil { go func() { - ctx, cancel := context.WithTimeout(context.Background(), alertSendTimeout) + ctx, cancel := context.WithTimeout(e.ctx, alertSendTimeout) defer cancel() if err := provider.Send(ctx, title, message); err != nil { e.AddLog(fmt.Sprintf("Alert send failed (%s): %v", cfg.Name, err)) @@ -250,8 +250,8 @@ func (e *Engine) GetAlertHealth(alertID int) AlertHealth { return e.alertHealth[alertID] } -func (e *Engine) TestAlert(alertID int) error { - cfg, err := e.db.GetAlert(context.Background(), alertID) +func (e *Engine) TestAlert(ctx context.Context, alertID int) error { + cfg, err := e.db.GetAlert(ctx, alertID) if err != nil { return fmt.Errorf("failed to load alert: %w", err) } @@ -259,7 +259,7 @@ func (e *Engine) TestAlert(alertID int) error { if provider == nil { return fmt.Errorf("no provider for type %q", cfg.Type) } - ctx, cancel := context.WithTimeout(context.Background(), alertSendTimeout) + ctx, cancel := context.WithTimeout(ctx, alertSendTimeout) defer cancel() err = provider.Send(ctx, "๐Ÿงช Test Alert", fmt.Sprintf("Test notification from uptop for channel '%s'.", cfg.Name)) if err != nil { diff --git a/internal/monitor/history.go b/internal/monitor/history.go index 2a88154..c2d3b00 100644 --- a/internal/monitor/history.go +++ b/internal/monitor/history.go @@ -14,8 +14,8 @@ type SiteHistory struct { UpChecks int } -func (e *Engine) InitHistory() { - all, err := e.db.LoadAllHistory(context.Background(), maxHistoryLen) +func (e *Engine) InitHistory(ctx context.Context) { + all, err := e.db.LoadAllHistory(ctx, maxHistoryLen) if err != nil { e.AddLog("Failed to load check history: " + err.Error()) return diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 8b43cc3..1497805 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -64,6 +64,7 @@ type Engine struct { dbWrites chan dbWrite writerWG sync.WaitGroup checkerWG sync.WaitGroup + ctx context.Context cancel context.CancelFunc stopOnce sync.Once } @@ -90,6 +91,7 @@ func newEngine(s store.Store, allowPrivateTargets bool) *Engine { allowPrivateTargets: allowPrivateTargets, maintRetention: defaultMaintRetention, dbWrites: make(chan dbWrite, dbWriteBuffer), + ctx: context.Background(), db: s, strictClient: &http.Client{ Transport: &http.Transport{ @@ -200,6 +202,8 @@ func (e *Engine) dbWriter(ctx context.Context) { } // drainWrites flushes everything still buffered, best-effort, at shutdown. +// Uses context.Background because the engine ctx is already cancelled when +// this runs โ€” writes still need to reach the DB. func (e *Engine) drainWrites() { for { select { @@ -238,8 +242,8 @@ func (e *Engine) Stop() { }) } -func (e *Engine) InitLogs() { - entries, err := e.db.LoadLogs(context.Background(), maxLogEntries) +func (e *Engine) InitLogs(ctx context.Context) { + entries, err := e.db.LoadLogs(ctx, maxLogEntries) if err != nil { return } @@ -264,6 +268,7 @@ func (e *Engine) Start(ctx context.Context) { // 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.ctx = ctx e.writerWG.Add(1) go e.dbWriter(ctx) diff --git a/internal/monitor/monitor_test.go b/internal/monitor/monitor_test.go index 8d18af0..daa351c 100644 --- a/internal/monitor/monitor_test.go +++ b/internal/monitor/monitor_test.go @@ -208,7 +208,7 @@ func TestInitHistory_LoadsFromDB(t *testing.T) { {SiteID: 1, LatencyNs: 3000000, IsUp: false}, } e := newTestEngine(ms) - e.InitHistory() + e.InitHistory(context.Background()) h, ok := e.GetHistory(1) if !ok { @@ -248,7 +248,7 @@ func TestInitLogs_LoadsFromDB(t *testing.T) { {Message: "old-log-2"}, } e := newTestEngine(ms) - e.InitLogs() + e.InitLogs(context.Background()) logs := e.GetLogs() if len(logs) != 2 { diff --git a/internal/monitor/sites.go b/internal/monitor/sites.go index 555aa90..bb6ae69 100644 --- a/internal/monitor/sites.go +++ b/internal/monitor/sites.go @@ -141,16 +141,16 @@ func (e *Engine) GetDisplayStatus(site models.Site) string { return string(site.Status) } -func (e *Engine) GetStateChanges(siteID int, limit int) []models.StateChange { - changes, err := e.db.GetStateChanges(context.Background(), siteID, limit) +func (e *Engine) GetStateChanges(ctx context.Context, siteID int, limit int) []models.StateChange { + changes, err := e.db.GetStateChanges(ctx, siteID, limit) if err != nil { return nil } return changes } -func (e *Engine) GetStateChangesSince(siteID int, since time.Time) []models.StateChange { - changes, err := e.db.GetStateChangesSince(context.Background(), siteID, since) +func (e *Engine) GetStateChangesSince(ctx context.Context, siteID int, since time.Time) []models.StateChange { + changes, err := e.db.GetStateChangesSince(ctx, siteID, since) if err != nil { return nil } diff --git a/internal/tui/data.go b/internal/tui/data.go index 5731134..ab0bb28 100644 --- a/internal/tui/data.go +++ b/internal/tui/data.go @@ -13,9 +13,9 @@ import ( tea "github.com/charmbracelet/bubbletea" ) -func loadCollapsed(s store.Store) map[int]bool { +func loadCollapsed(ctx context.Context, s store.Store) map[int]bool { m := make(map[int]bool) - raw, err := s.GetPreference(context.Background(), "collapsed_groups") + raw, err := s.GetPreference(ctx, "collapsed_groups") if err != nil || raw == "" { return m } @@ -163,9 +163,9 @@ func (m *Model) loadTabDataCmd() tea.Cmd { m.tabSeq++ seq := m.tabSeq st := m.store + ctx := m.ctx isAdmin := m.isAdmin return func() tea.Msg { - ctx := context.Background() alerts, err := st.GetAllAlerts(ctx) if err != nil { return tabDataMsg{seq: seq, err: err} @@ -192,6 +192,7 @@ func (m *Model) loadTabDataCmd() tea.Cmd { // goroutine. View renders the cached result rather than querying the DB. func (m *Model) loadDetailCmd(siteID int) tea.Cmd { eng := m.engine + ctx := m.ctx var currentStatus models.Status for _, s := range m.sites { if s.ID == siteID { @@ -200,9 +201,9 @@ func (m *Model) loadDetailCmd(siteID int) tea.Cmd { } } return func() tea.Msg { - changes := eng.GetStateChanges(siteID, 5) + changes := eng.GetStateChanges(ctx, siteID, 5) now := time.Now() - allChanges := eng.GetStateChangesSince(siteID, now.Add(-stateHistoryLookback)) + allChanges := eng.GetStateChangesSince(ctx, siteID, now.Add(-stateHistoryLookback)) daily := monitor.ComputeDailyBreakdown(allChanges, currentStatus, stateHistoryDays, now) return detailDataMsg{siteID: siteID, changes: changes, dailyDays: daily} } @@ -212,8 +213,9 @@ func (m *Model) loadDetailCmd(siteID int) tea.Cmd { // the UI goroutine. func (m *Model) loadHistoryCmd(siteID int) tea.Cmd { eng := m.engine + ctx := m.ctx return func() tea.Msg { - return historyDataMsg{siteID: siteID, changes: eng.GetStateChanges(siteID, stateHistoryLimit)} + return historyDataMsg{siteID: siteID, changes: eng.GetStateChanges(ctx, siteID, stateHistoryLimit)} } } @@ -222,12 +224,13 @@ func (m *Model) loadHistoryCmd(siteID int) tea.Cmd { // can be recognized and dropped. func (m *Model) loadSLACmd(siteID, periodIdx int) tea.Cmd { eng := m.engine + ctx := m.ctx since := time.Now().Add(-slaPeriods[periodIdx].duration) return func() tea.Msg { return slaDataMsg{ siteID: siteID, periodIdx: periodIdx, - changes: eng.GetStateChangesSince(siteID, since), + changes: eng.GetStateChangesSince(ctx, siteID, since), } } } diff --git a/internal/tui/tab_alerts.go b/internal/tui/tab_alerts.go index 1926eb1..d67eb4b 100644 --- a/internal/tui/tab_alerts.go +++ b/internal/tui/tab_alerts.go @@ -1,7 +1,6 @@ package tui import ( - "context" "fmt" neturl "net/url" "sort" @@ -537,15 +536,16 @@ func (m *Model) submitAlertForm() tea.Cmd { } st := m.store + ctx := m.ctx id := m.editID name, aType := d.Name, d.AlertType m.state = stateDashboard if id > 0 { return writeCmd("Update alert", func() error { - return st.UpdateAlert(context.Background(), id, name, aType, settings) + return st.UpdateAlert(ctx, id, name, aType, settings) }) } return writeCmd("Add alert", func() error { - return st.AddAlert(context.Background(), name, aType, settings) + return st.AddAlert(ctx, name, aType, settings) }) } diff --git a/internal/tui/tab_maint.go b/internal/tui/tab_maint.go index dcedb9e..5c3a097 100644 --- a/internal/tui/tab_maint.go +++ b/internal/tui/tab_maint.go @@ -1,7 +1,6 @@ package tui import ( - "context" "fmt" "strconv" "time" @@ -254,9 +253,9 @@ func (m *Model) submitMaintForm() tea.Cmd { } st := m.store + ctx := m.ctx m.state = stateDashboard return writeCmd("Add maintenance window", func() error { - ctx := context.Background() overlaps, _ := st.GetOverlappingMaintenanceWindows(ctx, mw.MonitorID, mw.StartTime, mw.EndTime) if len(overlaps) > 0 { _ = st.SaveLog(ctx, fmt.Sprintf("Overlap: new window %q overlaps with existing %q", mw.Title, overlaps[0].Title)) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 66890e3..8a65d29 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -1,7 +1,6 @@ package tui import ( - "context" "fmt" "net/url" "strconv" @@ -604,10 +603,11 @@ func (m *Model) submitSiteForm() tea.Cmd { } st := m.store + ctx := m.ctx m.state = stateDashboard if m.editID > 0 { m.engine.UpdateSiteConfig(cfg) - return writeCmd("Update site", func() error { return st.UpdateSite(context.Background(), cfg) }) + return writeCmd("Update site", func() error { return st.UpdateSite(ctx, cfg) }) } - return writeCmd("Add site", func() error { return st.AddSite(context.Background(), cfg) }) + return writeCmd("Add site", func() error { return st.AddSite(ctx, cfg) }) } diff --git a/internal/tui/tab_users.go b/internal/tui/tab_users.go index 2bd86fa..5599948 100644 --- a/internal/tui/tab_users.go +++ b/internal/tui/tab_users.go @@ -1,7 +1,6 @@ package tui import ( - "context" "fmt" tea "github.com/charmbracelet/bubbletea" @@ -114,15 +113,16 @@ func (m *Model) initUserHuhForm() tea.Cmd { func (m *Model) submitUserForm() tea.Cmd { d := m.userFormData st := m.store + ctx := m.ctx id := m.editID username, key, role := d.Username, d.PublicKey, d.Role m.state = stateDashboard if id > 0 { return writeCmd("Update user", func() error { - return st.UpdateUser(context.Background(), id, username, key, role) + return st.UpdateUser(ctx, id, username, key, role) }) } return writeCmd("Add user", func() error { - return st.AddUser(context.Background(), username, key, role) + return st.AddUser(ctx, username, key, role) }) } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 45c12d4..81b1d48 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -175,6 +175,7 @@ type Model struct { deleteName string deleteTab int + ctx context.Context collapsed map[int]bool store store.Store engine *monitor.Engine @@ -213,14 +214,14 @@ type Model struct { version string } -func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version string) Model { +func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor.Engine, version string) Model { vpLogs := viewport.New(100, 20) vpLogs.SetContent("Waiting for logs...") z := zone.New() spring := harmonica.NewSpring(harmonica.FPS(10), 6.0, 0.4) - collapsed := loadCollapsed(s) + collapsed := loadCollapsed(ctx, s) - themeName, _ := s.GetPreference(context.Background(), "theme") + themeName, _ := s.GetPreference(ctx, "theme") theme := themeByName(themeName) themeIdx := 0 for i, t := range themes { @@ -230,9 +231,10 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri } } - detailPref, _ := s.GetPreference(context.Background(), "detail_open") + detailPref, _ := s.GetPreference(ctx, "detail_open") return Model{ + ctx: ctx, state: stateDashboard, logViewport: vpLogs, maxTableRows: 5, diff --git a/internal/tui/update.go b/internal/tui/update.go index 0b93c16..be85437 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -1,7 +1,6 @@ package tui import ( - "context" "fmt" "time" @@ -76,23 +75,24 @@ func (m *Model) handleConfirmDelete(msg tea.Msg) (tea.Model, tea.Cmd) { // writeDoneMsg reload converges the UI back to the DB state (and the // engine poll loop re-adds a site that is still in the DB). st := m.store + ctx := m.ctx id := m.deleteID var cmd tea.Cmd switch m.deleteTab { case tabMonitors: - cmd = writeCmd("Delete site", func() error { return st.DeleteSite(context.Background(), id) }) + cmd = writeCmd("Delete site", func() error { return st.DeleteSite(ctx, id) }) m.engine.RemoveSite(id) m.adjustCursor(len(m.sites) - 1) case tabMaint: - cmd = writeCmd("Delete maintenance window", func() error { return st.DeleteMaintenanceWindow(context.Background(), id) }) + cmd = writeCmd("Delete maintenance window", func() error { return st.DeleteMaintenanceWindow(ctx, id) }) m.adjustCursor(len(m.maintenanceWindows) - 1) case tabSettings: switch m.settingsSection { case sectionAlerts: - cmd = writeCmd("Delete alert", func() error { return st.DeleteAlert(context.Background(), id) }) + cmd = writeCmd("Delete alert", func() error { return st.DeleteAlert(ctx, id) }) m.adjustCursor(len(m.alerts) - 1) case sectionUsers: - cmd = writeCmd("Delete user", func() error { return st.DeleteUser(context.Background(), id) }) + cmd = writeCmd("Delete user", func() error { return st.DeleteUser(ctx, id) }) m.adjustCursor(len(m.users) - 1) } } @@ -232,8 +232,9 @@ func (m *Model) handleTabData(msg tabDataMsg) (tea.Model, tea.Cmd) { // surfaces through the engine log (picked up by the next refreshLive). func (m *Model) testAlertCmd(id int, name string) tea.Cmd { eng := m.engine + ctx := m.ctx return func() tea.Msg { - if err := eng.TestAlert(id); err != nil { + if err := eng.TestAlert(ctx, id); err != nil { eng.AddLog(fmt.Sprintf("Test alert failed (%s): %v", name, err)) } return nil @@ -635,9 +636,10 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.collapsed[gid] = !m.collapsed[gid] payload := collapsedJSON(m.collapsed) st := m.store + ctx := m.ctx m.refreshLive() return m, writeCmd("Save collapsed groups", func() error { - return st.SetPreference(context.Background(), "collapsed_groups", payload) + return st.SetPreference(ctx, "collapsed_groups", payload) }) } case "p": @@ -645,9 +647,10 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { id := m.sites[m.cursor].ID paused := m.engine.ToggleSitePause(id) st := m.store + ctx := m.ctx m.refreshLive() return m, writeCmd("Update pause state", func() error { - return st.UpdateSitePaused(context.Background(), id, paused) + return st.UpdateSitePaused(ctx, id, paused) }) } case "i": @@ -655,6 +658,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.detailOpen = !m.detailOpen m.recalcLayout() st := m.store + ctx := m.ctx open := m.detailOpen var cmd tea.Cmd if m.detailOpen { @@ -665,7 +669,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if open { v = "true" } - return st.SetPreference(context.Background(), "detail_open", v) + return st.SetPreference(ctx, "detail_open", v) }) if cmd != nil { return m, tea.Batch(cmd, saveCmd) @@ -682,8 +686,9 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.detailOpen = false m.recalcLayout() st := m.store + ctx := m.ctx return m, writeCmd("Save detail preference", func() error { - return st.SetPreference(context.Background(), "detail_open", "false") + return st.SetPreference(ctx, "detail_open", "false") }) } } @@ -712,10 +717,11 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) if isActive { st := m.store + ctx := m.ctx id := mw.ID m.refreshLive() return m, writeCmd("End maintenance", func() error { - return st.EndMaintenanceWindow(context.Background(), id) + return st.EndMaintenanceWindow(ctx, id) }) } } @@ -724,9 +730,10 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.theme = themes[m.themeIndex] m.st = newStyles(m.theme) st := m.store + ctx := m.ctx name := m.theme.Name return m, writeCmd("Save theme", func() error { - return st.SetPreference(context.Background(), "theme", name) + return st.SetPreference(ctx, "theme", name) }) case "d": return m.handleDeleteItem()