9d12e3ecf1
- Module path: gitea.lerkolabs.com/lerko/uptop - Binary: cmd/uptop/ - All imports updated to full module path - Env vars: UPKEEP_* → UPTOP_* - Prometheus metrics: upkeep_* → uptop_* - Default DB: uptop.db - Docker image: lerko/uptop - All docs, compose files, CI updated Only remaining "go-upkeep" reference is the fork attribution in README.
964 lines
24 KiB
Go
964 lines
24 KiB
Go
package tui
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
|
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
|
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
var (
|
|
subtleStyle lipgloss.Style
|
|
specialStyle lipgloss.Style
|
|
warnStyle lipgloss.Style
|
|
dangerStyle lipgloss.Style
|
|
titleStyle lipgloss.Style
|
|
activeTab lipgloss.Style
|
|
inactiveTab lipgloss.Style
|
|
)
|
|
|
|
func applyTheme(t Theme) {
|
|
subtleStyle = lipgloss.NewStyle().Foreground(t.Subtle)
|
|
specialStyle = lipgloss.NewStyle().Foreground(t.Success)
|
|
warnStyle = lipgloss.NewStyle().Foreground(t.Warning)
|
|
dangerStyle = lipgloss.NewStyle().Foreground(t.Danger)
|
|
titleStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
|
|
activeTab = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, true, false).BorderForeground(t.Accent).Foreground(t.Accent).Bold(true).Padding(0, 1)
|
|
inactiveTab = lipgloss.NewStyle().Padding(0, 1).Foreground(t.Muted)
|
|
|
|
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
|
|
stateFormSite
|
|
stateFormAlert
|
|
stateFormUser
|
|
stateConfirmDelete
|
|
stateFormMaint
|
|
)
|
|
|
|
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
|
|
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
|
|
|
|
// 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
|
|
|
|
filterMode bool
|
|
filterText string
|
|
}
|
|
|
|
func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) 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("theme")
|
|
theme := themeByName(themeName)
|
|
themeIdx := 0
|
|
for i, t := range themes {
|
|
if t.Name == theme.Name {
|
|
themeIdx = i
|
|
break
|
|
}
|
|
}
|
|
|
|
applyTheme(theme)
|
|
|
|
return Model{
|
|
state: stateDashboard,
|
|
logViewport: vpLogs,
|
|
maxTableRows: 5,
|
|
isAdmin: isAdmin,
|
|
store: s,
|
|
engine: eng,
|
|
zones: z,
|
|
pulseSpring: spring,
|
|
collapsed: collapsed,
|
|
theme: theme,
|
|
themeIndex: themeIdx,
|
|
}
|
|
}
|
|
|
|
func loadCollapsed(s store.Store) map[int]bool {
|
|
m := make(map[int]bool)
|
|
raw, err := s.GetPreference("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
|
|
}
|
|
|
|
func saveCollapsed(s store.Store, collapsed map[int]bool) {
|
|
var ids []int
|
|
for id, v := range collapsed {
|
|
if v {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
data, _ := json.Marshal(ids)
|
|
_ = s.SetPreference("collapsed_groups", string(data))
|
|
}
|
|
|
|
func (m Model) Init() tea.Cmd {
|
|
return tea.Batch(tea.ClearScreen, tea.Tick(time.Second, func(t time.Time) tea.Msg { return t }))
|
|
}
|
|
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
var cmd tea.Cmd
|
|
|
|
if m.state == stateConfirmDelete {
|
|
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
|
switch keyMsg.String() {
|
|
case "y", "Y":
|
|
switch m.deleteTab {
|
|
case 0:
|
|
if err := m.store.DeleteSite(m.deleteID); err != nil {
|
|
m.engine.AddLog("Delete site failed: " + err.Error())
|
|
}
|
|
m.engine.RemoveSite(m.deleteID)
|
|
m.adjustCursor(len(m.sites) - 1)
|
|
case 1:
|
|
if err := m.store.DeleteAlert(m.deleteID); err != nil {
|
|
m.engine.AddLog("Delete alert failed: " + err.Error())
|
|
}
|
|
m.adjustCursor(len(m.alerts) - 1)
|
|
case 4:
|
|
if err := m.store.DeleteMaintenanceWindow(m.deleteID); err != nil {
|
|
m.engine.AddLog("Delete maintenance window failed: " + err.Error())
|
|
}
|
|
m.adjustCursor(len(m.maintenanceWindows) - 1)
|
|
case 5:
|
|
if err := m.store.DeleteUser(m.deleteID); err != nil {
|
|
m.engine.AddLog("Delete user failed: " + err.Error())
|
|
}
|
|
m.adjustCursor(len(m.users) - 1)
|
|
}
|
|
m.refreshData()
|
|
m.state = stateDashboard
|
|
if m.deleteTab == 5 {
|
|
m.state = stateUsers
|
|
}
|
|
case "n", "N", "esc":
|
|
m.state = stateDashboard
|
|
if m.deleteTab == 5 {
|
|
m.state = stateUsers
|
|
}
|
|
case "ctrl+c":
|
|
return m, tea.Quit
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// Form state: forward ALL messages to huh (keys, timers, resize, etc.)
|
|
if m.state == stateFormSite || m.state == stateFormAlert || m.state == stateFormUser || m.state == stateFormMaint {
|
|
if wsm, ok := msg.(tea.WindowSizeMsg); ok {
|
|
m.termWidth = wsm.Width
|
|
m.termHeight = wsm.Height
|
|
}
|
|
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
|
if keyMsg.String() == "ctrl+c" {
|
|
return m, tea.Quit
|
|
}
|
|
if keyMsg.String() == "esc" {
|
|
m.huhForm = nil
|
|
m.state = stateDashboard
|
|
if m.currentTab == 5 {
|
|
m.state = stateUsers
|
|
}
|
|
return m, nil
|
|
}
|
|
}
|
|
if m.huhForm != nil {
|
|
form, formCmd := m.huhForm.Update(msg)
|
|
if f, ok := form.(*huh.Form); ok {
|
|
m.huhForm = f
|
|
}
|
|
if m.huhForm.State == huh.StateCompleted {
|
|
m.submitForm()
|
|
m.refreshData()
|
|
m.huhForm = nil
|
|
return m, nil
|
|
}
|
|
return m, formCmd
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.termWidth = msg.Width
|
|
m.termHeight = msg.Height
|
|
chrome := chromeBase
|
|
if m.filterMode || m.filterText != "" {
|
|
chrome++
|
|
}
|
|
m.maxTableRows = msg.Height - chrome
|
|
if m.maxTableRows < 1 {
|
|
m.maxTableRows = 1
|
|
}
|
|
m.logViewport.Width = msg.Width - chromePadH
|
|
m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeGaps + chromeFooter)
|
|
return m, tea.ClearScreen
|
|
|
|
case time.Time:
|
|
m.refreshData()
|
|
m.tickCount++
|
|
target := math.Sin(float64(m.tickCount)*0.3)*0.5 + 0.5
|
|
m.pulsePos, m.pulseVel = m.pulseSpring.Update(m.pulsePos, m.pulseVel, target)
|
|
return m, tea.Tick(time.Second, func(t time.Time) tea.Msg { return t })
|
|
|
|
case tea.MouseMsg:
|
|
if m.state == stateDashboard || m.state == stateLogs || m.state == stateUsers {
|
|
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
|
|
return m.handleClick(msg)
|
|
}
|
|
if msg.Button == tea.MouseButtonWheelUp || msg.Button == tea.MouseButtonWheelDown {
|
|
if m.state == stateLogs {
|
|
if msg.Button == tea.MouseButtonWheelUp {
|
|
m.logViewport.ScrollUp(3)
|
|
} else {
|
|
m.logViewport.ScrollDown(3)
|
|
}
|
|
return m, nil
|
|
}
|
|
listLen := len(m.sites)
|
|
switch m.currentTab {
|
|
case 1:
|
|
listLen = len(m.alerts)
|
|
case 3:
|
|
listLen = len(m.nodes)
|
|
case 4:
|
|
listLen = len(m.maintenanceWindows)
|
|
case 5:
|
|
listLen = len(m.users)
|
|
}
|
|
if msg.Button == tea.MouseButtonWheelUp {
|
|
if m.cursor > 0 {
|
|
m.cursor--
|
|
if m.cursor < m.tableOffset {
|
|
m.tableOffset = m.cursor
|
|
}
|
|
}
|
|
} else {
|
|
if m.cursor < listLen-1 {
|
|
m.cursor++
|
|
if m.cursor >= m.tableOffset+m.maxTableRows {
|
|
m.tableOffset++
|
|
}
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
case tea.KeyMsg:
|
|
if msg.String() == "ctrl+c" {
|
|
return m, tea.Quit
|
|
}
|
|
if msg.String() == "ctrl+l" {
|
|
return m, tea.ClearScreen
|
|
}
|
|
|
|
if m.filterMode {
|
|
switch msg.String() {
|
|
case "esc":
|
|
m.filterMode = false
|
|
m.filterText = ""
|
|
m.cursor = 0
|
|
m.tableOffset = 0
|
|
m.refreshData()
|
|
case "enter":
|
|
m.filterMode = false
|
|
case "backspace":
|
|
if len(m.filterText) > 0 {
|
|
m.filterText = m.filterText[:len(m.filterText)-1]
|
|
m.cursor = 0
|
|
m.tableOffset = 0
|
|
m.refreshData()
|
|
}
|
|
case "ctrl+c":
|
|
return m, tea.Quit
|
|
default:
|
|
if len(msg.String()) == 1 {
|
|
m.filterText += msg.String()
|
|
m.cursor = 0
|
|
m.tableOffset = 0
|
|
m.refreshData()
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
switch m.state {
|
|
case stateDetail:
|
|
switch msg.String() {
|
|
case "i", "esc":
|
|
m.state = stateDashboard
|
|
case "q":
|
|
return m, tea.Quit
|
|
}
|
|
return m, nil
|
|
case stateDashboard, stateLogs, stateUsers:
|
|
switch msg.String() {
|
|
case "q":
|
|
return m, tea.Quit
|
|
case "/":
|
|
if m.currentTab == 0 {
|
|
m.filterMode = true
|
|
return m, nil
|
|
}
|
|
case "tab":
|
|
m.switchTab(m.currentTab + 1)
|
|
case "pgup", "pgdown":
|
|
if m.state == stateLogs {
|
|
m.logViewport, cmd = m.logViewport.Update(msg)
|
|
return m, cmd
|
|
}
|
|
case "up", "k":
|
|
if m.state == stateLogs {
|
|
m.logViewport.ScrollUp(1)
|
|
} else if m.cursor > 0 {
|
|
m.cursor--
|
|
if m.cursor < m.tableOffset {
|
|
m.tableOffset = m.cursor
|
|
}
|
|
}
|
|
case "down", "j":
|
|
if m.state == stateLogs {
|
|
m.logViewport.ScrollDown(1)
|
|
} else {
|
|
max := len(m.sites) - 1
|
|
if m.currentTab == 1 {
|
|
max = len(m.alerts) - 1
|
|
}
|
|
if m.currentTab == 3 {
|
|
max = len(m.nodes) - 1
|
|
}
|
|
if m.currentTab == 4 {
|
|
max = len(m.maintenanceWindows) - 1
|
|
}
|
|
if m.currentTab == 5 {
|
|
max = len(m.users) - 1
|
|
}
|
|
if m.cursor < max {
|
|
m.cursor++
|
|
if m.cursor >= m.tableOffset+m.maxTableRows {
|
|
m.tableOffset++
|
|
}
|
|
}
|
|
}
|
|
case "n":
|
|
m.editID = 0
|
|
m.editToken = ""
|
|
if m.currentTab == 0 {
|
|
m.state = stateFormSite
|
|
return m, m.initSiteHuhForm()
|
|
} else if m.currentTab == 1 {
|
|
m.state = stateFormAlert
|
|
return m, m.initAlertHuhForm()
|
|
} else if m.currentTab == 4 {
|
|
m.state = stateFormMaint
|
|
return m, m.initMaintHuhForm()
|
|
} else if m.currentTab == 5 && m.isAdmin {
|
|
m.state = stateFormUser
|
|
return m, m.initUserHuhForm()
|
|
}
|
|
case "e", "enter":
|
|
if m.currentTab == 0 && len(m.sites) > 0 {
|
|
m.editID = m.sites[m.cursor].ID
|
|
m.editToken = m.sites[m.cursor].Token
|
|
m.state = stateFormSite
|
|
return m, m.initSiteHuhForm()
|
|
} else if m.currentTab == 1 && len(m.alerts) > 0 {
|
|
m.editID = m.alerts[m.cursor].ID
|
|
m.state = stateFormAlert
|
|
return m, m.initAlertHuhForm()
|
|
} else if m.currentTab == 5 && m.isAdmin && len(m.users) > 0 {
|
|
m.editID = m.users[m.cursor].ID
|
|
m.state = stateFormUser
|
|
return m, m.initUserHuhForm()
|
|
}
|
|
case " ":
|
|
if m.currentTab == 0 && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" {
|
|
gid := m.sites[m.cursor].ID
|
|
m.collapsed[gid] = !m.collapsed[gid]
|
|
saveCollapsed(m.store, m.collapsed)
|
|
m.refreshData()
|
|
}
|
|
case "p":
|
|
if m.currentTab == 0 && len(m.sites) > 0 {
|
|
site := m.sites[m.cursor]
|
|
m.engine.ToggleSitePause(site.ID)
|
|
site.Paused = !site.Paused
|
|
_ = m.store.UpdateSitePaused(site.ID, site.Paused)
|
|
m.refreshData()
|
|
}
|
|
case "i":
|
|
if m.currentTab == 0 && len(m.sites) > 0 {
|
|
m.state = stateDetail
|
|
}
|
|
case "x":
|
|
if m.currentTab == 4 && len(m.maintenanceWindows) > 0 {
|
|
mw := m.maintenanceWindows[m.cursor]
|
|
now := time.Now()
|
|
isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now))
|
|
if isActive {
|
|
if err := m.store.EndMaintenanceWindow(mw.ID); err != nil {
|
|
m.engine.AddLog("End maintenance failed: " + err.Error())
|
|
}
|
|
m.refreshData()
|
|
}
|
|
}
|
|
case "T":
|
|
m.themeIndex = (m.themeIndex + 1) % len(themes)
|
|
m.theme = themes[m.themeIndex]
|
|
applyTheme(m.theme)
|
|
_ = m.store.SetPreference("theme", m.theme.Name)
|
|
case "d", "backspace":
|
|
if m.currentTab == 0 && len(m.sites) > 0 {
|
|
m.deleteID = m.sites[m.cursor].ID
|
|
m.deleteName = m.sites[m.cursor].Name
|
|
m.deleteTab = 0
|
|
m.state = stateConfirmDelete
|
|
} else if m.currentTab == 1 && len(m.alerts) > 0 {
|
|
m.deleteID = m.alerts[m.cursor].ID
|
|
m.deleteName = m.alerts[m.cursor].Name
|
|
m.deleteTab = 1
|
|
m.state = stateConfirmDelete
|
|
} else if m.currentTab == 4 && len(m.maintenanceWindows) > 0 {
|
|
m.deleteID = m.maintenanceWindows[m.cursor].ID
|
|
m.deleteName = m.maintenanceWindows[m.cursor].Title
|
|
m.deleteTab = 4
|
|
m.state = stateConfirmDelete
|
|
} else if m.currentTab == 5 && m.isAdmin && len(m.users) > 0 {
|
|
m.deleteID = m.users[m.cursor].ID
|
|
m.deleteName = m.users[m.cursor].Username
|
|
m.deleteTab = 5
|
|
m.state = stateConfirmDelete
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|
tabCount := 5
|
|
if m.isAdmin {
|
|
tabCount = 6
|
|
}
|
|
for i := 0; i < tabCount; i++ {
|
|
if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) {
|
|
m.switchTab(i)
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
if m.currentTab == 0 {
|
|
end := m.tableOffset + m.maxTableRows
|
|
if end > len(m.sites) {
|
|
end = len(m.sites)
|
|
}
|
|
for i := m.tableOffset; i < end; i++ {
|
|
if m.zones.Get(fmt.Sprintf("site-%d", i)).InBounds(msg) {
|
|
m.cursor = i
|
|
return m, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
if m.currentTab == 1 {
|
|
end := m.tableOffset + m.maxTableRows
|
|
if end > len(m.alerts) {
|
|
end = len(m.alerts)
|
|
}
|
|
for i := m.tableOffset; i < end; i++ {
|
|
if m.zones.Get(fmt.Sprintf("alert-%d", i)).InBounds(msg) {
|
|
m.cursor = i
|
|
return m, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
if m.currentTab == 4 {
|
|
end := m.tableOffset + m.maxTableRows
|
|
if end > len(m.maintenanceWindows) {
|
|
end = len(m.maintenanceWindows)
|
|
}
|
|
for i := m.tableOffset; i < end; i++ {
|
|
if m.zones.Get(fmt.Sprintf("maint-%d", i)).InBounds(msg) {
|
|
m.cursor = i
|
|
return m, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
if m.currentTab == 5 {
|
|
end := m.tableOffset + m.maxTableRows
|
|
if end > len(m.users) {
|
|
end = len(m.users)
|
|
}
|
|
for i := m.tableOffset; i < end; i++ {
|
|
if m.zones.Get(fmt.Sprintf("user-%d", i)).InBounds(msg) {
|
|
m.cursor = i
|
|
return m, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m *Model) switchTab(idx int) {
|
|
maxTabs := 4
|
|
if m.isAdmin {
|
|
maxTabs = 5
|
|
}
|
|
if idx > maxTabs {
|
|
idx = 0
|
|
}
|
|
m.currentTab = idx
|
|
m.cursor = 0
|
|
m.tableOffset = 0
|
|
switch idx {
|
|
case 2:
|
|
m.state = stateLogs
|
|
case 5:
|
|
m.state = stateUsers
|
|
default:
|
|
m.state = stateDashboard
|
|
}
|
|
}
|
|
|
|
func (m *Model) adjustCursor(newLen int) {
|
|
if m.cursor >= newLen && m.cursor > 0 {
|
|
m.cursor--
|
|
}
|
|
if m.cursor < m.tableOffset {
|
|
m.tableOffset = m.cursor
|
|
if m.tableOffset < 0 {
|
|
m.tableOffset = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *Model) refreshData() {
|
|
allSites := m.engine.GetAllSites()
|
|
|
|
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 })
|
|
for pid := range children {
|
|
c := children[pid]
|
|
sort.Slice(c, func(i, j int) bool { return c[i].ID < c[j].ID })
|
|
sort.SliceStable(c, func(i, j int) bool { return siteOrder(c[i]) < siteOrder(c[j]) })
|
|
children[pid] = c
|
|
}
|
|
sort.Slice(ungrouped, func(i, j int) bool { return ungrouped[i].ID < ungrouped[j].ID })
|
|
sort.SliceStable(ungrouped, func(i, j int) bool { return siteOrder(ungrouped[i]) < siteOrder(ungrouped[j]) })
|
|
|
|
var ordered []models.Site
|
|
for _, g := range groups {
|
|
ordered = append(ordered, g)
|
|
if !m.collapsed[g.ID] {
|
|
ordered = append(ordered, children[g.ID]...)
|
|
}
|
|
}
|
|
ordered = append(ordered, ungrouped...)
|
|
if m.filterText != "" {
|
|
var filtered []models.Site
|
|
needle := strings.ToLower(m.filterText)
|
|
for _, s := range ordered {
|
|
if strings.Contains(strings.ToLower(s.Name), needle) {
|
|
filtered = append(filtered, s)
|
|
}
|
|
}
|
|
ordered = filtered
|
|
}
|
|
m.sites = ordered
|
|
if alerts, err := m.store.GetAllAlerts(); err == nil {
|
|
m.alerts = alerts
|
|
}
|
|
if m.isAdmin {
|
|
if users, err := m.store.GetAllUsers(); err == nil {
|
|
m.users = users
|
|
}
|
|
}
|
|
if nodes, err := m.store.GetAllNodes(); err == nil {
|
|
m.nodes = nodes
|
|
}
|
|
if windows, err := m.store.GetAllMaintenanceWindows(100); err == nil {
|
|
m.maintenanceWindows = windows
|
|
}
|
|
m.logViewport.SetContent(strings.Join(m.engine.GetLogs(), "\n"))
|
|
|
|
listLen := len(m.sites)
|
|
switch m.currentTab {
|
|
case 1:
|
|
listLen = len(m.alerts)
|
|
case 3:
|
|
listLen = len(m.nodes)
|
|
case 4:
|
|
listLen = len(m.maintenanceWindows)
|
|
case 5:
|
|
listLen = len(m.users)
|
|
}
|
|
if listLen > 0 && m.cursor >= listLen {
|
|
m.cursor = listLen - 1
|
|
}
|
|
if m.cursor < m.tableOffset {
|
|
m.tableOffset = m.cursor
|
|
}
|
|
}
|
|
|
|
func (m *Model) submitForm() {
|
|
switch m.state {
|
|
case stateFormSite:
|
|
if m.siteFormData != nil {
|
|
m.submitSiteForm()
|
|
}
|
|
case stateFormAlert:
|
|
if m.alertFormData != nil {
|
|
m.submitAlertForm()
|
|
}
|
|
case stateFormUser:
|
|
if m.userFormData != nil {
|
|
m.submitUserForm()
|
|
}
|
|
case stateFormMaint:
|
|
if m.maintFormData != nil {
|
|
m.submitMaintForm()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m Model) pulseIndicator() string {
|
|
frame := m.tickCount % len(pulseFrames)
|
|
brightness := int(m.pulsePos*155) + 100
|
|
if brightness > 255 {
|
|
brightness = 255
|
|
}
|
|
hasDown := false
|
|
for _, s := range m.sites {
|
|
if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") {
|
|
hasDown = true
|
|
break
|
|
}
|
|
}
|
|
var color string
|
|
if hasDown {
|
|
color = fmt.Sprintf("#%02x%02x%02x", brightness, brightness/4, brightness/4)
|
|
} else {
|
|
color = fmt.Sprintf("#%02x%02x%02x", brightness/3, brightness, brightness/2)
|
|
}
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(pulseFrames[frame])
|
|
}
|
|
|
|
func (m Model) View() string {
|
|
switch m.state {
|
|
case stateConfirmDelete:
|
|
kind := "monitor"
|
|
switch m.deleteTab {
|
|
case 1:
|
|
kind = "alert"
|
|
case 4:
|
|
kind = "maintenance window"
|
|
case 5:
|
|
kind = "user"
|
|
}
|
|
msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName))
|
|
hint := subtleStyle.Render("[y] Confirm [n] Cancel")
|
|
box := lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(m.theme.Danger).
|
|
Padding(1, 3).
|
|
Render(msg + "\n\n" + hint)
|
|
return lipgloss.NewStyle().Padding(2, 4).Render(box)
|
|
case stateFormSite, stateFormAlert, stateFormUser, stateFormMaint:
|
|
if m.huhForm != nil {
|
|
title := ""
|
|
switch m.state {
|
|
case stateFormSite:
|
|
title = "Add Monitor"
|
|
if m.editID > 0 {
|
|
title = fmt.Sprintf("Edit Monitor #%d", m.editID)
|
|
}
|
|
case stateFormAlert:
|
|
title = "Add Alert"
|
|
if m.editID > 0 {
|
|
title = fmt.Sprintf("Edit Alert #%d", m.editID)
|
|
}
|
|
case stateFormUser:
|
|
title = "Add User"
|
|
if m.editID > 0 {
|
|
title = fmt.Sprintf("Edit User #%d", m.editID)
|
|
}
|
|
case stateFormMaint:
|
|
title = "New Maintenance Window"
|
|
}
|
|
formHeight := m.termHeight - 7
|
|
if formHeight < 5 {
|
|
formHeight = 5
|
|
}
|
|
m.huhForm.WithHeight(formHeight)
|
|
header := titleStyle.Render(title)
|
|
footer := subtleStyle.Render("\n[Esc] Cancel")
|
|
return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer)
|
|
}
|
|
return ""
|
|
case stateDetail:
|
|
return m.viewDetailPanel()
|
|
default:
|
|
return m.zones.Scan(m.viewDashboard())
|
|
}
|
|
}
|
|
|
|
func (m Model) viewDashboard() string {
|
|
allSites := m.engine.GetAllSites()
|
|
totalMonitors := 0
|
|
downCount := 0
|
|
for _, s := range allSites {
|
|
if s.Type == "group" {
|
|
continue
|
|
}
|
|
totalMonitors++
|
|
if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") {
|
|
downCount++
|
|
}
|
|
}
|
|
offlineNodes := 0
|
|
for _, n := range m.nodes {
|
|
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) > 5*time.Minute {
|
|
offlineNodes++
|
|
}
|
|
}
|
|
|
|
var sitesLabel string
|
|
if downCount > 0 {
|
|
sitesLabel = fmt.Sprintf("Sites (%d↓)", downCount)
|
|
} else if totalMonitors > 0 {
|
|
sitesLabel = fmt.Sprintf("Sites (%d)", totalMonitors)
|
|
} else {
|
|
sitesLabel = "Sites"
|
|
}
|
|
var nodesLabel string
|
|
if offlineNodes > 0 {
|
|
nodesLabel = fmt.Sprintf("Nodes (%d!)", offlineNodes)
|
|
} else if len(m.nodes) > 0 {
|
|
nodesLabel = fmt.Sprintf("Nodes (%d)", len(m.nodes))
|
|
} else {
|
|
nodesLabel = "Nodes"
|
|
}
|
|
|
|
activeMaint := 0
|
|
for _, mw := range m.maintenanceWindows {
|
|
now := time.Now()
|
|
if !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) {
|
|
activeMaint++
|
|
}
|
|
}
|
|
var maintLabel string
|
|
if activeMaint > 0 {
|
|
maintLabel = fmt.Sprintf("Maint (%d)", activeMaint)
|
|
} else {
|
|
maintLabel = "Maint"
|
|
}
|
|
|
|
tabs := []string{sitesLabel, "Alerts", "Logs", nodesLabel, maintLabel}
|
|
if m.isAdmin {
|
|
tabs = append(tabs, "Users")
|
|
}
|
|
var renderedTabs []string
|
|
for i, t := range tabs {
|
|
var rendered string
|
|
if i == m.currentTab {
|
|
rendered = activeTab.Render(t)
|
|
} else {
|
|
rendered = inactiveTab.Render(t)
|
|
}
|
|
renderedTabs = append(renderedTabs, m.zones.Mark(fmt.Sprintf("tab-%d", i), rendered))
|
|
}
|
|
header := lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...)
|
|
|
|
pulse := m.pulseIndicator()
|
|
header = pulse + " " + header
|
|
|
|
var content string
|
|
switch m.currentTab {
|
|
case 0:
|
|
content = m.viewSitesTab()
|
|
case 1:
|
|
content = m.viewAlertsTab()
|
|
case 2:
|
|
content = m.viewLogsTab()
|
|
case 3:
|
|
content = m.viewNodesTab()
|
|
case 4:
|
|
content = m.viewMaintTab()
|
|
case 5:
|
|
if m.isAdmin {
|
|
content = m.viewUsersTab()
|
|
}
|
|
}
|
|
|
|
upCount := totalMonitors - downCount
|
|
var upStr string
|
|
if downCount > 0 {
|
|
upStr = dangerStyle.Render(fmt.Sprintf("%d/%d UP", upCount, totalMonitors))
|
|
} else {
|
|
upStr = specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, totalMonitors))
|
|
}
|
|
statusParts := []string{upStr}
|
|
if len(m.nodes) > 0 {
|
|
online := 0
|
|
for _, n := range m.nodes {
|
|
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) < 60*time.Second {
|
|
online++
|
|
}
|
|
}
|
|
statusParts = append(statusParts, fmt.Sprintf("%d probes", online))
|
|
}
|
|
statusLine := strings.Join(statusParts, subtleStyle.Render(" · "))
|
|
|
|
var footer string
|
|
if m.filterMode {
|
|
cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│")
|
|
footer = "\n" + titleStyle.Render("/") + " " + m.filterText + cursor + " " + subtleStyle.Render("[Enter]Apply [Esc]Clear")
|
|
} else {
|
|
var keys string
|
|
switch m.currentTab {
|
|
case 0:
|
|
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [T]Theme [Tab]Switch [q]Quit"
|
|
case 4:
|
|
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
|
|
case 5:
|
|
keys = "[n]Add [d]Revoke [T]Theme [Tab]Switch [q]Quit"
|
|
default:
|
|
keys = "[T]Theme [Tab]Switch [q]Quit"
|
|
}
|
|
footer = "\n" + statusLine + " " + subtleStyle.Render(keys)
|
|
if m.filterText != "" && m.currentTab == 0 {
|
|
footer = "\n" + subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + subtleStyle.Render(keys)
|
|
}
|
|
}
|
|
s := lipgloss.NewStyle().Padding(1, 2)
|
|
if m.termHeight > 0 {
|
|
s = s.MaxHeight(m.termHeight)
|
|
}
|
|
return s.Render(header + "\n" + content + "\n" + footer)
|
|
}
|
|
|
|
func siteOrder(s models.Site) int {
|
|
if s.Paused {
|
|
return 3
|
|
}
|
|
switch s.Status {
|
|
case "DOWN", "SSL EXP":
|
|
return 0
|
|
case "PENDING":
|
|
return 2
|
|
default:
|
|
return 1
|
|
}
|
|
}
|
|
|
|
func limitStr(text string, max int) string {
|
|
if len(text) > max {
|
|
return text[:max-3] + "..."
|
|
}
|
|
return text
|
|
}
|