feat(tui): master-detail layout with sidebar SLA/history #163
@@ -10,127 +10,104 @@ import (
|
||||
"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)
|
||||
return m.viewDetailSidebar(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)
|
||||
|
||||
func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, width int) string {
|
||||
dot := m.st.subtleStyle.Render(" · ")
|
||||
label := m.st.subtleStyle
|
||||
var b strings.Builder
|
||||
for i := range lineCount {
|
||||
l := leftStyle.Render(leftLines[i])
|
||||
r := rightStyle.Render(rightLines[i])
|
||||
b.WriteString(l + " " + divChar + " " + r + "\n")
|
||||
innerW := width - 4
|
||||
if innerW < 20 {
|
||||
innerW = 20
|
||||
}
|
||||
|
||||
b.WriteString(" " + m.detailKeys() + "\n")
|
||||
// Status + latency + last check
|
||||
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
|
||||
statusParts := []string{status}
|
||||
if site.Latency > 0 {
|
||||
statusParts = append(statusParts, m.fmtLatency(site.Latency))
|
||||
}
|
||||
if !site.LastCheck.IsZero() {
|
||||
statusParts = append(statusParts, m.fmtTimeAgo(site.LastCheck))
|
||||
}
|
||||
b.WriteString(" " + strings.Join(statusParts, dot) + "\n")
|
||||
|
||||
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
|
||||
}
|
||||
// Type-specific details
|
||||
typeParts := m.detailTypeLine(site)
|
||||
if len(typeParts) > 0 {
|
||||
b.WriteString(" " + strings.Join(typeParts, dot) + "\n")
|
||||
}
|
||||
|
||||
func (m Model) detailLeftCol(site models.Site, hist monitor.SiteHistory, width int) string {
|
||||
var b strings.Builder
|
||||
// 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")
|
||||
|
||||
if len(hist.Latencies) > 0 {
|
||||
chartW := width - 2
|
||||
if chartW < 20 {
|
||||
chartW = 20
|
||||
// Error line
|
||||
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp ||
|
||||
site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
|
||||
errW := innerW
|
||||
if errW < 20 {
|
||||
errW = 20
|
||||
}
|
||||
chart := m.latencyChart(hist.Latencies, hist.Statuses, chartW, 3)
|
||||
b.WriteString(" " + label.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
|
||||
// Latency chart
|
||||
if len(hist.Latencies) > 0 {
|
||||
chart := m.latencyChart(hist.Latencies, hist.Statuses, innerW, 3)
|
||||
if chart != "" {
|
||||
b.WriteString(chart + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// 30d uptime timeline
|
||||
if len(m.detailDailyDays) > 0 && m.detailChangesSiteID == site.ID {
|
||||
timelineW := width - 2
|
||||
if timelineW < 20 {
|
||||
timelineW = 20
|
||||
b.WriteString(" " + label.Render("30d") + " " + m.uptimeTimeline(m.detailDailyDays, innerW) + "\n")
|
||||
}
|
||||
|
||||
// Sparkline + min/avg/max
|
||||
if site.Type != "push" && len(hist.Latencies) > 0 {
|
||||
b.WriteString(" " + m.latencySparkline(hist.Latencies, hist.Statuses, innerW, nil) + "\n")
|
||||
var minL, maxL, total time.Duration
|
||||
count := 0
|
||||
for i, l := range hist.Latencies {
|
||||
if i < len(hist.Statuses) && !hist.Statuses[i] {
|
||||
continue
|
||||
}
|
||||
if count == 0 {
|
||||
minL, maxL = l, l
|
||||
} else if l < minL {
|
||||
minL = l
|
||||
} else if l > maxL {
|
||||
maxL = l
|
||||
}
|
||||
total += l
|
||||
count++
|
||||
}
|
||||
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
|
||||
if count > 0 {
|
||||
avg := total / time.Duration(count)
|
||||
fmt.Fprintf(&b, " %s %dms %s %dms %s %dms\n",
|
||||
label.Render("Min"), minL.Milliseconds(),
|
||||
label.Render("Avg"), avg.Milliseconds(),
|
||||
label.Render("Max"), maxL.Milliseconds())
|
||||
}
|
||||
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)
|
||||
// State changes
|
||||
var stateChanges []models.StateChange
|
||||
if m.detailChangesSiteID == site.ID {
|
||||
stateChanges = m.detailChanges
|
||||
@@ -145,19 +122,23 @@ func (m Model) detailRightCol(site models.Site, hist monitor.SiteHistory, width
|
||||
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")
|
||||
entry := from + " " + arrow + " " + to + " " + label.Render(ago+" ago")
|
||||
if sc.ErrorReason != "" {
|
||||
reasonW := width - 30
|
||||
reasonW := innerW - 25
|
||||
if reasonW < 15 {
|
||||
reasonW = 15
|
||||
}
|
||||
entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonW))
|
||||
}
|
||||
b.WriteString(entry + "\n")
|
||||
b.WriteString(" " + entry + "\n")
|
||||
}
|
||||
} else {
|
||||
b.WriteString(" " + label.Render("No state changes") + "\n")
|
||||
}
|
||||
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
b.WriteString("\n " + m.detailKeys() + "\n")
|
||||
|
||||
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
|
||||
}
|
||||
|
||||
func (m Model) detailTypeLine(site models.Site) []string {
|
||||
@@ -224,79 +205,6 @@ func (m Model) detailTypeLine(site models.Site) []string {
|
||||
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("[e] Edit [h] History [s] SLA [q/Esc] Back")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user