feat(tui): uptime timeline, response histogram, detail panel overhaul #149
+13
-1
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
@@ -191,8 +192,19 @@ func (m *Model) loadTabDataCmd() tea.Cmd {
|
|||||||
// goroutine. View renders the cached result rather than querying the DB.
|
// goroutine. View renders the cached result rather than querying the DB.
|
||||||
func (m *Model) loadDetailCmd(siteID int) tea.Cmd {
|
func (m *Model) loadDetailCmd(siteID int) tea.Cmd {
|
||||||
eng := m.engine
|
eng := m.engine
|
||||||
|
var currentStatus models.Status
|
||||||
|
for _, s := range m.sites {
|
||||||
|
if s.ID == siteID {
|
||||||
|
currentStatus = s.Status
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
return detailDataMsg{siteID: siteID, changes: eng.GetStateChanges(siteID, 5)}
|
changes := eng.GetStateChanges(siteID, 5)
|
||||||
|
now := time.Now()
|
||||||
|
allChanges := eng.GetStateChangesSince(siteID, now.Add(-30*24*time.Hour))
|
||||||
|
daily := monitor.ComputeDailyBreakdown(allChanges, currentStatus, 30, now)
|
||||||
|
return detailDataMsg{siteID: siteID, changes: changes, dailyDays: daily}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
type latencyBucket struct {
|
||||||
|
label string
|
||||||
|
min int64
|
||||||
|
max int64
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) latencyHistogram(latencies []time.Duration, statuses []bool, width int) string {
|
||||||
|
if len(latencies) == 0 || width < 30 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
buckets := []latencyBucket{
|
||||||
|
{"0-50ms", 0, 50, 0},
|
||||||
|
{"50-100ms", 50, 100, 0},
|
||||||
|
{"100-200ms", 100, 200, 0},
|
||||||
|
{"200-500ms", 200, 500, 0},
|
||||||
|
{"500ms+", 500, 999999, 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, l := range latencies {
|
||||||
|
if i < len(statuses) && !statuses[i] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ms := l.Milliseconds()
|
||||||
|
for j := range buckets {
|
||||||
|
if ms >= buckets[j].min && ms < buckets[j].max {
|
||||||
|
buckets[j].count++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
maxCount := 0
|
||||||
|
for _, b := range buckets {
|
||||||
|
if b.count > maxCount {
|
||||||
|
maxCount = b.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if maxCount == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
labelW := 10
|
||||||
|
countW := len(fmt.Sprintf("%d", maxCount))
|
||||||
|
barW := width - labelW - countW - 4
|
||||||
|
if barW < 5 {
|
||||||
|
barW = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, b := range buckets {
|
||||||
|
fill := 0
|
||||||
|
if maxCount > 0 {
|
||||||
|
fill = b.count * barW / maxCount
|
||||||
|
}
|
||||||
|
|
||||||
|
var barColor lipgloss.Style
|
||||||
|
switch {
|
||||||
|
case b.min < 100:
|
||||||
|
barColor = m.st.specialStyle
|
||||||
|
case b.min < 500:
|
||||||
|
barColor = m.st.warnStyle
|
||||||
|
default:
|
||||||
|
barColor = m.st.dangerStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
bar := barColor.Render(strings.Repeat("█", fill))
|
||||||
|
if fill < barW {
|
||||||
|
bar += m.st.subtleStyle.Render(strings.Repeat("░", barW-fill))
|
||||||
|
}
|
||||||
|
|
||||||
|
label := fmt.Sprintf("%*s", labelW, b.label)
|
||||||
|
count := fmt.Sprintf("%*d", countW, b.count)
|
||||||
|
sb.WriteString(" " + m.st.subtleStyle.Render(label) + " " + bar + " " + count + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||||
)
|
)
|
||||||
|
|
||||||
// tabRefreshTTL bounds how often the DB-backed tab data (alerts, users, nodes,
|
// tabRefreshTTL bounds how often the DB-backed tab data (alerts, users, nodes,
|
||||||
@@ -34,8 +35,9 @@ type tabDataMsg struct {
|
|||||||
// on entry and refreshed on the tab-data cadence so View never touches the
|
// on entry and refreshed on the tab-data cadence so View never touches the
|
||||||
// database.
|
// database.
|
||||||
type detailDataMsg struct {
|
type detailDataMsg struct {
|
||||||
siteID int
|
siteID int
|
||||||
changes []models.StateChange
|
changes []models.StateChange
|
||||||
|
dailyDays []monitor.DayReport
|
||||||
}
|
}
|
||||||
|
|
||||||
// historyDataMsg carries the full state-change history for the history view.
|
// historyDataMsg carries the full state-change history for the history view.
|
||||||
|
|||||||
@@ -199,6 +199,8 @@ type Model struct {
|
|||||||
detailOpen bool
|
detailOpen bool
|
||||||
detailChanges []models.StateChange
|
detailChanges []models.StateChange
|
||||||
detailChangesSiteID int
|
detailChangesSiteID int
|
||||||
|
detailDailyDays []monitor.DayReport
|
||||||
|
detailViewport viewport.Model
|
||||||
|
|
||||||
filterMode bool
|
filterMode bool
|
||||||
filterText string
|
filterText string
|
||||||
|
|||||||
+14
-1
@@ -28,6 +28,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.detailChanges = msg.changes
|
m.detailChanges = msg.changes
|
||||||
m.detailChangesSiteID = msg.siteID
|
m.detailChangesSiteID = msg.siteID
|
||||||
|
m.detailDailyDays = msg.dailyDays
|
||||||
return m, nil
|
return m, nil
|
||||||
case historyDataMsg:
|
case historyDataMsg:
|
||||||
if msg.siteID != m.historySiteID {
|
if msg.siteID != m.historySiteID {
|
||||||
@@ -142,7 +143,7 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const detailInlineHeight = 11
|
const detailInlineHeight = 12
|
||||||
|
|
||||||
func (m *Model) recalcLayout() {
|
func (m *Model) recalcLayout() {
|
||||||
chrome := chromeBase
|
chrome := chromeBase
|
||||||
@@ -367,6 +368,18 @@ func (m *Model) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
func (m *Model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m *Model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
|
case "up", "k":
|
||||||
|
m.detailViewport.ScrollUp(1)
|
||||||
|
return m, nil
|
||||||
|
case "down", "j":
|
||||||
|
m.detailViewport.ScrollDown(1)
|
||||||
|
return m, nil
|
||||||
|
case "pgup":
|
||||||
|
m.detailViewport.ScrollUp(m.detailViewport.Height / 2)
|
||||||
|
return m, nil
|
||||||
|
case "pgdown":
|
||||||
|
m.detailViewport.ScrollDown(m.detailViewport.Height / 2)
|
||||||
|
return m, nil
|
||||||
case "esc":
|
case "esc":
|
||||||
if m.sparkTooltipIdx >= 0 {
|
if m.sparkTooltipIdx >= 0 {
|
||||||
m.sparkTooltipIdx = -1
|
m.sparkTooltipIdx = -1
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m Model) uptimeTimeline(days []monitor.DayReport, width int) string {
|
||||||
|
if len(days) == 0 {
|
||||||
|
return m.st.subtleStyle.Render("No uptime data")
|
||||||
|
}
|
||||||
|
|
||||||
|
maxDays := width - 10
|
||||||
|
if maxDays < 10 {
|
||||||
|
maxDays = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
display := days
|
||||||
|
if len(display) > maxDays {
|
||||||
|
display = display[len(display)-maxDays:]
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, d := range display {
|
||||||
|
ch := "█"
|
||||||
|
switch {
|
||||||
|
case d.UptimePct >= 99.0:
|
||||||
|
sb.WriteString(m.st.specialStyle.Render(ch))
|
||||||
|
case d.UptimePct >= 95.0:
|
||||||
|
sb.WriteString(m.st.warnStyle.Render(ch))
|
||||||
|
case d.UptimePct > 0:
|
||||||
|
sb.WriteString(m.st.dangerStyle.Render(ch))
|
||||||
|
default:
|
||||||
|
sb.WriteString(m.st.subtleStyle.Render("░"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pct := days[len(days)-1].UptimePct
|
||||||
|
pctStyle := m.st.specialStyle
|
||||||
|
if pct < 99.0 {
|
||||||
|
pctStyle = m.st.dangerStyle
|
||||||
|
} else if pct < 99.9 {
|
||||||
|
pctStyle = m.st.warnStyle
|
||||||
|
}
|
||||||
|
sb.WriteString(" " + pctStyle.Render(fmt.Sprintf("%.2f%%", pct)))
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
+181
-153
@@ -8,7 +8,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,6 +19,7 @@ func (m Model) viewDetailPanel() string {
|
|||||||
hist, _ := m.engine.GetHistory(site.ID)
|
hist, _ := m.engine.GetHistory(site.ID)
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
totalW := m.termWidth - chromePadH
|
||||||
|
|
||||||
var breadcrumb string
|
var breadcrumb string
|
||||||
if site.ParentID > 0 {
|
if site.ParentID > 0 {
|
||||||
@@ -36,29 +36,103 @@ func (m Model) viewDetailPanel() string {
|
|||||||
b.WriteString(breadcrumb + "\n")
|
b.WriteString(breadcrumb + "\n")
|
||||||
b.WriteString(m.divider() + "\n")
|
b.WriteString(m.divider() + "\n")
|
||||||
|
|
||||||
row := func(label, value string) {
|
// Two-column layout for key info
|
||||||
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render(label), value)
|
colW := (totalW - 4) / 2
|
||||||
|
if colW < 30 {
|
||||||
|
colW = 30
|
||||||
}
|
}
|
||||||
|
|
||||||
section := func(label string) {
|
row := func(label, value string) string {
|
||||||
b.WriteString("\n" + m.st.subtleStyle.Render(" "+label) + "\n")
|
return fmt.Sprintf(" %-16s %s", m.st.subtleStyle.Render(label), value)
|
||||||
}
|
}
|
||||||
|
|
||||||
row("Status", m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)))
|
divW := totalW - 4
|
||||||
|
if divW < 20 {
|
||||||
|
divW = 20
|
||||||
|
}
|
||||||
|
sectionDiv := m.st.subtleStyle.Render(strings.Repeat("─", divW))
|
||||||
|
sectionHead := func(title string) string {
|
||||||
|
return m.st.titleStyle.Render(" "+title) + " " + m.st.subtleStyle.Render(strings.Repeat("─", divW-len(title)-3))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left column: endpoint details
|
||||||
|
var left []string
|
||||||
|
left = append(left, m.st.titleStyle.Render(" ENDPOINT"))
|
||||||
|
left = append(left, row("Type", site.Type))
|
||||||
|
if site.URL != "" {
|
||||||
|
left = append(left, row("URL", limitStr(site.URL, colW-19)))
|
||||||
|
}
|
||||||
|
if site.Hostname != "" {
|
||||||
|
left = append(left, row("Host", site.Hostname))
|
||||||
|
}
|
||||||
|
if site.Port > 0 {
|
||||||
|
left = append(left, row("Port", strconv.Itoa(site.Port)))
|
||||||
|
}
|
||||||
|
left = append(left, row("Interval", fmt.Sprintf("%ds", site.Interval)))
|
||||||
|
if site.MaxRetries > 0 {
|
||||||
|
left = append(left, row("Retries", m.fmtRetries(site)))
|
||||||
|
}
|
||||||
|
if site.Regions != "" {
|
||||||
|
left = append(left, row("Regions", site.Regions))
|
||||||
|
}
|
||||||
|
if site.Description != "" {
|
||||||
|
left = append(left, row("Description", limitStr(site.Description, colW-19)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right column: status + timing + HTTP
|
||||||
|
var right []string
|
||||||
|
right = append(right, m.st.titleStyle.Render(" STATUS"))
|
||||||
|
right = append(right, row("Status", m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))))
|
||||||
|
right = append(right, row("Latency", m.fmtLatency(site.Latency)))
|
||||||
|
right = append(right, row("Uptime", m.fmtUptime(hist.Statuses)))
|
||||||
|
if !site.StatusChangedAt.IsZero() {
|
||||||
|
dur := time.Since(site.StatusChangedAt)
|
||||||
|
right = append(right, row("State Since", fmtDuration(dur)+" ago"))
|
||||||
|
}
|
||||||
|
if !site.LastCheck.IsZero() {
|
||||||
|
right = append(right, row("Last Check", m.fmtTimeAgo(site.LastCheck)))
|
||||||
|
}
|
||||||
|
if !site.LastSuccessAt.IsZero() {
|
||||||
|
right = append(right, row("Last Success", m.fmtTimeAgo(site.LastSuccessAt)))
|
||||||
|
}
|
||||||
|
|
||||||
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp || site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
|
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp || site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
|
||||||
errWidth := m.termWidth - chromePadH - 19
|
errW := colW - 19
|
||||||
if errWidth < 30 {
|
if errW < 20 {
|
||||||
errWidth = 30
|
errW = 20
|
||||||
}
|
}
|
||||||
wrapped := lipgloss.NewStyle().Width(errWidth).Render(site.LastError)
|
right = append(right, row("Error", m.st.dangerStyle.Render(limitStr(site.LastError, errW))))
|
||||||
row("Error", m.st.dangerStyle.Render(wrapped))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if site.Type == "http" && site.StatusCode > 0 {
|
if site.Type == "http" {
|
||||||
row("HTTP Code", strconv.Itoa(site.StatusCode))
|
if site.StatusCode > 0 {
|
||||||
|
right = append(right, row("HTTP Code", strconv.Itoa(site.StatusCode)))
|
||||||
|
}
|
||||||
|
codes := site.AcceptedCodes
|
||||||
|
if codes == "" {
|
||||||
|
codes = "200-299"
|
||||||
|
}
|
||||||
|
right = append(right, row("Codes", codes))
|
||||||
|
right = append(right, row("SSL", m.fmtSSL(site)))
|
||||||
|
if site.Method != "" && site.Method != "GET" {
|
||||||
|
right = append(right, row("Method", site.Method))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pad shorter column
|
||||||
|
for len(left) < len(right) {
|
||||||
|
left = append(left, "")
|
||||||
|
}
|
||||||
|
for len(right) < len(left) {
|
||||||
|
right = append(right, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
leftCol := lipgloss.NewStyle().Width(colW).Render(strings.Join(left, "\n"))
|
||||||
|
rightCol := lipgloss.NewStyle().Width(colW).Render(strings.Join(right, "\n"))
|
||||||
|
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol) + "\n")
|
||||||
|
b.WriteString("\n" + sectionDiv + "\n")
|
||||||
|
|
||||||
|
// Connection chain (full width, only on errors)
|
||||||
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp) && site.LastError != "" {
|
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp) && site.LastError != "" {
|
||||||
chain := connectionChain(site.LastError, site.Type, site.StatusCode, strings.HasPrefix(site.URL, "https"))
|
chain := connectionChain(site.LastError, site.Type, site.StatusCode, strings.HasPrefix(site.URL, "https"))
|
||||||
if len(chain) > 0 {
|
if len(chain) > 0 {
|
||||||
@@ -87,81 +161,22 @@ func (m Model) viewDetailPanel() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !site.StatusChangedAt.IsZero() {
|
// Maintenance
|
||||||
dur := time.Since(site.StatusChangedAt)
|
|
||||||
row("State Since", site.StatusChangedAt.Format("2006-01-02 15:04:05")+" ("+fmtDuration(dur)+")")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !site.LastSuccessAt.IsZero() {
|
|
||||||
ago := time.Since(site.LastSuccessAt)
|
|
||||||
row("Last Success", site.LastSuccessAt.Format("15:04:05")+" ("+fmtDuration(ago)+" ago)")
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.isMonitorInMaintenance(site.ID) {
|
if m.isMonitorInMaintenance(site.ID) {
|
||||||
for _, mw := range m.maintenanceWindows {
|
for _, mw := range m.maintenanceWindows {
|
||||||
if mw.Type == "maintenance" && (mw.MonitorID == 0 || mw.MonitorID == site.ID || mw.MonitorID == site.ParentID) {
|
if mw.Type == "maintenance" && (mw.MonitorID == 0 || mw.MonitorID == site.ID || mw.MonitorID == site.ParentID) {
|
||||||
row("Maintenance", m.st.maintStyle.Render(mw.Title))
|
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Maintenance"), m.st.maintStyle.Render(mw.Title))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
section("ENDPOINT")
|
// Push token
|
||||||
row("Type", site.Type)
|
|
||||||
if site.Type == "push" && site.Token != "" {
|
if site.Type == "push" && site.Token != "" {
|
||||||
row("Token", site.Token)
|
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Token"), site.Token)
|
||||||
row("Push", "curl -X POST -H 'Authorization: Bearer "+site.Token+"' <host>/api/push")
|
|
||||||
}
|
|
||||||
if site.URL != "" {
|
|
||||||
row("URL", site.URL)
|
|
||||||
}
|
|
||||||
if site.Hostname != "" {
|
|
||||||
row("Host", site.Hostname)
|
|
||||||
}
|
|
||||||
if site.Port > 0 {
|
|
||||||
row("Port", strconv.Itoa(site.Port))
|
|
||||||
}
|
|
||||||
|
|
||||||
section("TIMING")
|
|
||||||
row("Interval", fmt.Sprintf("%ds", site.Interval))
|
|
||||||
if site.Timeout > 0 {
|
|
||||||
row("Timeout", fmt.Sprintf("%ds", site.Timeout))
|
|
||||||
}
|
|
||||||
row("Latency", m.fmtLatency(site.Latency))
|
|
||||||
row("Uptime", m.fmtUptime(hist.Statuses))
|
|
||||||
if !site.LastCheck.IsZero() {
|
|
||||||
row("Last Check", m.fmtTimeAgo(site.LastCheck))
|
|
||||||
}
|
|
||||||
|
|
||||||
if site.Type == "http" {
|
|
||||||
section("HTTP")
|
|
||||||
if site.Method != "" && site.Method != "GET" {
|
|
||||||
row("Method", site.Method)
|
|
||||||
}
|
|
||||||
codes := site.AcceptedCodes
|
|
||||||
if codes == "" {
|
|
||||||
codes = "200-299"
|
|
||||||
}
|
|
||||||
row("Codes", codes)
|
|
||||||
row("SSL", m.fmtSSL(site))
|
|
||||||
if site.IgnoreTLS {
|
|
||||||
row("TLS Verify", m.st.dangerStyle.Render("disabled"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if site.MaxRetries > 0 || site.Regions != "" || site.Description != "" {
|
|
||||||
section("CONFIG")
|
|
||||||
if site.MaxRetries > 0 {
|
|
||||||
row("Retries", m.fmtRetries(site))
|
|
||||||
}
|
|
||||||
if site.Regions != "" {
|
|
||||||
row("Regions", site.Regions)
|
|
||||||
}
|
|
||||||
if site.Description != "" {
|
|
||||||
row("Description", site.Description)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Probe results
|
||||||
probeResults := m.engine.GetProbeResults(site.ID)
|
probeResults := m.engine.GetProbeResults(site.ID)
|
||||||
if len(probeResults) > 0 {
|
if len(probeResults) > 0 {
|
||||||
nodeIDs := make([]string, 0, len(probeResults))
|
nodeIDs := make([]string, 0, len(probeResults))
|
||||||
@@ -169,7 +184,7 @@ func (m Model) viewDetailPanel() string {
|
|||||||
nodeIDs = append(nodeIDs, id)
|
nodeIDs = append(nodeIDs, id)
|
||||||
}
|
}
|
||||||
sort.Strings(nodeIDs)
|
sort.Strings(nodeIDs)
|
||||||
b.WriteString("\n" + m.st.subtleStyle.Render(" PROBE RESULTS") + "\n")
|
b.WriteString("\n" + sectionHead("PROBE RESULTS") + "\n")
|
||||||
for _, nodeID := range nodeIDs {
|
for _, nodeID := range nodeIDs {
|
||||||
result := probeResults[nodeID]
|
result := probeResults[nodeID]
|
||||||
status := m.st.specialStyle.Render("UP")
|
status := m.st.specialStyle.Render("UP")
|
||||||
@@ -186,36 +201,30 @@ func (m Model) viewDetailPanel() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loaded on panel-enter (loadDetailCmd) and cached, so View does no DB IO.
|
// Bottom two-column: graphs left, state changes right
|
||||||
var stateChanges []models.StateChange
|
graphW := (totalW - 4) * 70 / 100
|
||||||
if m.detailChangesSiteID == site.ID {
|
changeW := totalW - 4 - graphW
|
||||||
stateChanges = m.detailChanges
|
if graphW < 30 {
|
||||||
|
graphW = 30
|
||||||
}
|
}
|
||||||
if len(stateChanges) > 0 {
|
if changeW < 20 {
|
||||||
b.WriteString("\n" + m.st.subtleStyle.Render(" STATE CHANGES") + "\n")
|
changeW = 20
|
||||||
for i, sc := range stateChanges {
|
}
|
||||||
ago := fmtDuration(time.Since(sc.ChangedAt))
|
bottomColW := graphW
|
||||||
arrow := m.st.subtleStyle.Render(sc.FromStatus) + " → "
|
|
||||||
if sc.ToStatus == string(models.StatusUp) {
|
// Left: latency + histogram
|
||||||
arrow += m.st.specialStyle.Render(sc.ToStatus)
|
var graphLines []string
|
||||||
} else {
|
sectionLabel := func(title string) string {
|
||||||
arrow += m.st.dangerStyle.Render(sc.ToStatus)
|
return m.st.titleStyle.Render(" " + title)
|
||||||
}
|
|
||||||
line := fmt.Sprintf(" %s %s", arrow, m.st.subtleStyle.Render(ago+" ago"))
|
|
||||||
if dur := computeOutageDuration(stateChanges, i); dur > 0 {
|
|
||||||
line += " " + m.st.warnStyle.Render("outage "+fmtDuration(dur))
|
|
||||||
}
|
|
||||||
if sc.ErrorReason != "" && sc.ToStatus != string(models.StatusUp) {
|
|
||||||
line += " " + m.st.dangerStyle.Render(sc.ErrorReason)
|
|
||||||
}
|
|
||||||
b.WriteString(line + "\n")
|
|
||||||
}
|
|
||||||
b.WriteString(" " + m.st.subtleStyle.Render("[h] History") + "\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString(m.divider() + "\n")
|
graphLines = append(graphLines, sectionLabel("LATENCY"))
|
||||||
if site.Type == "push" {
|
if site.Type == "push" {
|
||||||
b.WriteString(" " + m.zones.Mark("spark-heartbeat", m.heartbeatSparkline(hist.Statuses, detailSparkWidth, nil)))
|
sparkW := bottomColW - 4
|
||||||
|
if sparkW > detailSparkWidth {
|
||||||
|
sparkW = detailSparkWidth
|
||||||
|
}
|
||||||
|
graphLines = append(graphLines, " "+m.heartbeatSparkline(hist.Statuses, sparkW, nil))
|
||||||
if len(hist.Statuses) > 0 {
|
if len(hist.Statuses) > 0 {
|
||||||
up := 0
|
up := 0
|
||||||
for _, s := range hist.Statuses {
|
for _, s := range hist.Statuses {
|
||||||
@@ -223,12 +232,15 @@ func (m Model) viewDetailPanel() string {
|
|||||||
up++
|
up++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&b, "\n %s %d/%d checks up",
|
graphLines = append(graphLines, fmt.Sprintf(" %s %d/%d checks up",
|
||||||
m.st.subtleStyle.Render("Heartbeats"),
|
m.st.subtleStyle.Render("Heartbeats"), up, len(hist.Statuses)))
|
||||||
up, len(hist.Statuses))
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
b.WriteString(" " + m.zones.Mark("spark-latency", m.latencySparkline(hist.Latencies, hist.Statuses, detailSparkWidth, nil)))
|
sparkW := bottomColW - 4
|
||||||
|
if sparkW > detailSparkWidth {
|
||||||
|
sparkW = detailSparkWidth
|
||||||
|
}
|
||||||
|
graphLines = append(graphLines, " "+m.latencySparkline(hist.Latencies, hist.Statuses, sparkW, nil))
|
||||||
var minL, maxL, total time.Duration
|
var minL, maxL, total time.Duration
|
||||||
count := 0
|
count := 0
|
||||||
for i, l := range hist.Latencies {
|
for i, l := range hist.Latencies {
|
||||||
@@ -247,60 +259,76 @@ func (m Model) viewDetailPanel() string {
|
|||||||
}
|
}
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
avg := total / time.Duration(count)
|
avg := total / time.Duration(count)
|
||||||
fmt.Fprintf(&b, "\n %s %dms %s %dms %s %dms",
|
graphLines = append(graphLines, fmt.Sprintf(" %s %dms %s %dms %s %dms",
|
||||||
m.st.subtleStyle.Render("Min"), minL.Milliseconds(),
|
m.st.subtleStyle.Render("Min"), minL.Milliseconds(),
|
||||||
m.st.subtleStyle.Render("Avg"), avg.Milliseconds(),
|
m.st.subtleStyle.Render("Avg"), avg.Milliseconds(),
|
||||||
m.st.subtleStyle.Render("Max"), maxL.Milliseconds())
|
m.st.subtleStyle.Render("Max"), maxL.Milliseconds()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.sparkTooltipIdx >= 0 {
|
if site.Type != "push" && len(hist.Latencies) > 5 {
|
||||||
b.WriteString("\n" + m.renderSparkTooltip(site, hist, detailSparkWidth))
|
graphLines = append(graphLines, "")
|
||||||
|
graphLines = append(graphLines, sectionLabel("DISTRIBUTION"))
|
||||||
|
graphLines = append(graphLines, m.latencyHistogram(hist.Latencies, hist.Statuses, bottomColW))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Right: state changes
|
||||||
|
var changeLines []string
|
||||||
|
var stateChanges []models.StateChange
|
||||||
|
if m.detailChangesSiteID == site.ID {
|
||||||
|
stateChanges = m.detailChanges
|
||||||
|
}
|
||||||
|
changeLines = append(changeLines, sectionLabel("STATE CHANGES"))
|
||||||
|
if len(stateChanges) > 0 {
|
||||||
|
for i, sc := range stateChanges {
|
||||||
|
from := m.fmtStatusWord(string(sc.FromStatus))
|
||||||
|
to := m.fmtStatusWord(string(sc.ToStatus))
|
||||||
|
ago := fmtDuration(time.Since(sc.ChangedAt))
|
||||||
|
line := fmt.Sprintf(" %s → %s %s ago", from, to, ago)
|
||||||
|
if sc.ToStatus == "UP" {
|
||||||
|
dur := computeOutageDuration(stateChanges, i)
|
||||||
|
if dur > 0 {
|
||||||
|
line += " " + m.st.warnStyle.Render("outage "+fmtDuration(dur))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sc.ErrorReason != "" {
|
||||||
|
line += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, changeW-30))
|
||||||
|
}
|
||||||
|
changeLines = append(changeLines, line)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
changeLines = append(changeLines, m.st.subtleStyle.Render(" No state changes"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pad and join
|
||||||
|
for len(graphLines) < len(changeLines) {
|
||||||
|
graphLines = append(graphLines, "")
|
||||||
|
}
|
||||||
|
for len(changeLines) < len(graphLines) {
|
||||||
|
changeLines = append(changeLines, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
graphCol := lipgloss.NewStyle().Width(graphW).Render(strings.Join(graphLines, "\n"))
|
||||||
|
changeCol := lipgloss.NewStyle().Width(changeW).Render(strings.Join(changeLines, "\n"))
|
||||||
|
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, graphCol, changeCol) + "\n")
|
||||||
|
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
b.WriteString(m.divider() + "\n")
|
b.WriteString(m.divider() + "\n")
|
||||||
b.WriteString(m.st.subtleStyle.Render(" [q/Esc] Back [e] Edit [h] History [s] SLA [click] Inspect"))
|
b.WriteString(m.st.subtleStyle.Render(" [q/Esc] Back [e] Edit [h] History [s] SLA [click] Inspect"))
|
||||||
|
|
||||||
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
|
// Wrap in a viewport for scrolling
|
||||||
}
|
content := b.String()
|
||||||
|
contentH := m.termHeight - 4
|
||||||
func (m Model) renderSparkTooltip(site models.Site, hist monitor.SiteHistory, sparkWidth int) string {
|
if contentH < 10 {
|
||||||
idx := m.sparkTooltipIdx
|
contentH = 10
|
||||||
|
}
|
||||||
var dataLen int
|
lines := strings.Split(content, "\n")
|
||||||
if site.Type == "push" {
|
if len(lines) > contentH {
|
||||||
dataLen = len(hist.Statuses)
|
m.detailViewport.SetContent(content)
|
||||||
} else {
|
m.detailViewport.Width = totalW
|
||||||
dataLen = len(hist.Latencies)
|
m.detailViewport.Height = contentH
|
||||||
}
|
return lipgloss.NewStyle().Padding(1, 2).Render(m.detailViewport.View())
|
||||||
if idx < 0 || idx >= dataLen {
|
}
|
||||||
return ""
|
|
||||||
}
|
return lipgloss.NewStyle().Padding(1, 2).Render(content)
|
||||||
|
|
||||||
var parts []string
|
|
||||||
|
|
||||||
checksAgo := dataLen - 1 - idx
|
|
||||||
approxSecs := checksAgo * site.Interval
|
|
||||||
if approxSecs == 0 {
|
|
||||||
parts = append(parts, "latest")
|
|
||||||
} else {
|
|
||||||
parts = append(parts, "~"+fmtDuration(time.Duration(approxSecs)*time.Second)+" ago")
|
|
||||||
}
|
|
||||||
|
|
||||||
if site.Type != "push" && idx < len(hist.Latencies) {
|
|
||||||
parts = append(parts, m.fmtLatency(hist.Latencies[idx]))
|
|
||||||
}
|
|
||||||
|
|
||||||
if idx < len(hist.Statuses) {
|
|
||||||
if hist.Statuses[idx] {
|
|
||||||
parts = append(parts, m.st.specialStyle.Render("UP"))
|
|
||||||
} else {
|
|
||||||
parts = append(parts, m.st.dangerStyle.Render("DOWN"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sep := m.st.subtleStyle.Render(" | ")
|
|
||||||
pos := m.st.subtleStyle.Render(fmt.Sprintf("[%d/%d]", idx+1, dataLen))
|
|
||||||
return " " + strings.Join(parts, sep) + " " + pos
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,14 @@ func (m Model) viewDetailInline(width int) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
keys := m.st.subtleStyle.Render("[h] History [s] SLA [e] Edit [esc] Close")
|
keys := m.st.subtleStyle.Render("[h] History [s] SLA [e] Edit [esc] Close")
|
||||||
b.WriteString(" " + keys + "\n")
|
b.WriteString(" " + keys + "\n")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user