From 822eda4dfd6878b6d88dd79432fdd14a2ca6321f Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sun, 28 Jun 2026 10:23:45 -0400 Subject: [PATCH] feat(tui): two-column detail panel with type-specific enrichment Redesign the inline detail panel from a single left-aligned column to a two-column layout that uses the full panel width. Left column (55%): latency chart, min/avg/max stats, 30-day uptime timeline. Right column (45%): status/latency/last-check summary, type- specific metadata (HTTP response code + SSL expiry, ping hostname, port target, push interval/last heartbeat, DNS resolver), uptime percentage + retry count, and up to 5 state changes listed vertically with error reasons. Narrow terminals (<80 cols) fall back to the original stacked single-column layout. --- internal/tui/view_detail_inline.go | 238 +++++++++++++++++++++++++++-- 1 file changed, 223 insertions(+), 15 deletions(-) diff --git a/internal/tui/view_detail_inline.go b/internal/tui/view_detail_inline.go index 9c80e8b..5f10078 100644 --- a/internal/tui/view_detail_inline.go +++ b/internal/tui/view_detail_inline.go @@ -6,9 +6,12 @@ import ( "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 "" @@ -16,42 +19,247 @@ func (m Model) viewDetailInline(width int) string { 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") + } + + keys := m.st.subtleStyle.Render("[h] History [s] SLA [e] Edit [esc] Close") + b.WriteString(" " + keys + "\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)) - latency := m.fmtLatency(site.Latency) - uptime := m.fmtUptime(hist.Statuses) - - line1Parts := []string{status} + parts := []string{status} if site.Latency > 0 { - line1Parts = append(line1Parts, latency) + parts = append(parts, m.fmtLatency(site.Latency)) } - line1Parts = append(line1Parts, fmt.Sprintf("Uptime %s", uptime)) if !site.LastCheck.IsZero() { - line1Parts = append(line1Parts, fmt.Sprintf("Checked %s", m.fmtTimeAgo(site.LastCheck))) + parts = append(parts, m.fmtTimeAgo(site.LastCheck)) } - b.WriteString(" " + strings.Join(line1Parts, m.st.subtleStyle.Render(" · ")) + "\n") + 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 - 12 + errW := width - 8 if errW < 20 { errW = 20 } - errMsg := limitStr(site.LastError, errW) - b.WriteString(" " + m.st.subtleStyle.Render("Error") + " " + m.st.dangerStyle.Render(errMsg) + "\n") + 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 { - var parts []string - limit := 3 + 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("→") @@ -61,9 +269,9 @@ func (m Model) viewDetailInline(width int) string { if sc.ErrorReason != "" { entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, 30)) } - parts = append(parts, entry) + scParts = append(scParts, entry) } - b.WriteString(" " + strings.Join(parts, m.st.subtleStyle.Render(" · ")) + "\n") + b.WriteString(" " + strings.Join(scParts, dot) + "\n") } if len(hist.Latencies) > 0 {