diff --git a/internal/tui/data.go b/internal/tui/data.go index 59f6a36..aa0b68c 100644 --- a/internal/tui/data.go +++ b/internal/tui/data.go @@ -7,6 +7,7 @@ import ( "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/store" + tea "github.com/charmbracelet/bubbletea" ) func loadCollapsed(s store.Store) map[int]bool { @@ -80,41 +81,24 @@ func filterSites(sites []models.Site, needle string) []models.Site { return filtered } -func (m *Model) refreshData() { +// refreshLive updates everything sourced from in-memory engine copies — the +// live site list (sorted + filtered) and the log viewport. It does no database +// IO, so it is safe to call on every tick. DB-backed tab data is loaded +// separately via loadTabDataCmd. +func (m *Model) refreshLive() { allSites := m.engine.GetAllSites() ordered := sortSitesForDisplay(allSites, m.collapsed) if m.filterText != "" { ordered = filterSites(ordered, m.filterText) } m.sites = ordered - - if alerts, err := m.store.GetAllAlerts(); err == nil { - m.alerts = alerts - } - if m.isAdmin { - if users, err := m.store.GetAllUsers(); err == nil { - m.users = users - } - } - if nodes, err := m.store.GetAllNodes(); err == nil { - m.nodes = nodes - } - if windows, err := m.store.GetAllMaintenanceWindows(100); err == nil { - m.maintenanceWindows = windows - } m.logViewport.SetContent(strings.Join(m.engine.GetLogs(), "\n")) + m.clampCursor() +} - listLen := len(m.sites) - switch m.currentTab { - case 1: - listLen = len(m.alerts) - case 3: - listLen = len(m.nodes) - case 4: - listLen = len(m.maintenanceWindows) - case 5: - listLen = len(m.users) - } +// clampCursor keeps the cursor and scroll offset within the current tab's list. +func (m *Model) clampCursor() { + listLen := m.currentListLen() if listLen > 0 && m.cursor >= listLen { m.cursor = listLen - 1 } @@ -122,3 +106,42 @@ func (m *Model) refreshData() { m.tableOffset = m.cursor } } + +// loadTabDataCmd returns a tea.Cmd that loads the DB-backed tab tables off the +// UI goroutine. The closure reads only stable fields (store, isAdmin) and never +// mutates the model; results come back as a tabDataMsg. On the first store +// error it returns an error-only msg so the model keeps its previous data. +func (m *Model) loadTabDataCmd() tea.Cmd { + st := m.store + isAdmin := m.isAdmin + return func() tea.Msg { + alerts, err := st.GetAllAlerts() + if err != nil { + return tabDataMsg{err: err} + } + var users []models.User + if isAdmin { + if users, err = st.GetAllUsers(); err != nil { + return tabDataMsg{err: err} + } + } + nodes, err := st.GetAllNodes() + if err != nil { + return tabDataMsg{err: err} + } + maint, err := st.GetAllMaintenanceWindows(100) + if err != nil { + return tabDataMsg{err: err} + } + return tabDataMsg{alerts: alerts, users: users, nodes: nodes, maint: maint} + } +} + +// loadDetailCmd loads the state-change history for the detail panel off the UI +// goroutine. View renders the cached result rather than querying the DB. +func (m *Model) loadDetailCmd(siteID int) tea.Cmd { + eng := m.engine + return func() tea.Msg { + return detailDataMsg{siteID: siteID, changes: eng.GetStateChanges(siteID, 5)} + } +} diff --git a/internal/tui/messages.go b/internal/tui/messages.go new file mode 100644 index 0000000..6ba2eff --- /dev/null +++ b/internal/tui/messages.go @@ -0,0 +1,35 @@ +package tui + +import ( + "time" + + "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" +) + +// tabRefreshTTL bounds how often the DB-backed tab data (alerts, users, nodes, +// maintenance windows) is reloaded. Live sites + logs come from in-memory +// engine copies and refresh every tick; the DB tables change rarely, so a 5s +// floor keeps tab-bar counts fresh without a per-second query storm. +const tabRefreshTTL = 5 * time.Second + +// tickMsg is the once-per-second heartbeat. A named type (vs a bare time.Time) +// keeps it from colliding with any other time-valued message. +type tickMsg time.Time + +// tabDataMsg carries the result of an async load of the DB-backed tab tables. +// On err, the model keeps its previous data and logs — never wiping the view on +// a transient store error. +type tabDataMsg struct { + alerts []models.AlertConfig + users []models.User + nodes []models.ProbeNode + maint []models.MaintenanceWindow + err error +} + +// detailDataMsg carries the state-change history for the detail panel, loaded +// when the panel is opened so View never touches the database. +type detailDataMsg struct { + siteID int + changes []models.StateChange +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 7aeee69..acd06dd 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -140,6 +140,11 @@ type Model struct { users []models.User nodes []models.ProbeNode maintenanceWindows []models.MaintenanceWindow + lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle) + + // detail-panel state-change history, loaded on enter so View does no DB IO + detailChanges []models.StateChange + detailChangesSiteID int filterMode bool filterText string @@ -189,6 +194,12 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri } } -func (m Model) Init() tea.Cmd { - return tea.Batch(tea.ClearScreen, tea.Tick(time.Second, func(t time.Time) tea.Msg { return t })) +// tickCmd schedules the next one-second heartbeat. +func tickCmd() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) +} + +func (m Model) Init() tea.Cmd { + // Load tab data immediately so the dashboard isn't empty for the first second. + return tea.Batch(tea.ClearScreen, tickCmd(), m.loadTabDataCmd()) } diff --git a/internal/tui/update.go b/internal/tui/update.go index 207ea59..235c842 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -15,8 +15,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: return m.handleResize(msg) - case time.Time: - return m.handleTick(msg) + case tickMsg: + return m.handleTick(time.Time(msg)) + case tabDataMsg: + return m.handleTabData(msg) + case detailDataMsg: + m.detailChanges = msg.changes + m.detailChangesSiteID = msg.siteID + return m, nil } if m.state == stateConfirmDelete { @@ -65,11 +71,12 @@ func (m *Model) handleConfirmDelete(msg tea.Msg) (tea.Model, tea.Cmd) { } m.adjustCursor(len(m.users) - 1) } - m.refreshData() + m.refreshLive() m.state = stateDashboard if m.deleteTab == 5 { m.state = stateUsers } + return m, m.loadTabDataCmd() case "n", "N", "esc": m.state = stateDashboard if m.deleteTab == 5 { @@ -106,9 +113,9 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.huhForm.State == huh.StateCompleted { m.submitForm() - m.refreshData() + m.refreshLive() m.huhForm = nil - return m, nil + return m, m.loadTabDataCmd() } return m, formCmd } @@ -136,11 +143,47 @@ func (m *Model) handleResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { } func (m *Model) handleTick(t time.Time) (tea.Model, tea.Cmd) { - m.refreshData() + m.refreshLive() m.tickCount++ target := sinApprox(float64(m.tickCount)*0.3)*0.5 + 0.5 m.pulsePos, m.pulseVel = m.pulseSpring.Update(m.pulsePos, m.pulseVel, target) - return m, tea.Tick(time.Second, func(t time.Time) tea.Msg { return t }) + + cmds := []tea.Cmd{tickCmd()} + if t.Sub(m.lastTabLoad) > tabRefreshTTL { + m.lastTabLoad = t + cmds = append(cmds, m.loadTabDataCmd()) + } + return m, tea.Batch(cmds...) +} + +// handleTabData folds an async tab-data load into the model. On error the +// previous data is kept and the failure logged, so a transient store error +// never blanks the view. +func (m *Model) handleTabData(msg tabDataMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + m.engine.AddLog("Tab data refresh failed: " + msg.err.Error()) + return m, nil + } + m.alerts = msg.alerts + if m.isAdmin { + m.users = msg.users + } + m.nodes = msg.nodes + m.maintenanceWindows = msg.maint + m.clampCursor() + return m, nil +} + +// testAlertCmd sends a test notification off the UI goroutine; the outcome +// surfaces through the engine log (picked up by the next refreshLive). +func (m *Model) testAlertCmd(id int, name string) tea.Cmd { + eng := m.engine + return func() tea.Msg { + if err := eng.TestAlert(id); err != nil { + eng.AddLog(fmt.Sprintf("Test alert failed (%s): %v", name, err)) + } + return nil + } } func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { @@ -240,7 +283,7 @@ func (m *Model) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.filterText = "" m.cursor = 0 m.tableOffset = 0 - m.refreshData() + m.refreshLive() case "enter": m.filterMode = false case "backspace": @@ -248,7 +291,7 @@ func (m *Model) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.filterText = m.filterText[:len(m.filterText)-1] m.cursor = 0 m.tableOffset = 0 - m.refreshData() + m.refreshLive() } case "ctrl+c": return m, tea.Quit @@ -257,7 +300,7 @@ func (m *Model) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.filterText += msg.String() m.cursor = 0 m.tableOffset = 0 - m.refreshData() + m.refreshLive() } } return m, nil @@ -459,19 +502,14 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "t": if m.currentTab == 1 && len(m.alerts) > 0 { a := m.alerts[m.cursor] - go func() { - if err := m.engine.TestAlert(a.ID); err != nil { - m.engine.AddLog(fmt.Sprintf("Test alert failed (%s): %v", a.Name, err)) - } - }() - return m, nil + return m, m.testAlertCmd(a.ID, a.Name) } case " ": if m.currentTab == 0 && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" { gid := m.sites[m.cursor].ID m.collapsed[gid] = !m.collapsed[gid] saveCollapsed(m.store, m.collapsed) - m.refreshData() + m.refreshLive() } case "p": if m.currentTab == 0 && len(m.sites) > 0 { @@ -479,11 +517,12 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.engine.ToggleSitePause(site.ID) site.Paused = !site.Paused _ = m.store.UpdateSitePaused(site.ID, site.Paused) - m.refreshData() + m.refreshLive() } case "i": if m.currentTab == 0 && len(m.sites) > 0 { m.state = stateDetail + return m, m.loadDetailCmd(m.sites[m.cursor].ID) } else if m.currentTab == 1 && len(m.alerts) > 0 { m.state = stateAlertDetail } @@ -496,7 +535,8 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if err := m.store.EndMaintenanceWindow(mw.ID); err != nil { m.engine.AddLog("End maintenance failed: " + err.Error()) } - m.refreshData() + m.refreshLive() + return m, m.loadTabDataCmd() } } case "T": diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go new file mode 100644 index 0000000..43a13ba --- /dev/null +++ b/internal/tui/update_test.go @@ -0,0 +1,222 @@ +package tui + +import ( + "testing" + "time" + + "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" + "gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" + zone "github.com/lrstanley/bubblezone" +) + +// --- minimal Store mock for TUI data-flow tests --- + +type tuiMockStore struct { + alerts []models.AlertConfig + users []models.User + nodes []models.ProbeNode + maint []models.MaintenanceWindow + stateChanges []models.StateChange + stateChangeCalls int // counts GetStateChanges hits (to prove View does no IO) +} + +func (m *tuiMockStore) GetAllAlerts() ([]models.AlertConfig, error) { return m.alerts, nil } +func (m *tuiMockStore) GetAllUsers() ([]models.User, error) { return m.users, nil } +func (m *tuiMockStore) GetAllNodes() ([]models.ProbeNode, error) { return m.nodes, nil } +func (m *tuiMockStore) GetStateChanges(int, int) ([]models.StateChange, error) { + m.stateChangeCalls++ + return m.stateChanges, nil +} +func (m *tuiMockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) { + return m.maint, nil +} + +func (m *tuiMockStore) Init() error { return nil } +func (m *tuiMockStore) GetSites() ([]models.Site, error) { return nil, nil } +func (m *tuiMockStore) AddSite(models.Site) error { return nil } +func (m *tuiMockStore) UpdateSite(models.Site) error { return nil } +func (m *tuiMockStore) UpdateSitePaused(int, bool) error { return nil } +func (m *tuiMockStore) DeleteSite(int) error { return nil } +func (m *tuiMockStore) GetAlert(int) (models.AlertConfig, error) { return models.AlertConfig{}, nil } +func (m *tuiMockStore) AddAlert(string, string, map[string]string) error { return nil } +func (m *tuiMockStore) UpdateAlert(int, string, string, map[string]string) error { return nil } +func (m *tuiMockStore) DeleteAlert(int) error { return nil } +func (m *tuiMockStore) GetSiteByName(string) (models.Site, error) { return models.Site{}, nil } +func (m *tuiMockStore) GetAlertByName(string) (models.AlertConfig, error) { + return models.AlertConfig{}, nil +} +func (m *tuiMockStore) AddSiteReturningID(models.Site) (int, error) { return 0, nil } +func (m *tuiMockStore) AddAlertReturningID(string, string, map[string]string) (int, error) { + return 0, nil +} +func (m *tuiMockStore) AddUser(string, string, string) error { return nil } +func (m *tuiMockStore) UpdateUser(int, string, string, string) error { return nil } +func (m *tuiMockStore) DeleteUser(int) error { return nil } +func (m *tuiMockStore) SaveCheck(int, int64, bool) error { return nil } +func (m *tuiMockStore) SaveCheckFromNode(int, string, int64, bool) error { + return nil +} +func (m *tuiMockStore) LoadAllHistory(int) (map[int][]models.CheckRecord, error) { + return nil, nil +} +func (m *tuiMockStore) PruneCheckHistory() error { return nil } +func (m *tuiMockStore) SaveStateChange(int, string, string, string) error { return nil } +func (m *tuiMockStore) GetStateChangesSince(int, time.Time) ([]models.StateChange, error) { + return nil, nil +} +func (m *tuiMockStore) PruneStateChanges() error { return nil } +func (m *tuiMockStore) RegisterNode(models.ProbeNode) error { return nil } +func (m *tuiMockStore) GetNode(string) (models.ProbeNode, error) { return models.ProbeNode{}, nil } +func (m *tuiMockStore) UpdateNodeLastSeen(string) error { return nil } +func (m *tuiMockStore) DeleteNode(string) error { return nil } +func (m *tuiMockStore) LoadAlertHealth() (map[int]models.AlertHealthRecord, error) { + return nil, nil +} +func (m *tuiMockStore) SaveAlertHealth(models.AlertHealthRecord) error { return nil } +func (m *tuiMockStore) SaveLog(string) error { return nil } +func (m *tuiMockStore) LoadLogs(int) ([]string, error) { return nil, nil } +func (m *tuiMockStore) PruneLogs() error { return nil } +func (m *tuiMockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) { + return nil, nil +} +func (m *tuiMockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { return nil } +func (m *tuiMockStore) EndMaintenanceWindow(int) error { return nil } +func (m *tuiMockStore) DeleteMaintenanceWindow(int) error { return nil } +func (m *tuiMockStore) PruneExpiredMaintenanceWindows(time.Duration) (int64, error) { + return 0, nil +} +func (m *tuiMockStore) IsMonitorInMaintenance(int) (bool, error) { return false, nil } +func (m *tuiMockStore) GetPreference(string) (string, error) { return "", nil } +func (m *tuiMockStore) SetPreference(string, string) error { return nil } +func (m *tuiMockStore) ExportData() (models.Backup, error) { return models.Backup{}, nil } +func (m *tuiMockStore) ImportData(models.Backup) error { return nil } +func (m *tuiMockStore) Close() error { return nil } + +func newTestModel(ms *tuiMockStore) Model { + return Model{ + store: ms, + engine: monitor.NewEngine(ms), + isAdmin: true, + zones: zone.New(), + detailChangesSiteID: -1, + } +} + +// --- Tests --- + +func TestLoadTabDataCmd_ReturnsRows(t *testing.T) { + ms := &tuiMockStore{ + alerts: []models.AlertConfig{{ID: 1, Name: "a"}}, + nodes: []models.ProbeNode{{ID: "n1"}}, + users: []models.User{{Username: "u"}}, + maint: []models.MaintenanceWindow{{ID: 7}}, + } + m := newTestModel(ms) + + msg := m.loadTabDataCmd()() + td, ok := msg.(tabDataMsg) + if !ok { + t.Fatalf("expected tabDataMsg, got %T", msg) + } + if len(td.alerts) != 1 || len(td.nodes) != 1 || len(td.users) != 1 || len(td.maint) != 1 { + t.Errorf("unexpected counts: %+v", td) + } + if td.err != nil { + t.Errorf("unexpected err: %v", td.err) + } +} + +func TestHandleTabData_PopulatesModel(t *testing.T) { + m := newTestModel(&tuiMockStore{}) + msg := tabDataMsg{ + alerts: []models.AlertConfig{{ID: 1}}, + nodes: []models.ProbeNode{{ID: "n"}}, + users: []models.User{{Username: "u"}}, + maint: []models.MaintenanceWindow{{ID: 2}}, + } + updated, _ := m.handleTabData(msg) + got := updated.(*Model) + if len(got.alerts) != 1 || len(got.nodes) != 1 || len(got.users) != 1 || len(got.maintenanceWindows) != 1 { + t.Errorf("model not populated: alerts=%d nodes=%d users=%d maint=%d", + len(got.alerts), len(got.nodes), len(got.users), len(got.maintenanceWindows)) + } +} + +func TestHandleTabData_ErrorKeepsPreviousData(t *testing.T) { + m := newTestModel(&tuiMockStore{}) + m.alerts = []models.AlertConfig{{ID: 99}} // pre-existing data + updated, _ := m.handleTabData(tabDataMsg{err: errSentinel}) + got := updated.(*Model) + if len(got.alerts) != 1 || got.alerts[0].ID != 99 { + t.Error("transient error wiped previous tab data") + } +} + +var errSentinel = &stubErr{} + +type stubErr struct{} + +func (*stubErr) Error() string { return "boom" } + +func TestDetailLoad_CachesAndViewDoesNoIO(t *testing.T) { + ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}} + m := newTestModel(ms) + m.sites = []models.Site{{ID: 1, Name: "site", Status: "DOWN"}} + m.cursor = 0 + m.state = stateDetail + m.termWidth = 120 + m.termHeight = 40 + + // Entering detail dispatches the load Cmd. + cmd := m.loadDetailCmd(1) + if cmd == nil { + t.Fatal("loadDetailCmd returned nil") + } + msg := cmd() + dd, ok := msg.(detailDataMsg) + if !ok || dd.siteID != 1 || len(dd.changes) != 1 { + t.Fatalf("unexpected detailDataMsg: %+v", msg) + } + if ms.stateChangeCalls != 1 { + t.Fatalf("expected exactly 1 store hit from the load Cmd, got %d", ms.stateChangeCalls) + } + + // Apply the msg through Update (caches into the model). + updated, _ := m.Update(dd) + m = updated.(Model) + if m.detailChangesSiteID != 1 || len(m.detailChanges) != 1 { + t.Fatalf("detail changes not cached: id=%d n=%d", m.detailChangesSiteID, len(m.detailChanges)) + } + + // Render the detail panel several times — it must read the cache, not the DB. + for i := 0; i < 3; i++ { + _ = m.viewDetailPanel() + } + if ms.stateChangeCalls != 1 { + t.Errorf("View performed DB IO: store hit %d times (want 1, from the Cmd only)", ms.stateChangeCalls) + } +} + +func TestHandleTick_ThrottlesTabLoad(t *testing.T) { + m := newTestModel(&tuiMockStore{}) + mp := &m + + t0 := time.Unix(1_000_000, 0) + mp.handleTick(t0) + if !mp.lastTabLoad.Equal(t0) { + t.Fatalf("first tick should dispatch + stamp lastTabLoad=%v, got %v", t0, mp.lastTabLoad) + } + + // Within the TTL → no new dispatch, stamp unchanged. + mp.handleTick(t0.Add(time.Second)) + if !mp.lastTabLoad.Equal(t0) { + t.Errorf("tick within TTL should not re-dispatch; lastTabLoad=%v", mp.lastTabLoad) + } + + // Past the TTL → dispatch again. + t2 := t0.Add(tabRefreshTTL + time.Second) + mp.handleTick(t2) + if !mp.lastTabLoad.Equal(t2) { + t.Errorf("tick past TTL should re-dispatch; lastTabLoad=%v want %v", mp.lastTabLoad, t2) + } +} diff --git a/internal/tui/view_detail.go b/internal/tui/view_detail.go index 1bb284d..7a02d03 100644 --- a/internal/tui/view_detail.go +++ b/internal/tui/view_detail.go @@ -179,7 +179,11 @@ func (m Model) viewDetailPanel() string { } } - stateChanges := m.engine.GetStateChanges(site.ID, 5) + // Loaded on panel-enter (loadDetailCmd) and cached, so View does no DB IO. + var stateChanges []models.StateChange + if m.detailChangesSiteID == site.ID { + stateChanges = m.detailChanges + } if len(stateChanges) > 0 { b.WriteString("\n" + subtleStyle.Render(" STATE CHANGES") + "\n") for i, sc := range stateChanges {