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/models"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
|
|
||||||
func loadCollapsed(s store.Store) map[int]bool {
|
func loadCollapsed(s store.Store) map[int]bool {
|
||||||
@@ -80,41 +81,24 @@ func filterSites(sites []models.Site, needle string) []models.Site {
|
|||||||
return filtered
|
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()
|
allSites := m.engine.GetAllSites()
|
||||||
ordered := sortSitesForDisplay(allSites, m.collapsed)
|
ordered := sortSitesForDisplay(allSites, m.collapsed)
|
||||||
if m.filterText != "" {
|
if m.filterText != "" {
|
||||||
ordered = filterSites(ordered, m.filterText)
|
ordered = filterSites(ordered, m.filterText)
|
||||||
}
|
}
|
||||||
m.sites = ordered
|
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.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 {
|
if listLen > 0 && m.cursor >= listLen {
|
||||||
m.cursor = listLen - 1
|
m.cursor = listLen - 1
|
||||||
}
|
}
|
||||||
@@ -122,3 +106,42 @@ func (m *Model) refreshData() {
|
|||||||
m.tableOffset = m.cursor
|
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
|
users []models.User
|
||||||
nodes []models.ProbeNode
|
nodes []models.ProbeNode
|
||||||
maintenanceWindows []models.MaintenanceWindow
|
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
|
filterMode bool
|
||||||
filterText string
|
filterText string
|
||||||
@@ -189,6 +194,12 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd {
|
// tickCmd schedules the next one-second heartbeat.
|
||||||
return tea.Batch(tea.ClearScreen, tea.Tick(time.Second, func(t time.Time) tea.Msg { return t }))
|
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) {
|
switch msg := msg.(type) {
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
return m.handleResize(msg)
|
return m.handleResize(msg)
|
||||||
case time.Time:
|
case tickMsg:
|
||||||
return m.handleTick(msg)
|
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 {
|
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.adjustCursor(len(m.users) - 1)
|
||||||
}
|
}
|
||||||
m.refreshData()
|
m.refreshLive()
|
||||||
m.state = stateDashboard
|
m.state = stateDashboard
|
||||||
if m.deleteTab == 5 {
|
if m.deleteTab == 5 {
|
||||||
m.state = stateUsers
|
m.state = stateUsers
|
||||||
}
|
}
|
||||||
|
return m, m.loadTabDataCmd()
|
||||||
case "n", "N", "esc":
|
case "n", "N", "esc":
|
||||||
m.state = stateDashboard
|
m.state = stateDashboard
|
||||||
if m.deleteTab == 5 {
|
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 {
|
if m.huhForm.State == huh.StateCompleted {
|
||||||
m.submitForm()
|
m.submitForm()
|
||||||
m.refreshData()
|
m.refreshLive()
|
||||||
m.huhForm = nil
|
m.huhForm = nil
|
||||||
return m, nil
|
return m, m.loadTabDataCmd()
|
||||||
}
|
}
|
||||||
return m, formCmd
|
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) {
|
func (m *Model) handleTick(t time.Time) (tea.Model, tea.Cmd) {
|
||||||
m.refreshData()
|
m.refreshLive()
|
||||||
m.tickCount++
|
m.tickCount++
|
||||||
target := sinApprox(float64(m.tickCount)*0.3)*0.5 + 0.5
|
target := sinApprox(float64(m.tickCount)*0.3)*0.5 + 0.5
|
||||||
m.pulsePos, m.pulseVel = m.pulseSpring.Update(m.pulsePos, m.pulseVel, target)
|
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) {
|
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.filterText = ""
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
m.tableOffset = 0
|
m.tableOffset = 0
|
||||||
m.refreshData()
|
m.refreshLive()
|
||||||
case "enter":
|
case "enter":
|
||||||
m.filterMode = false
|
m.filterMode = false
|
||||||
case "backspace":
|
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.filterText = m.filterText[:len(m.filterText)-1]
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
m.tableOffset = 0
|
m.tableOffset = 0
|
||||||
m.refreshData()
|
m.refreshLive()
|
||||||
}
|
}
|
||||||
case "ctrl+c":
|
case "ctrl+c":
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
@@ -257,7 +300,7 @@ func (m *Model) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.filterText += msg.String()
|
m.filterText += msg.String()
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
m.tableOffset = 0
|
m.tableOffset = 0
|
||||||
m.refreshData()
|
m.refreshLive()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -459,19 +502,14 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "t":
|
case "t":
|
||||||
if m.currentTab == 1 && len(m.alerts) > 0 {
|
if m.currentTab == 1 && len(m.alerts) > 0 {
|
||||||
a := m.alerts[m.cursor]
|
a := m.alerts[m.cursor]
|
||||||
go func() {
|
return m, m.testAlertCmd(a.ID, a.Name)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
case " ":
|
case " ":
|
||||||
if m.currentTab == 0 && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" {
|
if m.currentTab == 0 && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" {
|
||||||
gid := m.sites[m.cursor].ID
|
gid := m.sites[m.cursor].ID
|
||||||
m.collapsed[gid] = !m.collapsed[gid]
|
m.collapsed[gid] = !m.collapsed[gid]
|
||||||
saveCollapsed(m.store, m.collapsed)
|
saveCollapsed(m.store, m.collapsed)
|
||||||
m.refreshData()
|
m.refreshLive()
|
||||||
}
|
}
|
||||||
case "p":
|
case "p":
|
||||||
if m.currentTab == 0 && len(m.sites) > 0 {
|
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)
|
m.engine.ToggleSitePause(site.ID)
|
||||||
site.Paused = !site.Paused
|
site.Paused = !site.Paused
|
||||||
_ = m.store.UpdateSitePaused(site.ID, site.Paused)
|
_ = m.store.UpdateSitePaused(site.ID, site.Paused)
|
||||||
m.refreshData()
|
m.refreshLive()
|
||||||
}
|
}
|
||||||
case "i":
|
case "i":
|
||||||
if m.currentTab == 0 && len(m.sites) > 0 {
|
if m.currentTab == 0 && len(m.sites) > 0 {
|
||||||
m.state = stateDetail
|
m.state = stateDetail
|
||||||
|
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||||
} else if m.currentTab == 1 && len(m.alerts) > 0 {
|
} else if m.currentTab == 1 && len(m.alerts) > 0 {
|
||||||
m.state = stateAlertDetail
|
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 {
|
if err := m.store.EndMaintenanceWindow(mw.ID); err != nil {
|
||||||
m.engine.AddLog("End maintenance failed: " + err.Error())
|
m.engine.AddLog("End maintenance failed: " + err.Error())
|
||||||
}
|
}
|
||||||
m.refreshData()
|
m.refreshLive()
|
||||||
|
return m, m.loadTabDataCmd()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "T":
|
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 {
|
if len(stateChanges) > 0 {
|
||||||
b.WriteString("\n" + subtleStyle.Render(" STATE CHANGES") + "\n")
|
b.WriteString("\n" + subtleStyle.Render(" STATE CHANGES") + "\n")
|
||||||
for i, sc := range stateChanges {
|
for i, sc := range stateChanges {
|
||||||
|
|||||||
Reference in New Issue
Block a user