From a59edf84102e918739293820ae4b4883b5036dfd Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Wed, 24 Jun 2026 20:16:01 -0400 Subject: [PATCH 1/6] feat(tui): 30-day uptime timeline in inline detail panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Statuspage-style colored bar — one █ per day, colored by uptime: green (>= 99%), yellow (>= 95%), red (< 95%), gray (no data). 30-day percentage shown to the right. Daily breakdown computed via ComputeDailyBreakdown from state_changes and loaded alongside detail data in loadDetailCmd. Auto-updates on cursor move. --- internal/tui/data.go | 14 ++++++++- internal/tui/messages.go | 6 ++-- internal/tui/tui.go | 1 + internal/tui/update.go | 3 +- internal/tui/uptime_timeline.go | 50 ++++++++++++++++++++++++++++++ internal/tui/view_detail_inline.go | 8 +++++ 6 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 internal/tui/uptime_timeline.go diff --git a/internal/tui/data.go b/internal/tui/data.go index f40df6e..c27a642 100644 --- a/internal/tui/data.go +++ b/internal/tui/data.go @@ -8,6 +8,7 @@ import ( "time" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" + "gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" "gitea.lerkolabs.com/lerkolabs/uptop/internal/store" 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. func (m *Model) loadDetailCmd(siteID int) tea.Cmd { 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 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} } } diff --git a/internal/tui/messages.go b/internal/tui/messages.go index fbf5653..cd7e7c5 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -4,6 +4,7 @@ import ( "time" "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, @@ -34,8 +35,9 @@ type tabDataMsg struct { // on entry and refreshed on the tab-data cadence so View never touches the // database. type detailDataMsg struct { - siteID int - changes []models.StateChange + siteID int + changes []models.StateChange + dailyDays []monitor.DayReport } // historyDataMsg carries the full state-change history for the history view. diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 31e002d..f18de95 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -199,6 +199,7 @@ type Model struct { detailOpen bool detailChanges []models.StateChange detailChangesSiteID int + detailDailyDays []monitor.DayReport filterMode bool filterText string diff --git a/internal/tui/update.go b/internal/tui/update.go index 2e40428..8f508a1 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -28,6 +28,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.detailChanges = msg.changes m.detailChangesSiteID = msg.siteID + m.detailDailyDays = msg.dailyDays return m, nil case historyDataMsg: if msg.siteID != m.historySiteID { @@ -142,7 +143,7 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -const detailInlineHeight = 11 +const detailInlineHeight = 12 func (m *Model) recalcLayout() { chrome := chromeBase diff --git a/internal/tui/uptime_timeline.go b/internal/tui/uptime_timeline.go new file mode 100644 index 0000000..c071c0c --- /dev/null +++ b/internal/tui/uptime_timeline.go @@ -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() +} diff --git a/internal/tui/view_detail_inline.go b/internal/tui/view_detail_inline.go index 4c39db6..9c80e8b 100644 --- a/internal/tui/view_detail_inline.go +++ b/internal/tui/view_detail_inline.go @@ -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") b.WriteString(" " + keys + "\n") -- 2.52.0 From 99121d07d81762baf29a3c9caf3a5fb796164cc2 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Wed, 24 Jun 2026 20:23:22 -0400 Subject: [PATCH 2/6] feat(tui): response time distribution histogram in full-screen detail Horizontal bar chart showing latency distribution across 5 buckets: 0-50ms, 50-100ms, 100-200ms, 200-500ms, 500ms+. Color-coded green (fast), yellow (medium), red (slow). Shows count per bucket. Appears in the full-screen detail panel (Enter) after the sparkline stats. Skipped for push monitors (no latency data). --- internal/tui/histogram.go | 89 +++++++++++++++++++++++++++++++++++++ internal/tui/view_detail.go | 9 ++++ 2 files changed, 98 insertions(+) create mode 100644 internal/tui/histogram.go diff --git a/internal/tui/histogram.go b/internal/tui/histogram.go new file mode 100644 index 0000000..3c547cf --- /dev/null +++ b/internal/tui/histogram.go @@ -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() +} diff --git a/internal/tui/view_detail.go b/internal/tui/view_detail.go index 8f3dc93..80d70c0 100644 --- a/internal/tui/view_detail.go +++ b/internal/tui/view_detail.go @@ -258,6 +258,15 @@ func (m Model) viewDetailPanel() string { b.WriteString("\n" + m.renderSparkTooltip(site, hist, detailSparkWidth)) } + if site.Type != "push" && len(hist.Latencies) > 5 { + histW := m.termWidth - chromePadH - 4 + if histW < 30 { + histW = 30 + } + b.WriteString("\n\n" + m.st.subtleStyle.Render(" RESPONSE TIME DISTRIBUTION") + "\n") + b.WriteString(m.latencyHistogram(hist.Latencies, hist.Statuses, histW)) + } + b.WriteString("\n") b.WriteString(m.divider() + "\n") b.WriteString(m.st.subtleStyle.Render(" [q/Esc] Back [e] Edit [h] History [s] SLA [click] Inspect")) -- 2.52.0 From fb50184f377fdecad4e67d37e244a7de6f255e5d Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Wed, 24 Jun 2026 20:48:15 -0400 Subject: [PATCH 3/6] feat(tui): two-column detail layout with viewport scrolling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactored full-screen detail panel: status+endpoint on left column, timing+config on right column. Halves vertical height of the info section. Content wraps in a viewport when it exceeds terminal height — scroll with j/k/arrows/pgup/pgdn. Restored renderSparkTooltip lost in rewrite. Fixed lint warnings. --- internal/tui/tui.go | 1 + internal/tui/update.go | 12 +++ internal/tui/view_detail.go | 200 ++++++++++++++++++++---------------- 3 files changed, 123 insertions(+), 90 deletions(-) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index f18de95..45c12d4 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -200,6 +200,7 @@ type Model struct { detailChanges []models.StateChange detailChangesSiteID int detailDailyDays []monitor.DayReport + detailViewport viewport.Model filterMode bool filterText string diff --git a/internal/tui/update.go b/internal/tui/update.go index 8f508a1..0b93c16 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -368,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) { 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": if m.sparkTooltipIdx >= 0 { m.sparkTooltipIdx = -1 diff --git a/internal/tui/view_detail.go b/internal/tui/view_detail.go index 80d70c0..a023250 100644 --- a/internal/tui/view_detail.go +++ b/internal/tui/view_detail.go @@ -20,6 +20,7 @@ func (m Model) viewDetailPanel() string { hist, _ := m.engine.GetHistory(site.ID) var b strings.Builder + totalW := m.termWidth - chromePadH var breadcrumb string if site.ParentID > 0 { @@ -36,29 +37,93 @@ func (m Model) viewDetailPanel() string { b.WriteString(breadcrumb + "\n") b.WriteString(m.divider() + "\n") - row := func(label, value string) { - fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render(label), value) + // Two-column layout for key info + colW := (totalW - 4) / 2 + if colW < 30 { + colW = 30 } - section := func(label string) { - b.WriteString("\n" + m.st.subtleStyle.Render(" "+label) + "\n") + row := func(label, value string) string { + return fmt.Sprintf(" %-16s %s", m.st.subtleStyle.Render(label), value) } - row("Status", m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))) + // Left column: status + endpoint + var left []string + left = append(left, row("Status", m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)))) if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp || site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" { - errWidth := m.termWidth - chromePadH - 19 - if errWidth < 30 { - errWidth = 30 + errW := colW - 19 + if errW < 20 { + errW = 20 } - wrapped := lipgloss.NewStyle().Width(errWidth).Render(site.LastError) - row("Error", m.st.dangerStyle.Render(wrapped)) + errMsg := limitStr(site.LastError, errW) + left = append(left, row("Error", m.st.dangerStyle.Render(errMsg))) } if site.Type == "http" && site.StatusCode > 0 { - row("HTTP Code", strconv.Itoa(site.StatusCode)) + left = append(left, row("HTTP Code", strconv.Itoa(site.StatusCode))) + } + if !site.StatusChangedAt.IsZero() { + dur := time.Since(site.StatusChangedAt) + left = append(left, row("State Since", fmtDuration(dur)+" ago")) } + left = append(left, "") + left = append(left, m.st.subtleStyle.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))) + } + + // Right column: timing + config + var right []string + right = append(right, row("Latency", m.fmtLatency(site.Latency))) + right = append(right, row("Uptime", m.fmtUptime(hist.Statuses))) + right = append(right, row("Interval", fmt.Sprintf("%ds", site.Interval))) + 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.Type == "http" { + right = append(right, "") + right = append(right, m.st.subtleStyle.Render(" HTTP")) + 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)) + } + } + + if site.MaxRetries > 0 { + right = append(right, row("Retries", m.fmtRetries(site))) + } + + // 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") + + // Connection chain (full width, only on errors) if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp) && site.LastError != "" { chain := connectionChain(site.LastError, site.Type, site.StatusCode, strings.HasPrefix(site.URL, "https")) if len(chain) > 0 { @@ -87,81 +152,22 @@ func (m Model) viewDetailPanel() string { } } - 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)") - } - + // Maintenance 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", m.st.maintStyle.Render(mw.Title)) + fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Maintenance"), m.st.maintStyle.Render(mw.Title)) break } } } - section("ENDPOINT") - row("Type", site.Type) + // Push token if site.Type == "push" && site.Token != "" { - row("Token", site.Token) - row("Push", "curl -X POST -H 'Authorization: Bearer "+site.Token+"' /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) - } + fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Token"), site.Token) } + // Probe results probeResults := m.engine.GetProbeResults(site.ID) if len(probeResults) > 0 { nodeIDs := make([]string, 0, len(probeResults)) @@ -186,7 +192,7 @@ func (m Model) viewDetailPanel() string { } } - // Loaded on panel-enter (loadDetailCmd) and cached, so View does no DB IO. + // State changes var stateChanges []models.StateChange if m.detailChangesSiteID == site.ID { stateChanges = m.detailChanges @@ -194,26 +200,25 @@ func (m Model) viewDetailPanel() string { if len(stateChanges) > 0 { b.WriteString("\n" + m.st.subtleStyle.Render(" STATE CHANGES") + "\n") for i, sc := range stateChanges { + from := m.fmtStatusWord(string(sc.FromStatus)) + to := m.fmtStatusWord(string(sc.ToStatus)) ago := fmtDuration(time.Since(sc.ChangedAt)) - arrow := m.st.subtleStyle.Render(sc.FromStatus) + " → " - if sc.ToStatus == string(models.StatusUp) { - arrow += m.st.specialStyle.Render(sc.ToStatus) - } else { - arrow += m.st.dangerStyle.Render(sc.ToStatus) + 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)) + } } - 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) { + if sc.ErrorReason != "" { 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") + // Sparkline + stats + b.WriteString("\n") if site.Type == "push" { b.WriteString(" " + m.zones.Mark("spark-heartbeat", m.heartbeatSparkline(hist.Statuses, detailSparkWidth, nil))) if len(hist.Statuses) > 0 { @@ -258,8 +263,9 @@ func (m Model) viewDetailPanel() string { b.WriteString("\n" + m.renderSparkTooltip(site, hist, detailSparkWidth)) } + // Histogram if site.Type != "push" && len(hist.Latencies) > 5 { - histW := m.termWidth - chromePadH - 4 + histW := totalW - 4 if histW < 30 { histW = 30 } @@ -271,7 +277,21 @@ func (m Model) viewDetailPanel() string { b.WriteString(m.divider() + "\n") 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 + if contentH < 10 { + contentH = 10 + } + lines := strings.Split(content, "\n") + if len(lines) > contentH { + m.detailViewport.SetContent(content) + m.detailViewport.Width = totalW + m.detailViewport.Height = contentH + return lipgloss.NewStyle().Padding(1, 2).Render(m.detailViewport.View()) + } + + return lipgloss.NewStyle().Padding(1, 2).Render(content) } func (m Model) renderSparkTooltip(site models.Site, hist monitor.SiteHistory, sparkWidth int) string { -- 2.52.0 From 1268d99f5c8e35e84b595cd1c15884ff3ee22941 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Wed, 24 Jun 2026 20:56:21 -0400 Subject: [PATCH 4/6] feat(tui): styled section headers with trailing rules in detail panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section labels (STATE CHANGES, LATENCY, RESPONSE TIME DISTRIBUTION, PROBE RESULTS) now render in bold accent color with a trailing ─── rule that fills the width. Two-column info block separated by a full divider below. Creates clear visual boundaries between sections. --- internal/tui/view_detail.go | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/internal/tui/view_detail.go b/internal/tui/view_detail.go index a023250..09723c1 100644 --- a/internal/tui/view_detail.go +++ b/internal/tui/view_detail.go @@ -47,6 +47,15 @@ func (m Model) viewDetailPanel() string { return fmt.Sprintf(" %-16s %s", m.st.subtleStyle.Render(label), value) } + 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: status + endpoint var left []string left = append(left, row("Status", m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)))) @@ -69,7 +78,7 @@ func (m Model) viewDetailPanel() string { } left = append(left, "") - left = append(left, m.st.subtleStyle.Render(" ENDPOINT")) + 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))) @@ -95,7 +104,7 @@ func (m Model) viewDetailPanel() string { if site.Type == "http" { right = append(right, "") - right = append(right, m.st.subtleStyle.Render(" HTTP")) + right = append(right, m.st.titleStyle.Render(" HTTP")) codes := site.AcceptedCodes if codes == "" { codes = "200-299" @@ -122,6 +131,7 @@ func (m Model) viewDetailPanel() string { 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(sectionDiv + "\n") // Connection chain (full width, only on errors) if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp) && site.LastError != "" { @@ -175,7 +185,7 @@ func (m Model) viewDetailPanel() string { nodeIDs = append(nodeIDs, id) } sort.Strings(nodeIDs) - b.WriteString("\n" + m.st.subtleStyle.Render(" PROBE RESULTS") + "\n") + b.WriteString("\n" + sectionHead("PROBE RESULTS") + "\n") for _, nodeID := range nodeIDs { result := probeResults[nodeID] status := m.st.specialStyle.Render("UP") @@ -198,7 +208,7 @@ func (m Model) viewDetailPanel() string { stateChanges = m.detailChanges } if len(stateChanges) > 0 { - b.WriteString("\n" + m.st.subtleStyle.Render(" STATE CHANGES") + "\n") + b.WriteString("\n" + sectionHead("STATE CHANGES") + "\n") for i, sc := range stateChanges { from := m.fmtStatusWord(string(sc.FromStatus)) to := m.fmtStatusWord(string(sc.ToStatus)) @@ -218,7 +228,7 @@ func (m Model) viewDetailPanel() string { } // Sparkline + stats - b.WriteString("\n") + b.WriteString("\n" + sectionHead("LATENCY") + "\n") if site.Type == "push" { b.WriteString(" " + m.zones.Mark("spark-heartbeat", m.heartbeatSparkline(hist.Statuses, detailSparkWidth, nil))) if len(hist.Statuses) > 0 { @@ -269,7 +279,7 @@ func (m Model) viewDetailPanel() string { if histW < 30 { histW = 30 } - b.WriteString("\n\n" + m.st.subtleStyle.Render(" RESPONSE TIME DISTRIBUTION") + "\n") + b.WriteString("\n" + sectionHead("RESPONSE TIME DISTRIBUTION") + "\n") b.WriteString(m.latencyHistogram(hist.Latencies, hist.Statuses, histW)) } -- 2.52.0 From 11b647b9525cb4b8f62289ce90f1f88b11be0ed0 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Wed, 24 Jun 2026 20:58:44 -0400 Subject: [PATCH 5/6] fix(tui): add spacing between latency graph and histogram --- internal/tui/view_detail.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/tui/view_detail.go b/internal/tui/view_detail.go index 09723c1..459351a 100644 --- a/internal/tui/view_detail.go +++ b/internal/tui/view_detail.go @@ -274,6 +274,7 @@ func (m Model) viewDetailPanel() string { } // Histogram + b.WriteString("\n") if site.Type != "push" && len(hist.Latencies) > 5 { histW := totalW - 4 if histW < 30 { -- 2.52.0 From 11344b4ae6e79b67606abe77e4228c25eed11cb3 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Wed, 24 Jun 2026 21:11:04 -0400 Subject: [PATCH 6/6] feat(tui): two-column bottom layout, endpoint/status split, clean labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bottom section: graphs (latency + histogram) 70% left, state changes 30% right. Top section rearranged: endpoint details left, status + timing + HTTP right. Bottom section labels use bold accent without trailing rules. Spacing between top and bottom halves. Removed unused renderSparkTooltip (zone marks not wired in column layout — can restore later). --- internal/tui/view_detail.go | 216 +++++++++++++++++------------------- 1 file changed, 102 insertions(+), 114 deletions(-) diff --git a/internal/tui/view_detail.go b/internal/tui/view_detail.go index 459351a..c20ae58 100644 --- a/internal/tui/view_detail.go +++ b/internal/tui/view_detail.go @@ -8,7 +8,6 @@ import ( "time" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" - "gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" "github.com/charmbracelet/lipgloss" ) @@ -56,28 +55,8 @@ func (m Model) viewDetailPanel() string { return m.st.titleStyle.Render(" "+title) + " " + m.st.subtleStyle.Render(strings.Repeat("─", divW-len(title)-3)) } - // Left column: status + endpoint + // Left column: endpoint details var left []string - left = append(left, row("Status", m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)))) - - if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp || site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" { - errW := colW - 19 - if errW < 20 { - errW = 20 - } - errMsg := limitStr(site.LastError, errW) - left = append(left, row("Error", m.st.dangerStyle.Render(errMsg))) - } - - if site.Type == "http" && site.StatusCode > 0 { - left = append(left, row("HTTP Code", strconv.Itoa(site.StatusCode))) - } - if !site.StatusChangedAt.IsZero() { - dur := time.Since(site.StatusChangedAt) - left = append(left, row("State Since", fmtDuration(dur)+" ago")) - } - - left = append(left, "") left = append(left, m.st.titleStyle.Render(" ENDPOINT")) left = append(left, row("Type", site.Type)) if site.URL != "" { @@ -89,12 +68,27 @@ func (m Model) viewDetailPanel() string { 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: timing + config + // 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))) - right = append(right, row("Interval", fmt.Sprintf("%ds", site.Interval))) + 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))) } @@ -102,9 +96,18 @@ func (m Model) viewDetailPanel() string { 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 != "" { + errW := colW - 19 + if errW < 20 { + errW = 20 + } + right = append(right, row("Error", m.st.dangerStyle.Render(limitStr(site.LastError, errW)))) + } + if site.Type == "http" { - right = append(right, "") - right = append(right, m.st.titleStyle.Render(" HTTP")) + if site.StatusCode > 0 { + right = append(right, row("HTTP Code", strconv.Itoa(site.StatusCode))) + } codes := site.AcceptedCodes if codes == "" { codes = "200-299" @@ -116,10 +119,6 @@ func (m Model) viewDetailPanel() string { } } - if site.MaxRetries > 0 { - right = append(right, row("Retries", m.fmtRetries(site))) - } - // Pad shorter column for len(left) < len(right) { left = append(left, "") @@ -131,7 +130,7 @@ func (m Model) viewDetailPanel() string { 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(sectionDiv + "\n") + b.WriteString("\n" + sectionDiv + "\n") // Connection chain (full width, only on errors) if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp) && site.LastError != "" { @@ -202,35 +201,30 @@ func (m Model) viewDetailPanel() string { } } - // State changes - var stateChanges []models.StateChange - if m.detailChangesSiteID == site.ID { - stateChanges = m.detailChanges + // Bottom two-column: graphs left, state changes right + graphW := (totalW - 4) * 70 / 100 + changeW := totalW - 4 - graphW + if graphW < 30 { + graphW = 30 } - if len(stateChanges) > 0 { - b.WriteString("\n" + sectionHead("STATE CHANGES") + "\n") - 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(sc.ErrorReason) - } - b.WriteString(line + "\n") - } + if changeW < 20 { + changeW = 20 + } + bottomColW := graphW + + // Left: latency + histogram + var graphLines []string + sectionLabel := func(title string) string { + return m.st.titleStyle.Render(" " + title) } - // Sparkline + stats - b.WriteString("\n" + sectionHead("LATENCY") + "\n") + graphLines = append(graphLines, sectionLabel("LATENCY")) 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 { up := 0 for _, s := range hist.Statuses { @@ -238,12 +232,15 @@ func (m Model) viewDetailPanel() string { up++ } } - fmt.Fprintf(&b, "\n %s %d/%d checks up", - m.st.subtleStyle.Render("Heartbeats"), - up, len(hist.Statuses)) + graphLines = append(graphLines, fmt.Sprintf(" %s %d/%d checks up", + m.st.subtleStyle.Render("Heartbeats"), up, len(hist.Statuses))) } } 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 count := 0 for i, l := range hist.Latencies { @@ -262,28 +259,59 @@ func (m Model) viewDetailPanel() string { } if count > 0 { 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("Avg"), avg.Milliseconds(), - m.st.subtleStyle.Render("Max"), maxL.Milliseconds()) + m.st.subtleStyle.Render("Max"), maxL.Milliseconds())) } } - if m.sparkTooltipIdx >= 0 { - b.WriteString("\n" + m.renderSparkTooltip(site, hist, detailSparkWidth)) - } - - // Histogram - b.WriteString("\n") if site.Type != "push" && len(hist.Latencies) > 5 { - histW := totalW - 4 - if histW < 30 { - histW = 30 - } - b.WriteString("\n" + sectionHead("RESPONSE TIME DISTRIBUTION") + "\n") - b.WriteString(m.latencyHistogram(hist.Latencies, hist.Statuses, histW)) + 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(m.divider() + "\n") b.WriteString(m.st.subtleStyle.Render(" [q/Esc] Back [e] Edit [h] History [s] SLA [click] Inspect")) @@ -304,43 +332,3 @@ func (m Model) viewDetailPanel() string { return lipgloss.NewStyle().Padding(1, 2).Render(content) } - -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, 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 -} -- 2.52.0