fix(tui): move blocking DB IO out of Update/View into tea.Cmds #101
+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"))
|
||||
|
||||
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)
|
||||
m.clampCursor()
|
||||
}
|
||||
|
||||
// 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