83ec6bee42
viewLogsTab filtered logViewport.View() — the visible window — so the entry count showed the window size and hidden lines reappeared while scrolling. Filter and render now happen at content-set time from engine.GetLogs(); the view only reads stored counts.
227 lines
6.4 KiB
Go
227 lines
6.4 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 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
|
|
|
|
detailSparkWidth = 40
|
|
)
|
|
|
|
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
|
|
selectedID 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
|
|
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
|
|
|
|
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())
|
|
}
|