6e936ecce3
Gives monitors panel more room for columns while detail sidebar content stacks vertically and doesn't need the extra width.
328 lines
9.2 KiB
Go
328 lines
9.2 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.deleteKind {
|
|
case "maint":
|
|
kind = "maintenance window"
|
|
case "alert":
|
|
kind = "alert"
|
|
case "user":
|
|
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 stateLogs:
|
|
return m.viewLogsFullscreen()
|
|
case stateAlertDetail:
|
|
return m.viewAlertDetailPanel()
|
|
case stateSettings:
|
|
return m.viewSettingsOverlay()
|
|
case stateMaintDetail:
|
|
return m.viewMaintDetailPanel()
|
|
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
|
|
}
|
|
|
|
const maintSidebarW = 22
|
|
|
|
func (m Model) viewMonitorsLayout() string {
|
|
availW := m.termWidth - chromePadH
|
|
wide := m.termWidth >= wideBreakpoint
|
|
|
|
showMaint := m.maintOpen && wide
|
|
showDetail := m.detailOpen && wide
|
|
|
|
var maintW, detailW, monW int
|
|
if showMaint {
|
|
maintW = maintSidebarW
|
|
if maintW > availW/4 {
|
|
maintW = availW / 4
|
|
}
|
|
}
|
|
remaining := availW - maintW
|
|
if showDetail {
|
|
monW = remaining * 60 / 100
|
|
detailW = remaining - monW
|
|
} else {
|
|
monW = remaining
|
|
}
|
|
|
|
m.contentWidth = monW - 2
|
|
|
|
monitors := m.viewSitesTab()
|
|
monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, monW, m.focusedPanel == panelMonitors))
|
|
|
|
var topParts []string
|
|
if showMaint {
|
|
sidebar := m.viewMaintSidebar(maintW-2, m.maxTableRows)
|
|
maintPanel := m.zones.Mark("panel-maint", m.titledPanel("Maint", sidebar, maintW, m.focusedPanel == panelMaint))
|
|
topParts = append(topParts, maintPanel)
|
|
}
|
|
topParts = append(topParts, monPanel)
|
|
if showDetail {
|
|
title := ""
|
|
if m.cursor < len(m.sites) {
|
|
title = m.sites[m.cursor].Name
|
|
}
|
|
switch m.detailMode {
|
|
case detailSLA:
|
|
title = "SLA · " + title
|
|
case detailHistory:
|
|
title = "History · " + title
|
|
}
|
|
monHeight := lipgloss.Height(monPanel)
|
|
detail := m.viewDetailInline(detailW-2, monHeight)
|
|
footer := m.detailFooter(detailW - 2)
|
|
detailPanel := m.zones.Mark("panel-detail", m.titledPanelH(title, detail, footer, detailW, monHeight, m.detailScrollOffset, m.focusedPanel == panelDetail))
|
|
topParts = append(topParts, detailPanel)
|
|
}
|
|
|
|
top := lipgloss.JoinHorizontal(lipgloss.Top, topParts...)
|
|
|
|
if m.logsOpen {
|
|
logContent := m.viewLogsStrip(availW-2, logsStripHeight-2)
|
|
logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", logContent, availW, m.focusedPanel == panelLogs))
|
|
return top + "\n" + logPanel
|
|
}
|
|
return top
|
|
}
|
|
|
|
func (m Model) viewDashboard() string {
|
|
stats := m.computeStats()
|
|
|
|
header := m.renderStatusLine(stats)
|
|
|
|
content := m.viewMonitorsLayout()
|
|
|
|
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
|
|
}
|
|
|
|
contentHeight := availHeight - lipgloss.Height(header) - 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, paddedContent, footer))
|
|
}
|
|
|
|
func (m Model) renderStatusLine(stats dashboardStats) string {
|
|
dot := m.st.subtleStyle.Render(" · ")
|
|
|
|
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))
|
|
}
|
|
|
|
parts := []string{m.pulseIndicator() + " " + upStr}
|
|
|
|
if stats.lateCount > 0 {
|
|
parts = append(parts, m.st.warnStyle.Render(fmt.Sprintf("%d late", stats.lateCount)))
|
|
}
|
|
if stats.activeMaint > 0 {
|
|
parts = append(parts, m.st.maintStyle.Render(fmt.Sprintf("%d maint", stats.activeMaint)))
|
|
}
|
|
if len(m.nodes) > 0 {
|
|
online := 0
|
|
for _, n := range m.nodes {
|
|
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) < nodeOnlineThreshold {
|
|
online++
|
|
}
|
|
}
|
|
label := "probes"
|
|
if online == 1 {
|
|
label = "probe"
|
|
}
|
|
parts = append(parts, fmt.Sprintf("%d %s", online, label))
|
|
}
|
|
|
|
left := strings.Join(parts, dot)
|
|
ver := m.st.subtleStyle.Render("v" + m.version)
|
|
|
|
padW := m.termWidth - chromePadH - lipgloss.Width(left) - lipgloss.Width(ver)
|
|
if padW < 2 {
|
|
padW = 2
|
|
}
|
|
|
|
return left + strings.Repeat(" ", padW) + ver
|
|
}
|
|
|
|
func (m Model) hotkey(key, desc string) string {
|
|
k := lipgloss.NewStyle().Foreground(m.theme.Accent).Render(key)
|
|
d := m.st.subtleStyle.Render(desc)
|
|
return k + " " + d
|
|
}
|
|
|
|
func (m Model) renderFooter(_ dashboardStats) string {
|
|
dot := m.st.subtleStyle.Render(" · ")
|
|
|
|
if m.filterMode {
|
|
cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│")
|
|
keys := m.hotkey("Enter", "Apply") + dot + m.hotkey("Esc", "Clear")
|
|
return "\n" + m.st.titleStyle.Render("/") + " " + m.filterText + cursor + " " + keys
|
|
}
|
|
|
|
var parts []string
|
|
if m.focusedPanel == panelMaint {
|
|
parts = []string{m.hotkey("n", "New"), m.hotkey("x", "End"), m.hotkey("d", "Del"), m.hotkey("Esc", "Back")}
|
|
} else if m.focusedPanel == panelLogs {
|
|
parts = []string{m.hotkey("↑/↓", "Scroll"), m.hotkey("Enter", "Expand"), m.hotkey("l/Esc", "Back")}
|
|
} else if m.detailOpen && m.detailMode == detailSLA {
|
|
parts = []string{m.hotkey("1-4", "Period"), m.hotkey("Esc", "Back")}
|
|
} else if m.detailOpen && m.detailMode == detailHistory {
|
|
parts = []string{m.hotkey("Esc", "Back")}
|
|
} else if m.detailOpen {
|
|
parts = []string{m.hotkey("Enter", "Close"), m.hotkey("h", "History"), m.hotkey("s", "SLA"), m.hotkey("e", "Edit"), m.hotkey("m", "Maint")}
|
|
} else {
|
|
parts = []string{m.hotkey("/", "Filter"), m.hotkey("Enter", "Detail")}
|
|
if m.cursor < len(m.sites) && m.sites[m.cursor].Type == "group" {
|
|
if m.collapsed[m.sites[m.cursor].ID] {
|
|
parts = append(parts, m.hotkey("Space", "Expand"))
|
|
} else {
|
|
parts = append(parts, m.hotkey("Space", "Collapse"))
|
|
}
|
|
}
|
|
parts = append(parts, m.hotkey("n", "New"), m.hotkey("e", "Edit"), m.hotkey("d", "Del"), m.hotkey("m", "Maint"))
|
|
}
|
|
parts = append(parts, m.hotkey("l", "Logs"), m.hotkey("S", "Settings"), m.hotkey("T", "Theme"), m.hotkey("q", "Quit"))
|
|
|
|
line := strings.Join(parts, dot)
|
|
if m.filterText != "" {
|
|
line = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s ", m.filterText)) + line
|
|
}
|
|
|
|
return "\n" + line
|
|
}
|