fix(tui): move blocking DB IO out of Update/View into tea.Cmds #101

Merged
lerko merged 1 commits from fix/tui-io-mvu into main 2026-06-11 01:26:06 +00:00
6 changed files with 384 additions and 49 deletions
+50 -27
View File
@@ -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) // clampCursor keeps the cursor and scroll offset within the current tab's list.
switch m.currentTab { func (m *Model) clampCursor() {
case 1: listLen := m.currentListLen()
listLen = len(m.alerts)
case 3:
listLen = len(m.nodes)
case 4:
listLen = len(m.maintenanceWindows)
case 5:
listLen = len(m.users)
}
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)}
}
}
+35
View File
@@ -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
View File
@@ -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
View File
@@ -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":
+222
View File
@@ -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)
}
}
+5 -1
View File
@@ -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 {