7ce5af5bf6
Delete confirmation dialog used wrong magic numbers for entity kind mapping — maintenance windows showed as "alert", settings deletions showed as "monitor". Now uses tab/section constants to match correctly. Also: consistent [q/Esc] Back across all views (detail inline, logs, SLA, history headers), extract detailKeys(), remove dead maxSections variable, add nodes empty state hint, use models.Status constants in siteOrder, delete orphaned theme.png.
351 lines
9.9 KiB
Go
351 lines
9.9 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
func sinApprox(x float64) float64 {
|
|
return math.Sin(x)
|
|
}
|
|
|
|
func (m Model) pulseIndicator() string {
|
|
hasDown := false
|
|
for _, s := range m.sites {
|
|
if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == models.StatusDown || s.Status == models.StatusSSLExp) {
|
|
hasDown = true
|
|
break
|
|
}
|
|
}
|
|
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 tabMaint:
|
|
kind = "maintenance window"
|
|
case tabSettings:
|
|
switch m.settingsSection {
|
|
case sectionAlerts:
|
|
kind = "alert"
|
|
case sectionUsers:
|
|
kind = "user"
|
|
}
|
|
}
|
|
msg := m.st.dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName))
|
|
hint := m.st.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"
|
|
}
|
|
header := m.st.titleStyle.Render(title)
|
|
footer := m.st.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.zones.Scan(m.viewDetailPanel())
|
|
case stateLogs:
|
|
return m.viewLogsFullscreen()
|
|
case stateHistory:
|
|
return m.viewHistoryPanel()
|
|
case stateSLA:
|
|
return m.viewSLAPanel()
|
|
case stateAlertDetail:
|
|
return m.viewAlertDetailPanel()
|
|
default:
|
|
return m.zones.Scan(m.viewDashboard())
|
|
}
|
|
}
|
|
|
|
type dashboardStats struct {
|
|
totalMonitors int
|
|
downCount int
|
|
lateCount int
|
|
offlineNodes int
|
|
activeMaint int
|
|
}
|
|
|
|
func (m Model) computeStats() dashboardStats {
|
|
allSites := m.engine.GetAllSites()
|
|
var s dashboardStats
|
|
for _, site := range allSites {
|
|
if site.Type == "group" {
|
|
continue
|
|
}
|
|
s.totalMonitors++
|
|
if site.Paused || m.isMonitorInMaintenance(site.ID) {
|
|
continue
|
|
}
|
|
switch site.Status {
|
|
case models.StatusDown, models.StatusSSLExp:
|
|
s.downCount++
|
|
case models.StatusLate:
|
|
s.lateCount++
|
|
}
|
|
}
|
|
for _, n := range m.nodes {
|
|
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) > nodeStaleThreshold {
|
|
s.offlineNodes++
|
|
}
|
|
}
|
|
for _, mw := range m.maintenanceWindows {
|
|
now := time.Now()
|
|
if !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) {
|
|
s.activeMaint++
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (m Model) viewDashboard() string {
|
|
stats := m.computeStats()
|
|
|
|
header := m.renderTabBar(stats)
|
|
header = m.pulseIndicator() + " " + header
|
|
|
|
var content string
|
|
switch m.currentTab {
|
|
case tabMonitors:
|
|
showSidebar := m.termWidth >= wideBreakpoint && m.logsOpen
|
|
if showSidebar {
|
|
availW := m.termWidth - chromePadH
|
|
leftW := availW * 70 / 100
|
|
rightW := availW - leftW
|
|
m.contentWidth = leftW - 2
|
|
monitors := m.viewSitesTab()
|
|
monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, leftW, m.focusedPanel == panelMonitors))
|
|
sidebarContent := m.viewLogsSidebar(rightW-2, m.maxTableRows)
|
|
logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", sidebarContent, rightW, m.focusedPanel == panelLogs))
|
|
top := lipgloss.JoinHorizontal(lipgloss.Top, monPanel, logPanel)
|
|
if m.detailOpen {
|
|
site := ""
|
|
if m.cursor < len(m.sites) {
|
|
site = m.sites[m.cursor].Name
|
|
}
|
|
detail := m.viewDetailInline(availW - 2)
|
|
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail))
|
|
content = top + "\n" + detailPanel
|
|
} else {
|
|
content = top
|
|
}
|
|
} else {
|
|
m.contentWidth = m.termWidth - 2
|
|
monitors := m.viewSitesTab()
|
|
availW := m.termWidth - chromePadH
|
|
monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, availW, m.focusedPanel == panelMonitors))
|
|
if m.detailOpen {
|
|
site := ""
|
|
if m.cursor < len(m.sites) {
|
|
site = m.sites[m.cursor].Name
|
|
}
|
|
detail := m.viewDetailInline(availW - 2)
|
|
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail))
|
|
content = monPanel + "\n" + detailPanel
|
|
} else {
|
|
content = monPanel
|
|
}
|
|
}
|
|
case tabMaint:
|
|
m.contentWidth = m.termWidth
|
|
content = m.viewMaintTab()
|
|
case tabSettings:
|
|
m.contentWidth = m.termWidth
|
|
content = m.viewSettingsTab()
|
|
}
|
|
|
|
content = strings.TrimSpace(content)
|
|
footer := m.renderFooter(stats)
|
|
|
|
outerPad := lipgloss.NewStyle().Padding(1, 2)
|
|
_, frameV := outerPad.GetFrameSize()
|
|
availHeight := m.termHeight - frameV
|
|
if availHeight < 5 {
|
|
availHeight = 5
|
|
}
|
|
|
|
divW := m.termWidth - chromePadH
|
|
if divW < 40 {
|
|
divW = 40
|
|
}
|
|
tabDivider := m.st.subtleStyle.Render(strings.Repeat("─", divW))
|
|
|
|
contentHeight := availHeight - lipgloss.Height(header) - 1 - lipgloss.Height(footer)
|
|
if contentHeight < 1 {
|
|
contentHeight = 1
|
|
}
|
|
paddedContent := lipgloss.NewStyle().Height(contentHeight).MaxHeight(contentHeight).Render(content)
|
|
|
|
return outerPad.Render(lipgloss.JoinVertical(lipgloss.Top, header, tabDivider, paddedContent, footer))
|
|
}
|
|
|
|
type tabEntry struct {
|
|
name string
|
|
count int
|
|
warn int
|
|
}
|
|
|
|
func (m Model) renderTabBar(stats dashboardStats) string {
|
|
settingsCount := len(m.alerts) + len(m.nodes)
|
|
settingsWarn := stats.offlineNodes
|
|
for _, a := range m.alerts {
|
|
h := m.engine.GetAlertHealth(a.ID)
|
|
if !h.LastSendOK && !h.LastSendAt.IsZero() {
|
|
settingsWarn++
|
|
}
|
|
}
|
|
if m.isAdmin {
|
|
settingsCount += len(m.users)
|
|
}
|
|
tabs := []tabEntry{
|
|
{"Monitors", stats.totalMonitors, stats.downCount + stats.lateCount},
|
|
{"Maint", len(m.maintenanceWindows), stats.activeMaint},
|
|
{"Settings", settingsCount, settingsWarn},
|
|
}
|
|
|
|
countStyle := lipgloss.NewStyle().Foreground(m.theme.Muted)
|
|
|
|
var renderedTabs []string
|
|
for i, t := range tabs {
|
|
label := t.name
|
|
if t.count > 0 {
|
|
badge := countStyle.Render(fmt.Sprintf(" %d", t.count))
|
|
if t.warn > 0 {
|
|
badge = m.st.dangerStyle.Render(fmt.Sprintf(" %d", t.warn))
|
|
}
|
|
label += badge
|
|
}
|
|
|
|
var rendered string
|
|
if i == m.currentTab {
|
|
rendered = m.st.activeTab.Render(label)
|
|
} else {
|
|
rendered = m.st.inactiveTab.Render(label)
|
|
}
|
|
renderedTabs = append(renderedTabs, m.zones.Mark(fmt.Sprintf("tab-%d", i), rendered))
|
|
}
|
|
return lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...)
|
|
}
|
|
|
|
func (m Model) renderFooter(stats dashboardStats) string {
|
|
if m.filterMode {
|
|
cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│")
|
|
return "\n" + m.st.titleStyle.Render("/") + " " + m.filterText + cursor + " " + m.st.subtleStyle.Render("[Enter]Apply [Esc]Clear")
|
|
}
|
|
|
|
upCount := stats.totalMonitors - stats.downCount - stats.lateCount
|
|
var upStr string
|
|
if stats.downCount > 0 {
|
|
upStr = m.st.dangerStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors))
|
|
} else if stats.lateCount > 0 {
|
|
upStr = m.st.warnStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors))
|
|
} else {
|
|
upStr = m.st.specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors))
|
|
}
|
|
statusParts := []string{upStr}
|
|
if stats.lateCount > 0 {
|
|
statusParts = append(statusParts, m.st.warnStyle.Render(fmt.Sprintf("%d LATE", stats.lateCount)))
|
|
}
|
|
if len(m.nodes) > 0 {
|
|
online := 0
|
|
for _, n := range m.nodes {
|
|
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) < nodeOnlineThreshold {
|
|
online++
|
|
}
|
|
}
|
|
probeLabel := "probes"
|
|
if online == 1 {
|
|
probeLabel = "probe"
|
|
}
|
|
statusParts = append(statusParts, fmt.Sprintf("%d %s", online, probeLabel))
|
|
}
|
|
statusLine := strings.Join(statusParts, m.st.subtleStyle.Render(" · "))
|
|
|
|
var keys string
|
|
switch m.currentTab {
|
|
case tabMonitors:
|
|
if m.focusedPanel == panelLogs {
|
|
keys = "[↑/↓]Scroll [Enter]Expand [l/Esc]Back [T]Theme [q]Quit"
|
|
} else if m.detailOpen {
|
|
keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [l]Logs [↑/↓]Select [T]Theme [q]Quit"
|
|
} else {
|
|
keys = "[/]Filter [i]Info [Enter]Detail [</>]Sort [r]Reverse [n]New [e]Edit [d]Del [l]Logs [T]Theme [Tab]Switch [q]Quit"
|
|
}
|
|
case tabMaint:
|
|
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
|
|
case tabSettings:
|
|
switch m.settingsSection {
|
|
case sectionAlerts:
|
|
keys = "[n]New [e]Edit [i]Info [d]Del [t]Test [←/→]Section [T]Theme [Tab]Switch [q]Quit"
|
|
case sectionUsers:
|
|
keys = "[n]Add [d]Revoke [←/→]Section [T]Theme [Tab]Switch [q]Quit"
|
|
default:
|
|
keys = "[←/→]Section [T]Theme [Tab]Switch [q]Quit"
|
|
}
|
|
default:
|
|
keys = "[T]Theme [Tab]Switch [q]Quit"
|
|
}
|
|
|
|
ver := m.st.subtleStyle.Render("v" + m.version)
|
|
line := statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
|
|
if m.filterText != "" && m.currentTab == tabMonitors {
|
|
line = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
|
|
}
|
|
|
|
divW := m.termWidth - chromePadH
|
|
if divW < 40 {
|
|
divW = 40
|
|
}
|
|
return m.st.subtleStyle.Render(strings.Repeat("─", divW)) + "\n" + line
|
|
}
|