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).
266 lines
7.1 KiB
Go
266 lines
7.1 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"time"
|
|
|
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
|
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/harmonica"
|
|
"github.com/charmbracelet/huh"
|
|
"github.com/charmbracelet/lipgloss"
|
|
zone "github.com/lrstanley/bubblezone"
|
|
)
|
|
|
|
// styles holds every theme-derived lipgloss style. Each Model owns its own
|
|
// instance (built by newStyles), so concurrent SSH sessions can run different
|
|
// themes without racing on shared package state. Never mutate after creation.
|
|
type styles struct {
|
|
subtleStyle lipgloss.Style
|
|
specialStyle lipgloss.Style
|
|
warnStyle lipgloss.Style
|
|
staleStyle lipgloss.Style
|
|
dangerStyle lipgloss.Style
|
|
titleStyle lipgloss.Style
|
|
activeTab lipgloss.Style
|
|
inactiveTab lipgloss.Style
|
|
|
|
sparkSuccess lipgloss.TerminalColor
|
|
sparkWarning lipgloss.TerminalColor
|
|
sparkDanger lipgloss.TerminalColor
|
|
|
|
tableHeaderStyle lipgloss.Style
|
|
tableCellStyle lipgloss.Style
|
|
tableSelectedStyle lipgloss.Style
|
|
tableBorderStyle lipgloss.Style
|
|
tableZebraStyle lipgloss.Style
|
|
|
|
siteGroupStyle lipgloss.Style
|
|
maintStyle lipgloss.Style
|
|
}
|
|
|
|
func newStyles(t Theme) *styles {
|
|
return &styles{
|
|
subtleStyle: lipgloss.NewStyle().Foreground(t.Subtle).Faint(true),
|
|
specialStyle: lipgloss.NewStyle().Foreground(t.Success),
|
|
warnStyle: lipgloss.NewStyle().Foreground(t.Warning).Bold(true),
|
|
staleStyle: lipgloss.NewStyle().Foreground(t.Stale).Faint(true),
|
|
dangerStyle: lipgloss.NewStyle().Foreground(t.Danger).Bold(true),
|
|
titleStyle: lipgloss.NewStyle().Foreground(t.Accent).Bold(true),
|
|
activeTab: lipgloss.NewStyle().Background(t.Surface).Foreground(t.Accent).Bold(true).Padding(0, 1),
|
|
inactiveTab: lipgloss.NewStyle().Padding(0, 1).Foreground(t.Muted).Faint(true),
|
|
|
|
sparkSuccess: t.Success,
|
|
sparkWarning: t.Warning,
|
|
sparkDanger: t.Danger,
|
|
|
|
tableHeaderStyle: lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Padding(0, 1),
|
|
tableCellStyle: lipgloss.NewStyle().Padding(0, 1),
|
|
tableSelectedStyle: lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.SelectedFg).Background(t.SelectedBg),
|
|
tableBorderStyle: lipgloss.NewStyle().Foreground(t.Border).Faint(true),
|
|
tableZebraStyle: lipgloss.NewStyle().Padding(0, 1).Background(t.ZebraBg),
|
|
|
|
siteGroupStyle: lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.Accent),
|
|
maintStyle: lipgloss.NewStyle().Foreground(t.Purple),
|
|
}
|
|
}
|
|
|
|
var pulseFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
|
|
|
const (
|
|
chromePadV = 2 // outer Padding(1,2): 1 top + 1 bottom
|
|
chromePadH = 4 // outer Padding(1,2): 2 left + 2 right
|
|
chromeHeader = 1 // tab bar line
|
|
chromeGaps = 2 // "\n" separators: before content + before footer
|
|
chromeFooter = 2 // footer: "\n" prefix + text line
|
|
chromeTable = 3 // renderTable "\n" prefix + top border + header + bottom border (lipgloss collapses two into three rendered lines)
|
|
chromeBase = chromePadV + chromeHeader + chromeGaps + chromeFooter + chromeTable
|
|
|
|
detailSparkWidth = 40
|
|
)
|
|
|
|
const (
|
|
tabMonitors = 0
|
|
tabMaint = 1
|
|
tabSettings = 2
|
|
)
|
|
|
|
const (
|
|
sectionAlerts = 0
|
|
sectionNodes = 1
|
|
sectionUsers = 2
|
|
)
|
|
|
|
const (
|
|
panelMonitors = 0
|
|
panelLogs = 1
|
|
panelDetail = 2
|
|
)
|
|
|
|
const (
|
|
sortStatus = 0
|
|
sortName = 1
|
|
sortLatency = 2
|
|
sortMax = 3
|
|
)
|
|
|
|
type sessionState int
|
|
|
|
const (
|
|
stateDashboard sessionState = iota
|
|
stateLogs
|
|
stateUsers
|
|
stateDetail
|
|
stateAlertDetail
|
|
stateFormSite
|
|
stateFormAlert
|
|
stateFormUser
|
|
stateConfirmDelete
|
|
stateFormMaint
|
|
stateHistory
|
|
stateSLA
|
|
)
|
|
|
|
type Model struct {
|
|
state sessionState
|
|
currentTab int
|
|
settingsSection int
|
|
cursor int
|
|
selectedID int
|
|
sortColumn int
|
|
sortAsc bool
|
|
tableOffset int
|
|
maxTableRows int
|
|
termWidth int
|
|
termHeight int
|
|
contentWidth int
|
|
focusedPanel int
|
|
logScrollOffset int
|
|
editID int
|
|
editToken string
|
|
|
|
huhForm *huh.Form
|
|
siteFormData *siteFormData
|
|
lastSiteType string
|
|
alertFormData *alertFormData
|
|
userFormData *userFormData
|
|
maintFormData *maintFormData
|
|
|
|
logViewport viewport.Model
|
|
logFilterImportant bool
|
|
logTotal int
|
|
logShown int
|
|
|
|
historyViewport viewport.Model
|
|
historyChanges []models.StateChange
|
|
historySiteName string
|
|
historySiteID int
|
|
|
|
slaViewport viewport.Model
|
|
slaReport monitor.SLAReport
|
|
slaDailyBreakdown []monitor.DayReport
|
|
slaSiteName string
|
|
slaSiteID int
|
|
slaPeriodIdx int
|
|
|
|
isAdmin bool
|
|
zones *zone.Manager
|
|
|
|
deleteID int
|
|
deleteName string
|
|
deleteTab int
|
|
|
|
ctx context.Context
|
|
collapsed map[int]bool
|
|
store store.Store
|
|
engine *monitor.Engine
|
|
theme Theme
|
|
themeIndex int
|
|
st *styles
|
|
|
|
// harmonica animation state
|
|
pulseSpring harmonica.Spring
|
|
pulsePos float64
|
|
pulseVel float64
|
|
tickCount int
|
|
|
|
sites []models.Site
|
|
alerts []models.AlertConfig
|
|
users []models.User
|
|
nodes []models.ProbeNode
|
|
maintenanceWindows []models.MaintenanceWindow
|
|
lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle)
|
|
tabSeq int // seq of the newest issued tab-data load
|
|
|
|
detailOpen bool
|
|
detailChanges []models.StateChange
|
|
detailChangesSiteID int
|
|
detailDailyDays []monitor.DayReport
|
|
detailViewport viewport.Model
|
|
|
|
filterMode bool
|
|
filterText string
|
|
|
|
sparkTooltipIdx int // clicked sparkline data index, -1 = none
|
|
|
|
// demoMode renders a stable status dot instead of the animated pulse so
|
|
// screenshots/recordings don't capture the spinner mid-frame. Set via UPTOP_DEMO=1.
|
|
demoMode bool
|
|
version string
|
|
}
|
|
|
|
func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor.Engine, version string) Model {
|
|
vpLogs := viewport.New(100, 20)
|
|
vpLogs.SetContent("Waiting for logs...")
|
|
z := zone.New()
|
|
spring := harmonica.NewSpring(harmonica.FPS(10), 6.0, 0.4)
|
|
collapsed := loadCollapsed(ctx, s)
|
|
|
|
themeName, _ := s.GetPreference(ctx, "theme")
|
|
theme := themeByName(themeName)
|
|
themeIdx := 0
|
|
for i, t := range themes {
|
|
if t.Name == theme.Name {
|
|
themeIdx = i
|
|
break
|
|
}
|
|
}
|
|
|
|
detailPref, _ := s.GetPreference(ctx, "detail_open")
|
|
|
|
return Model{
|
|
ctx: ctx,
|
|
state: stateDashboard,
|
|
logViewport: vpLogs,
|
|
maxTableRows: 5,
|
|
isAdmin: isAdmin,
|
|
store: s,
|
|
engine: eng,
|
|
zones: z,
|
|
pulseSpring: spring,
|
|
collapsed: collapsed,
|
|
theme: theme,
|
|
themeIndex: themeIdx,
|
|
st: newStyles(theme),
|
|
detailOpen: detailPref == "true",
|
|
demoMode: os.Getenv("UPTOP_DEMO") == "1",
|
|
version: version,
|
|
sparkTooltipIdx: -1,
|
|
}
|
|
}
|
|
|
|
// 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())
|
|
}
|