fix(tui): move blocking DB IO out of Update/View into tea.Cmds
The TUI ran database queries on the UI goroutine: handleTick called refreshData every second, which issued four blocking SQLite queries (GetAllAlerts/GetAllUsers/GetAllNodes/GetAllMaintenanceWindows) and swallowed their errors; viewDetailPanel ran GetStateChanges — a DB query — inside View(), on every render (tick, keypress, mouse). A slow disk stalled input and animation. Split refreshData into refreshLive() (in-memory engine copies only — sites + logs — safe every tick) and loadTabDataCmd(), a tea.Cmd that loads the four DB-backed tables off the UI goroutine and returns a tabDataMsg. handleTick now refreshes live state every tick but dispatches the tab-data load only when older than tabRefreshTTL (5s), so tab-bar counts stay fresh without a per-second query storm. Errors surface to the log instead of being dropped, and a transient failure keeps the previous data rather than blanking the view. The detail panel's state-change history is loaded once on enter via loadDetailCmd and cached on the model; viewDetailPanel reads the cache, so View no longer touches the database. Init kicks an initial load so the dashboard isn't empty on the first frame, and the bare time.Time tick message is now a named tickMsg (no cross-message collision). The test-alert handler's raw goroutine becomes a tea.Cmd. Adds the package's first Update()-driven tests: tab-data load + apply, error-keeps-previous-data, detail cache with a store-hit counter proving View does zero IO across repeated renders, and the handleTick throttle. Full suite green under -race; golangci-lint clean.
This commit was merged in pull request #101.
This commit is contained in:
+50
-27
@@ -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)}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+13
-2
@@ -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())
|
||||
}
|
||||
|
||||
+59
-19
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user