Files
uptop/internal/tui/tui.go
T
lerko af5246e777
CI / test (pull_request) Successful in 2m41s
CI / lint (pull_request) Successful in 1m12s
CI / vulncheck (pull_request) Successful in 51s
chore(tui): visual polish — detail sections, column headers, alert detail
Detail panel:
- Grouped fields into sections (ENDPOINT, TIMING, HTTP, CONFIG)
- Omit Timeout when 0 (unconfigured)
- Omit Method when default GET
- Show explicit "200-299" when AcceptedCodes empty

Table:
- LATENCY header → LAT (design short, never truncate)

Alerts:
- Press [i] for alert detail panel: full config, health status,
  send counts, last error
- Keybinding display updated with [i]Info

Bundled remaining UX polish items from screenshot review.
2026-05-28 13:18:27 -04:00

1015 lines
25 KiB
Go

package tui
import (
"encoding/json"
"fmt"
"math"
"sort"
"strings"
"time"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"gitea.lerkolabs.com/lerko/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"
)
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
stateAlertDetail
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
logFilterImportant bool
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 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 {
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()
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++
}
}
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 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
}