refactor(tui): decompose god files into single-concern modules
tui.go (1032→164) and tab_sites.go (993→482) violated "small functions" and "testable in isolation" standards. Extracted 6 new files by concern: - format.go: pure formatting functions (fmtLatency, fmtUptime, etc.) - sparkline.go: sparkline rendering (latency, heartbeat, group) - update.go: Update method decomposed into 15 named handlers - view_dashboard.go: View, dashboard composition, tab bar, footer - view_detail.go: site detail panel - data.go: data refresh with extracted sortSitesForDisplay/filterSites Added 17 unit tests for the newly-testable pure functions covering format, sparkline, sort ordering, and filter logic. No behavioral changes — strict move-and-extract refactor.
This commit was merged in pull request #53.
This commit is contained in:
@@ -1,12 +1,7 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
@@ -164,869 +159,6 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
|
||||
}
|
||||
}
|
||||
|
||||
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 stateAlertDetail:
|
||||
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 "f":
|
||||
if m.state == stateLogs {
|
||||
m.logFilterImportant = !m.logFilterImportant
|
||||
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 "t":
|
||||
if m.currentTab == 1 && len(m.alerts) > 0 {
|
||||
a := m.alerts[m.cursor]
|
||||
go func() {
|
||||
if err := m.engine.TestAlert(a.ID); err != nil {
|
||||
m.engine.AddLog(fmt.Sprintf("Test alert failed (%s): %v", a.Name, err))
|
||||
}
|
||||
}()
|
||||
return m, nil
|
||||
}
|
||||
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
|
||||
} else if m.currentTab == 1 && len(m.alerts) > 0 {
|
||||
m.state = stateAlertDetail
|
||||
}
|
||||
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 {
|
||||
hasDown := false
|
||||
for _, s := range m.sites {
|
||||
if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") {
|
||||
hasDown = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// Stills can't show animation: render a stable status dot in demo mode.
|
||||
if m.demoMode {
|
||||
c := m.theme.Success
|
||||
if hasDown {
|
||||
c = m.theme.Danger
|
||||
}
|
||||
return lipgloss.NewStyle().Foreground(c).Render("●")
|
||||
}
|
||||
frame := m.tickCount % len(pulseFrames)
|
||||
brightness := int(m.pulsePos*155) + 100
|
||||
if brightness > 255 {
|
||||
brightness = 255
|
||||
}
|
||||
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()
|
||||
case stateAlertDetail:
|
||||
return m.viewAlertDetailPanel()
|
||||
default:
|
||||
return m.zones.Scan(m.viewDashboard())
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) viewDashboard() string {
|
||||
allSites := m.engine.GetAllSites()
|
||||
totalMonitors := 0
|
||||
downCount := 0
|
||||
lateCount := 0
|
||||
for _, s := range allSites {
|
||||
if s.Type == "group" {
|
||||
continue
|
||||
}
|
||||
totalMonitors++
|
||||
if s.Paused || m.isMonitorInMaintenance(s.ID) {
|
||||
continue
|
||||
}
|
||||
switch s.Status {
|
||||
case "DOWN", "SSL EXP":
|
||||
downCount++
|
||||
case "LATE":
|
||||
lateCount++
|
||||
}
|
||||
}
|
||||
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 lateCount > 0 {
|
||||
sitesLabel = fmt.Sprintf("Sites (%d⚠)", lateCount)
|
||||
} 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 - lateCount
|
||||
var upStr string
|
||||
if downCount > 0 {
|
||||
upStr = dangerStyle.Render(fmt.Sprintf("%d/%d UP", upCount, totalMonitors))
|
||||
} else if lateCount > 0 {
|
||||
upStr = warnStyle.Render(fmt.Sprintf("%d/%d UP", upCount, totalMonitors))
|
||||
} else {
|
||||
upStr = specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, totalMonitors))
|
||||
}
|
||||
statusParts := []string{upStr}
|
||||
if lateCount > 0 {
|
||||
statusParts = append(statusParts, warnStyle.Render(fmt.Sprintf("%d LATE", lateCount)))
|
||||
}
|
||||
if len(m.nodes) > 0 {
|
||||
online := 0
|
||||
for _, n := range m.nodes {
|
||||
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) < 60*time.Second {
|
||||
online++
|
||||
}
|
||||
}
|
||||
probeLabel := "probes"
|
||||
if online == 1 {
|
||||
probeLabel = "probe"
|
||||
}
|
||||
statusParts = append(statusParts, fmt.Sprintf("%d %s", online, probeLabel))
|
||||
}
|
||||
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 1:
|
||||
keys = "[n]New [e]Edit [i]Info [d]Del [t]Test [T]Theme [Tab]Switch [q]Quit"
|
||||
case 2:
|
||||
keys = "[f]Filter [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 "LATE":
|
||||
return 1
|
||||
case "PENDING":
|
||||
return 3
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func limitStr(text string, max int) string {
|
||||
runes := []rune(text)
|
||||
if len(runes) > max {
|
||||
return string(runes[:max-3]) + "..."
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user