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.
314 lines
9.0 KiB
Go
314 lines
9.0 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
const detailTwoColMinWidth = 80
|
|
|
|
func (m Model) viewDetailInline(width int) string {
|
|
if m.cursor >= len(m.sites) {
|
|
return ""
|
|
}
|
|
site := m.sites[m.cursor]
|
|
hist, _ := m.engine.GetHistory(site.ID)
|
|
|
|
if width < detailTwoColMinWidth {
|
|
return m.viewDetailSingleCol(site, hist, width)
|
|
}
|
|
return m.viewDetailTwoCol(site, hist, width)
|
|
}
|
|
|
|
func (m Model) viewDetailTwoCol(site models.Site, hist monitor.SiteHistory, width int) string {
|
|
leftW := width * 55 / 100
|
|
rightW := width - leftW - 3 // 3 for " │ " divider
|
|
|
|
left := m.detailLeftCol(site, hist, leftW)
|
|
right := m.detailRightCol(site, hist, rightW)
|
|
|
|
leftLines := strings.Split(left, "\n")
|
|
rightLines := strings.Split(right, "\n")
|
|
|
|
lineCount := len(leftLines)
|
|
if len(rightLines) > lineCount {
|
|
lineCount = len(rightLines)
|
|
}
|
|
for len(leftLines) < lineCount {
|
|
leftLines = append(leftLines, "")
|
|
}
|
|
for len(rightLines) < lineCount {
|
|
rightLines = append(rightLines, "")
|
|
}
|
|
|
|
divChar := m.st.subtleStyle.Render("│")
|
|
leftStyle := lipgloss.NewStyle().Width(leftW).MaxWidth(leftW)
|
|
rightStyle := lipgloss.NewStyle().Width(rightW).MaxWidth(rightW)
|
|
|
|
var b strings.Builder
|
|
for i := range lineCount {
|
|
l := leftStyle.Render(leftLines[i])
|
|
r := rightStyle.Render(rightLines[i])
|
|
b.WriteString(l + " " + divChar + " " + r + "\n")
|
|
}
|
|
|
|
b.WriteString(" " + m.detailKeys() + "\n")
|
|
|
|
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
|
|
}
|
|
|
|
func (m Model) detailLeftCol(site models.Site, hist monitor.SiteHistory, width int) string {
|
|
var b strings.Builder
|
|
|
|
if len(hist.Latencies) > 0 {
|
|
chartW := width - 2
|
|
if chartW < 20 {
|
|
chartW = 20
|
|
}
|
|
chart := m.latencyChart(hist.Latencies, hist.Statuses, chartW, 3)
|
|
if chart != "" {
|
|
b.WriteString(chart + "\n")
|
|
}
|
|
}
|
|
|
|
if len(m.detailDailyDays) > 0 && m.detailChangesSiteID == site.ID {
|
|
timelineW := width - 2
|
|
if timelineW < 20 {
|
|
timelineW = 20
|
|
}
|
|
b.WriteString(" " + m.st.subtleStyle.Render("30d") + " " + m.uptimeTimeline(m.detailDailyDays, timelineW) + "\n")
|
|
}
|
|
|
|
return strings.TrimRight(b.String(), "\n")
|
|
}
|
|
|
|
func (m Model) detailRightCol(site models.Site, hist monitor.SiteHistory, width int) string {
|
|
dot := m.st.subtleStyle.Render(" · ")
|
|
label := m.st.subtleStyle
|
|
|
|
var b strings.Builder
|
|
|
|
// Line 1: status + latency + last check
|
|
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
|
|
parts := []string{status}
|
|
if site.Latency > 0 {
|
|
parts = append(parts, m.fmtLatency(site.Latency))
|
|
}
|
|
if !site.LastCheck.IsZero() {
|
|
parts = append(parts, m.fmtTimeAgo(site.LastCheck))
|
|
}
|
|
b.WriteString(strings.Join(parts, dot) + "\n")
|
|
|
|
// Line 2: type-specific details
|
|
typeParts := m.detailTypeLine(site)
|
|
if len(typeParts) > 0 {
|
|
b.WriteString(strings.Join(typeParts, dot) + "\n")
|
|
}
|
|
|
|
// Line 3: uptime + retries
|
|
uptimeParts := []string{label.Render("Uptime") + " " + m.fmtUptime(hist.Statuses)}
|
|
if site.Type != "group" && site.MaxRetries > 0 {
|
|
uptimeParts = append(uptimeParts, label.Render("Retries")+" "+m.fmtRetries(site))
|
|
}
|
|
b.WriteString(strings.Join(uptimeParts, dot) + "\n")
|
|
|
|
// Error line (if down/broken)
|
|
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp ||
|
|
site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
|
|
errW := width - 8
|
|
if errW < 20 {
|
|
errW = 20
|
|
}
|
|
b.WriteString(label.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\n")
|
|
}
|
|
|
|
// Blank line before state changes
|
|
b.WriteString("\n")
|
|
|
|
// State changes (one per line, compact)
|
|
var stateChanges []models.StateChange
|
|
if m.detailChangesSiteID == site.ID {
|
|
stateChanges = m.detailChanges
|
|
}
|
|
if len(stateChanges) > 0 {
|
|
limit := 5
|
|
if len(stateChanges) < limit {
|
|
limit = len(stateChanges)
|
|
}
|
|
for _, sc := range stateChanges[:limit] {
|
|
ago := fmtDuration(time.Since(sc.ChangedAt))
|
|
arrow := m.st.subtleStyle.Render("→")
|
|
from := m.fmtStatusWord(sc.FromStatus)
|
|
to := m.fmtStatusWord(sc.ToStatus)
|
|
entry := from + " " + arrow + " " + to + " " + m.st.subtleStyle.Render(ago+" ago")
|
|
if sc.ErrorReason != "" {
|
|
reasonW := width - 30
|
|
if reasonW < 15 {
|
|
reasonW = 15
|
|
}
|
|
entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonW))
|
|
}
|
|
b.WriteString(entry + "\n")
|
|
}
|
|
}
|
|
|
|
return strings.TrimRight(b.String(), "\n")
|
|
}
|
|
|
|
func (m Model) detailTypeLine(site models.Site) []string {
|
|
label := m.st.subtleStyle
|
|
var parts []string
|
|
|
|
switch site.Type {
|
|
case "http":
|
|
if site.StatusCode > 0 {
|
|
codeStr := fmt.Sprintf("HTTP %d", site.StatusCode)
|
|
if site.StatusCode >= httpErrorThreshold {
|
|
parts = append(parts, m.st.dangerStyle.Render(codeStr))
|
|
} else {
|
|
parts = append(parts, m.st.specialStyle.Render(codeStr))
|
|
}
|
|
}
|
|
if site.CheckSSL && site.HasSSL {
|
|
days := int(time.Until(site.CertExpiry).Hours() / 24)
|
|
sslStr := fmt.Sprintf("SSL %dd", days)
|
|
switch {
|
|
case days <= 0:
|
|
parts = append(parts, m.st.dangerStyle.Render("SSL EXPIRED"))
|
|
case days <= site.ExpiryThreshold:
|
|
parts = append(parts, m.st.warnStyle.Render(sslStr))
|
|
default:
|
|
parts = append(parts, m.st.specialStyle.Render(sslStr))
|
|
}
|
|
}
|
|
if site.URL != "" {
|
|
parts = append(parts, label.Render(limitStr(site.URL, 40)))
|
|
}
|
|
case "push":
|
|
parts = append(parts, label.Render("Push"))
|
|
if site.Interval > 0 {
|
|
parts = append(parts, label.Render(fmt.Sprintf("every %s", fmtDuration(time.Duration(site.Interval)*time.Second))))
|
|
}
|
|
if !site.LastSuccessAt.IsZero() {
|
|
parts = append(parts, label.Render("last")+" "+m.fmtTimeAgo(site.LastSuccessAt))
|
|
}
|
|
case "ping":
|
|
parts = append(parts, label.Render("Ping"))
|
|
if site.Hostname != "" {
|
|
parts = append(parts, label.Render(site.Hostname))
|
|
}
|
|
case "port":
|
|
parts = append(parts, label.Render("Port"))
|
|
if site.Hostname != "" {
|
|
target := site.Hostname
|
|
if site.Port > 0 {
|
|
target = fmt.Sprintf("%s:%d", site.Hostname, site.Port)
|
|
}
|
|
parts = append(parts, label.Render(target))
|
|
}
|
|
case "dns":
|
|
parts = append(parts, label.Render("DNS"))
|
|
if site.DNSResolveType != "" {
|
|
parts = append(parts, label.Render(site.DNSResolveType))
|
|
}
|
|
if site.DNSServer != "" {
|
|
parts = append(parts, label.Render(site.DNSServer))
|
|
}
|
|
}
|
|
|
|
return parts
|
|
}
|
|
|
|
// viewDetailSingleCol is the narrow-terminal fallback (original stacked layout).
|
|
func (m Model) viewDetailSingleCol(site models.Site, hist monitor.SiteHistory, width int) string {
|
|
var b strings.Builder
|
|
dot := m.st.subtleStyle.Render(" · ")
|
|
|
|
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
|
|
parts := []string{status}
|
|
if site.Latency > 0 {
|
|
parts = append(parts, m.fmtLatency(site.Latency))
|
|
}
|
|
parts = append(parts, fmt.Sprintf("Uptime %s", m.fmtUptime(hist.Statuses)))
|
|
if !site.LastCheck.IsZero() {
|
|
parts = append(parts, m.fmtTimeAgo(site.LastCheck))
|
|
}
|
|
b.WriteString(" " + strings.Join(parts, dot) + "\n")
|
|
|
|
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp ||
|
|
site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
|
|
errW := width - 12
|
|
if errW < 20 {
|
|
errW = 20
|
|
}
|
|
b.WriteString(" " + m.st.subtleStyle.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\n")
|
|
}
|
|
|
|
var stateChanges []models.StateChange
|
|
if m.detailChangesSiteID == site.ID {
|
|
stateChanges = m.detailChanges
|
|
}
|
|
if len(stateChanges) > 0 {
|
|
limit := 3
|
|
if len(stateChanges) < limit {
|
|
limit = len(stateChanges)
|
|
}
|
|
var scParts []string
|
|
for _, sc := range stateChanges[:limit] {
|
|
ago := fmtDuration(time.Since(sc.ChangedAt))
|
|
arrow := m.st.subtleStyle.Render("→")
|
|
from := m.fmtStatusWord(sc.FromStatus)
|
|
to := m.fmtStatusWord(sc.ToStatus)
|
|
entry := from + " " + arrow + " " + to + " " + m.st.subtleStyle.Render(ago+" ago")
|
|
if sc.ErrorReason != "" {
|
|
entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, 30))
|
|
}
|
|
scParts = append(scParts, entry)
|
|
}
|
|
b.WriteString(" " + strings.Join(scParts, dot) + "\n")
|
|
}
|
|
|
|
if len(hist.Latencies) > 0 {
|
|
chartW := width - 4
|
|
if chartW < 20 {
|
|
chartW = 20
|
|
}
|
|
chart := m.latencyChart(hist.Latencies, hist.Statuses, chartW, 3)
|
|
if chart != "" {
|
|
b.WriteString(chart + "\n")
|
|
}
|
|
}
|
|
|
|
if len(m.detailDailyDays) > 0 && m.detailChangesSiteID == site.ID {
|
|
timelineW := width - 4
|
|
if timelineW < 20 {
|
|
timelineW = 20
|
|
}
|
|
b.WriteString(" " + m.st.subtleStyle.Render("30d") + " " + m.uptimeTimeline(m.detailDailyDays, timelineW) + "\n")
|
|
}
|
|
|
|
b.WriteString(" " + m.detailKeys() + "\n")
|
|
|
|
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
|
|
}
|
|
|
|
func (m Model) detailKeys() string {
|
|
return m.st.subtleStyle.Render("[h] History [s] SLA [e] Edit [q/Esc] Back")
|
|
}
|
|
|
|
func (m Model) fmtStatusWord(status string) string {
|
|
switch status {
|
|
case "DOWN":
|
|
return m.st.dangerStyle.Render("DOWN")
|
|
case "UP":
|
|
return m.st.specialStyle.Render("UP")
|
|
default:
|
|
return m.st.subtleStyle.Render(status)
|
|
}
|
|
}
|