fix(tui): move blocking DB IO out of Update/View into tea.Cmds
CI / test (pull_request) Successful in 2m38s
CI / lint (pull_request) Successful in 56s
CI / vulncheck (pull_request) Successful in 51s

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:
2026-06-10 21:14:47 -04:00
parent 809620340e
commit f349d0dfd1
6 changed files with 384 additions and 49 deletions
+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
}