f349d0dfd1
The TUI ran database queries on the UI goroutine: handleTick called refreshData every second, which issued four blocking SQLite queries (GetAllAlerts/GetAllUsers/GetAllNodes/GetAllMaintenanceWindows) and swallowed their errors; viewDetailPanel ran GetStateChanges — a DB query — inside View(), on every render (tick, keypress, mouse). A slow disk stalled input and animation. Split refreshData into refreshLive() (in-memory engine copies only — sites + logs — safe every tick) and loadTabDataCmd(), a tea.Cmd that loads the four DB-backed tables off the UI goroutine and returns a tabDataMsg. handleTick now refreshes live state every tick but dispatches the tab-data load only when older than tabRefreshTTL (5s), so tab-bar counts stay fresh without a per-second query storm. Errors surface to the log instead of being dropped, and a transient failure keeps the previous data rather than blanking the view. The detail panel's state-change history is loaded once on enter via loadDetailCmd and cached on the model; viewDetailPanel reads the cache, so View no longer touches the database. Init kicks an initial load so the dashboard isn't empty on the first frame, and the bare time.Time tick message is now a named tickMsg (no cross-message collision). The test-alert handler's raw goroutine becomes a tea.Cmd. Adds the package's first Update()-driven tests: tab-data load + apply, error-keeps-previous-data, detail cache with a store-hit counter proving View does zero IO across repeated renders, and the handleTick throttle. Full suite green under -race; golangci-lint clean.
301 lines
8.2 KiB
Go
301 lines
8.2 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
func (m Model) viewDetailPanel() string {
|
|
if m.cursor >= len(m.sites) {
|
|
return ""
|
|
}
|
|
site := m.sites[m.cursor]
|
|
hist, _ := m.engine.GetHistory(site.ID)
|
|
|
|
var b strings.Builder
|
|
|
|
var breadcrumb string
|
|
if site.ParentID > 0 {
|
|
for _, s := range m.sites {
|
|
if s.ID == site.ParentID {
|
|
breadcrumb = subtleStyle.Render(" Sites > "+s.Name+" > ") + titleStyle.Render(site.Name)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if breadcrumb == "" {
|
|
breadcrumb = subtleStyle.Render(" Sites > ") + titleStyle.Render(site.Name)
|
|
}
|
|
b.WriteString(breadcrumb + "\n")
|
|
b.WriteString(m.divider() + "\n")
|
|
|
|
row := func(label, value string) {
|
|
fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render(label), value)
|
|
}
|
|
|
|
section := func(label string) {
|
|
b.WriteString("\n" + subtleStyle.Render(" "+label) + "\n")
|
|
}
|
|
|
|
row("Status", fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)))
|
|
|
|
if (site.Status == "DOWN" || site.Status == "SSL EXP" || site.Status == "LATE" || site.Status == "STALE") && site.LastError != "" {
|
|
errWidth := m.termWidth - chromePadH - 19
|
|
if errWidth < 30 {
|
|
errWidth = 30
|
|
}
|
|
wrapped := lipgloss.NewStyle().Width(errWidth).Render(site.LastError)
|
|
row("Error", dangerStyle.Render(wrapped))
|
|
}
|
|
|
|
if site.Type == "http" && site.StatusCode > 0 {
|
|
row("HTTP Code", strconv.Itoa(site.StatusCode))
|
|
}
|
|
|
|
if (site.Status == "DOWN" || site.Status == "SSL EXP") && site.LastError != "" {
|
|
chain := connectionChain(site.LastError, site.Type, site.StatusCode, strings.HasPrefix(site.URL, "https"))
|
|
if len(chain) > 0 {
|
|
b.WriteString("\n")
|
|
for _, step := range chain {
|
|
var icon string
|
|
switch step.Status {
|
|
case stepPassed:
|
|
icon = specialStyle.Render("✓")
|
|
case stepFailed:
|
|
icon = dangerStyle.Render("✗")
|
|
case stepSkipped:
|
|
icon = subtleStyle.Render("·")
|
|
}
|
|
line := fmt.Sprintf(" %s %-16s", icon, step.Name)
|
|
if step.Detail != "" {
|
|
switch step.Status {
|
|
case stepFailed:
|
|
line += " " + dangerStyle.Render(step.Detail)
|
|
case stepSkipped:
|
|
line += " " + subtleStyle.Render(step.Detail)
|
|
}
|
|
}
|
|
b.WriteString(line + "\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
if !site.StatusChangedAt.IsZero() {
|
|
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) {
|
|
for _, mw := range m.maintenanceWindows {
|
|
if mw.Type == "maintenance" && (mw.MonitorID == 0 || mw.MonitorID == site.ID || mw.MonitorID == site.ParentID) {
|
|
row("Maintenance", maintStyle.Render(mw.Title))
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
section("ENDPOINT")
|
|
row("Type", site.Type)
|
|
if site.Type == "push" && site.Token != "" {
|
|
row("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", fmtLatency(site.Latency))
|
|
row("Uptime", fmtUptime(hist.Statuses))
|
|
if !site.LastCheck.IsZero() {
|
|
row("Last Check", 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", fmtSSL(site))
|
|
if site.IgnoreTLS {
|
|
row("TLS Verify", dangerStyle.Render("disabled"))
|
|
}
|
|
}
|
|
|
|
if site.MaxRetries > 0 || site.Regions != "" || site.Description != "" {
|
|
section("CONFIG")
|
|
if site.MaxRetries > 0 {
|
|
row("Retries", fmtRetries(site))
|
|
}
|
|
if site.Regions != "" {
|
|
row("Regions", site.Regions)
|
|
}
|
|
if site.Description != "" {
|
|
row("Description", site.Description)
|
|
}
|
|
}
|
|
|
|
probeResults := m.engine.GetProbeResults(site.ID)
|
|
if len(probeResults) > 0 {
|
|
b.WriteString("\n" + subtleStyle.Render(" PROBE RESULTS") + "\n")
|
|
for nodeID, result := range probeResults {
|
|
status := specialStyle.Render("UP")
|
|
if !result.IsUp {
|
|
status = dangerStyle.Render("DN")
|
|
}
|
|
latency := time.Duration(result.LatencyNs).Milliseconds()
|
|
ago := time.Since(result.CheckedAt).Truncate(time.Second)
|
|
line := fmt.Sprintf(" %-14s %s %dms %s ago", nodeID, status, latency, ago)
|
|
if !result.IsUp && result.ErrorReason != "" {
|
|
line += " " + dangerStyle.Render(result.ErrorReason)
|
|
}
|
|
b.WriteString(line + "\n")
|
|
}
|
|
}
|
|
|
|
// Loaded on panel-enter (loadDetailCmd) and cached, so View does no DB IO.
|
|
var stateChanges []models.StateChange
|
|
if m.detailChangesSiteID == site.ID {
|
|
stateChanges = m.detailChanges
|
|
}
|
|
if len(stateChanges) > 0 {
|
|
b.WriteString("\n" + subtleStyle.Render(" STATE CHANGES") + "\n")
|
|
for i, sc := range stateChanges {
|
|
ago := fmtDuration(time.Since(sc.ChangedAt))
|
|
arrow := subtleStyle.Render(sc.FromStatus) + " → "
|
|
if sc.ToStatus == "UP" {
|
|
arrow += specialStyle.Render(sc.ToStatus)
|
|
} else {
|
|
arrow += dangerStyle.Render(sc.ToStatus)
|
|
}
|
|
line := fmt.Sprintf(" %s %s", arrow, subtleStyle.Render(ago+" ago"))
|
|
if dur := computeOutageDuration(stateChanges, i); dur > 0 {
|
|
line += " " + warnStyle.Render("outage "+fmtDuration(dur))
|
|
}
|
|
if sc.ErrorReason != "" && sc.ToStatus != "UP" {
|
|
line += " " + dangerStyle.Render(sc.ErrorReason)
|
|
}
|
|
b.WriteString(line + "\n")
|
|
}
|
|
b.WriteString(" " + subtleStyle.Render("[h] History") + "\n")
|
|
}
|
|
|
|
b.WriteString(m.divider() + "\n")
|
|
const sparkWidth = 40
|
|
if site.Type == "push" {
|
|
b.WriteString(" " + m.zones.Mark("spark-heartbeat", heartbeatSparkline(hist.Statuses, sparkWidth, "")))
|
|
if len(hist.Statuses) > 0 {
|
|
up := 0
|
|
for _, s := range hist.Statuses {
|
|
if s {
|
|
up++
|
|
}
|
|
}
|
|
fmt.Fprintf(&b, "\n %s %d/%d checks up",
|
|
subtleStyle.Render("Heartbeats"),
|
|
up, len(hist.Statuses))
|
|
}
|
|
} else {
|
|
b.WriteString(" " + m.zones.Mark("spark-latency", latencySparkline(hist.Latencies, hist.Statuses, sparkWidth, "")))
|
|
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++
|
|
}
|
|
if count > 0 {
|
|
avg := total / time.Duration(count)
|
|
fmt.Fprintf(&b, "\n %s %dms %s %dms %s %dms",
|
|
subtleStyle.Render("Min"), minL.Milliseconds(),
|
|
subtleStyle.Render("Avg"), avg.Milliseconds(),
|
|
subtleStyle.Render("Max"), maxL.Milliseconds())
|
|
}
|
|
}
|
|
|
|
if m.sparkTooltipIdx >= 0 {
|
|
b.WriteString("\n" + m.renderSparkTooltip(site, hist, sparkWidth))
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
b.WriteString(m.divider() + "\n")
|
|
b.WriteString(subtleStyle.Render(" [i/Esc] Back [e] Edit [h] History [s] SLA [click] Inspect [q] Quit"))
|
|
|
|
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
|
|
}
|
|
|
|
func (m Model) renderSparkTooltip(site models.Site, hist monitor.SiteHistory, sparkWidth int) string {
|
|
idx := m.sparkTooltipIdx
|
|
|
|
var dataLen int
|
|
if site.Type == "push" {
|
|
dataLen = len(hist.Statuses)
|
|
} else {
|
|
dataLen = len(hist.Latencies)
|
|
}
|
|
if idx < 0 || idx >= dataLen {
|
|
return ""
|
|
}
|
|
|
|
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, fmtLatency(hist.Latencies[idx]))
|
|
}
|
|
|
|
if idx < len(hist.Statuses) {
|
|
if hist.Statuses[idx] {
|
|
parts = append(parts, specialStyle.Render("UP"))
|
|
} else {
|
|
parts = append(parts, dangerStyle.Render("DOWN"))
|
|
}
|
|
}
|
|
|
|
sep := subtleStyle.Render(" | ")
|
|
pos := subtleStyle.Render(fmt.Sprintf("[%d/%d]", idx+1, dataLen))
|
|
return " " + strings.Join(parts, sep) + " " + pos
|
|
}
|