4321e094a3
Restructure dashboard from [Monitors 70% | Logs 30%] to [Monitors 45% | Detail 55%] master-detail layout. Detail panel now sits beside the monitor list instead of below it, keeping dashboard context visible during inspection. - Detail panel renders in horizontal split (right of monitors) - Logs sidebar removed; `l` key opens fullscreen logs directly - recalcLayout no longer subtracts detail height from table rows - Remove unused renderCompactLogLine/viewLogsSidebar (Phase 4 recreates)
295 lines
7.7 KiB
Go
295 lines
7.7 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 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()
|
|
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 * 45 / 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 {
|
|
site := ""
|
|
if m.cursor < len(m.sites) {
|
|
site = m.sites[m.cursor].Name
|
|
}
|
|
detail := m.viewDetailInline(detailW - 2)
|
|
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, detailW, m.focusedPanel == panelDetail))
|
|
topParts = append(topParts, detailPanel)
|
|
}
|
|
|
|
return lipgloss.JoinHorizontal(lipgloss.Top, topParts...)
|
|
}
|
|
|
|
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) renderFooter(_ 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")
|
|
}
|
|
|
|
var keys string
|
|
if m.focusedPanel == panelMaint {
|
|
keys = "[n]New [x]End [d]Del [Esc]Back [S]Settings [T]Theme [q]Quit"
|
|
} else if m.detailOpen {
|
|
keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [m]Maint [l]Logs [S]Settings [T]Theme [q]Quit"
|
|
} else {
|
|
keys = "[/]Filter [i]Info [Enter]Detail [n]New [e]Edit [d]Del [m]Maint [l]Logs [S]Settings [T]Theme [q]Quit"
|
|
}
|
|
|
|
line := m.st.subtleStyle.Render(keys)
|
|
if m.filterText != "" {
|
|
line = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s ", m.filterText)) + line
|
|
}
|
|
|
|
return "\n" + line
|
|
}
|