a5fd3aec90
Thread context.Context from callers instead of creating context.Background() at every call site. Engine stores its lifecycle ctx for use by triggerAlert goroutines. TUI Model carries ctx for store operations in Cmd closures. CLI commands create a root ctx and thread it through openStore, seed functions, and init methods. Only two intentional context.Background() remain in non-root positions: engine constructor default (overridden by Start) and drainWrites (parent already cancelled at shutdown).
237 lines
6.5 KiB
Go
237 lines
6.5 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
)
|
|
|
|
func loadCollapsed(ctx context.Context, s store.Store) map[int]bool {
|
|
m := make(map[int]bool)
|
|
raw, err := s.GetPreference(ctx, "collapsed_groups")
|
|
if err != nil || raw == "" {
|
|
return m
|
|
}
|
|
var ids []int
|
|
if err := json.Unmarshal([]byte(raw), &ids); err != nil {
|
|
return m
|
|
}
|
|
for _, id := range ids {
|
|
m[id] = true
|
|
}
|
|
return m
|
|
}
|
|
|
|
// collapsedJSON snapshots the collapsed-group set for persistence. Marshaling
|
|
// happens on the UI goroutine so the write Cmd never reads the live map.
|
|
func collapsedJSON(collapsed map[int]bool) string {
|
|
var ids []int
|
|
for id, v := range collapsed {
|
|
if v {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
data, _ := json.Marshal(ids)
|
|
return string(data)
|
|
}
|
|
|
|
// writeCmd runs a store mutation off the UI goroutine. The closure must only
|
|
// capture values snapshotted in Update — never the model itself.
|
|
func writeCmd(op string, fn func() error) tea.Cmd {
|
|
return func() tea.Msg {
|
|
return writeDoneMsg{op: op, err: fn()}
|
|
}
|
|
}
|
|
|
|
func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool, sortCol int, sortAsc bool) []models.Site {
|
|
var groups, ungrouped []models.Site
|
|
children := make(map[int][]models.Site)
|
|
for _, s := range allSites {
|
|
if s.Type == "group" {
|
|
groups = append(groups, s)
|
|
} else if s.ParentID > 0 {
|
|
children[s.ParentID] = append(children[s.ParentID], s)
|
|
} else {
|
|
ungrouped = append(ungrouped, s)
|
|
}
|
|
}
|
|
sort.Slice(groups, func(i, j int) bool { return groups[i].ID < groups[j].ID })
|
|
|
|
sortSlice := func(s []models.Site) {
|
|
sort.Slice(s, func(i, j int) bool { return s[i].ID < s[j].ID })
|
|
sort.SliceStable(s, func(i, j int) bool {
|
|
var less bool
|
|
switch sortCol {
|
|
case sortName:
|
|
less = strings.ToLower(s[i].Name) < strings.ToLower(s[j].Name)
|
|
case sortLatency:
|
|
less = s[i].Latency < s[j].Latency
|
|
default:
|
|
less = siteOrder(s[i]) < siteOrder(s[j])
|
|
}
|
|
if !sortAsc {
|
|
return less
|
|
}
|
|
return !less
|
|
})
|
|
}
|
|
|
|
for pid := range children {
|
|
c := children[pid]
|
|
sortSlice(c)
|
|
children[pid] = c
|
|
}
|
|
sortSlice(ungrouped)
|
|
|
|
var ordered []models.Site
|
|
for _, g := range groups {
|
|
ordered = append(ordered, g)
|
|
if !collapsed[g.ID] {
|
|
ordered = append(ordered, children[g.ID]...)
|
|
}
|
|
}
|
|
ordered = append(ordered, ungrouped...)
|
|
return ordered
|
|
}
|
|
|
|
func filterSites(sites []models.Site, needle string) []models.Site {
|
|
lower := strings.ToLower(needle)
|
|
var filtered []models.Site
|
|
for _, s := range sites {
|
|
if strings.Contains(strings.ToLower(s.Name), lower) {
|
|
filtered = append(filtered, s)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// 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, m.sortColumn, m.sortAsc)
|
|
if m.filterText != "" {
|
|
ordered = filterSites(ordered, m.filterText)
|
|
}
|
|
m.sites = ordered
|
|
m.refreshLogContent()
|
|
|
|
if m.currentTab == tabMonitors && m.selectedID != 0 {
|
|
for i, s := range m.sites {
|
|
if s.ID == m.selectedID {
|
|
m.cursor = i
|
|
break
|
|
}
|
|
}
|
|
}
|
|
m.clampCursor()
|
|
}
|
|
|
|
func (m *Model) syncSelectedID() {
|
|
if m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
|
m.selectedID = m.sites[m.cursor].ID
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
if m.cursor < m.tableOffset {
|
|
m.tableOffset = m.cursor
|
|
}
|
|
}
|
|
|
|
// loadTabDataCmd returns a tea.Cmd that loads the DB-backed tab tables off the
|
|
// UI goroutine. Each call bumps tabSeq and stamps the reply with it, so
|
|
// handleTabData can drop out-of-order results from slower earlier loads. 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 {
|
|
m.tabSeq++
|
|
seq := m.tabSeq
|
|
st := m.store
|
|
ctx := m.ctx
|
|
isAdmin := m.isAdmin
|
|
return func() tea.Msg {
|
|
alerts, err := st.GetAllAlerts(ctx)
|
|
if err != nil {
|
|
return tabDataMsg{seq: seq, err: err}
|
|
}
|
|
var users []models.User
|
|
if isAdmin {
|
|
if users, err = st.GetAllUsers(ctx); err != nil {
|
|
return tabDataMsg{seq: seq, err: err}
|
|
}
|
|
}
|
|
nodes, err := st.GetAllNodes(ctx)
|
|
if err != nil {
|
|
return tabDataMsg{seq: seq, err: err}
|
|
}
|
|
maint, err := st.GetAllMaintenanceWindows(ctx, 100)
|
|
if err != nil {
|
|
return tabDataMsg{seq: seq, err: err}
|
|
}
|
|
return tabDataMsg{seq: seq, 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
|
|
ctx := m.ctx
|
|
var currentStatus models.Status
|
|
for _, s := range m.sites {
|
|
if s.ID == siteID {
|
|
currentStatus = s.Status
|
|
break
|
|
}
|
|
}
|
|
return func() tea.Msg {
|
|
changes := eng.GetStateChanges(ctx, siteID, 5)
|
|
now := time.Now()
|
|
allChanges := eng.GetStateChangesSince(ctx, siteID, now.Add(-stateHistoryLookback))
|
|
daily := monitor.ComputeDailyBreakdown(allChanges, currentStatus, stateHistoryDays, now)
|
|
return detailDataMsg{siteID: siteID, changes: changes, dailyDays: daily}
|
|
}
|
|
}
|
|
|
|
// loadHistoryCmd loads the full state-change history for the history view off
|
|
// the UI goroutine.
|
|
func (m *Model) loadHistoryCmd(siteID int) tea.Cmd {
|
|
eng := m.engine
|
|
ctx := m.ctx
|
|
return func() tea.Msg {
|
|
return historyDataMsg{siteID: siteID, changes: eng.GetStateChanges(ctx, siteID, stateHistoryLimit)}
|
|
}
|
|
}
|
|
|
|
// loadSLACmd loads the state changes backing the SLA view off the UI
|
|
// goroutine. The reply carries the request's site and period so a stale reply
|
|
// can be recognized and dropped.
|
|
func (m *Model) loadSLACmd(siteID, periodIdx int) tea.Cmd {
|
|
eng := m.engine
|
|
ctx := m.ctx
|
|
since := time.Now().Add(-slaPeriods[periodIdx].duration)
|
|
return func() tea.Msg {
|
|
return slaDataMsg{
|
|
siteID: siteID,
|
|
periodIdx: periodIdx,
|
|
changes: eng.GetStateChangesSince(ctx, siteID, since),
|
|
}
|
|
}
|
|
}
|