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 string sparkWarning string sparkDanger string 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), specialStyle: lipgloss.NewStyle().Foreground(t.Success), warnStyle: lipgloss.NewStyle().Foreground(t.Warning), staleStyle: lipgloss.NewStyle().Foreground(t.Stale), dangerStyle: lipgloss.NewStyle().Foreground(t.Danger), 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), sparkSuccess: string(t.Success), sparkWarning: string(t.Warning), sparkDanger: string(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), 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 ) 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 cursor int tableOffset int maxTableRows int termWidth int termHeight int editID int editToken string huhForm *huh.Form siteFormData *siteFormData alertFormData *alertFormData userFormData *userFormData maintFormData *maintFormData logViewport viewport.Model logFilterImportant bool 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 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 // detail-panel state-change history, loaded on enter so View does no DB IO detailChanges []models.StateChange detailChangesSiteID int 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(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(s) themeName, _ := s.GetPreference(context.Background(), "theme") theme := themeByName(themeName) themeIdx := 0 for i, t := range themes { if t.Name == theme.Name { themeIdx = i break } } return Model{ 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), 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()) }