From f97ea3d66b2f0b77d54f8c259f87abf1421e884e Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Wed, 10 Jun 2026 11:28:29 -0400 Subject: [PATCH] feat(tui): click-to-inspect sparkline tooltips in detail view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Click any sparkline character to see data point details — approximate time, latency, and up/down status. Esc dismisses tooltip without leaving detail view. Uses existing BubbleZone infrastructure with zone-relative coordinate math for index resolution. --- internal/tui/sparkline.go | 16 +++++++++++ internal/tui/sparkline_test.go | 31 ++++++++++++++++++++ internal/tui/tui.go | 29 ++++++++++--------- internal/tui/update.go | 40 +++++++++++++++++++++++++- internal/tui/view_dashboard.go | 2 +- internal/tui/view_detail.go | 52 ++++++++++++++++++++++++++++++++-- 6 files changed, 152 insertions(+), 18 deletions(-) diff --git a/internal/tui/sparkline.go b/internal/tui/sparkline.go index 38216b9..6c74512 100644 --- a/internal/tui/sparkline.go +++ b/internal/tui/sparkline.go @@ -127,6 +127,22 @@ func heartbeatSparkline(statuses []bool, width int, bg lipgloss.Color) string { return sb.String() } +func resolveSparklineIndex(x, sparkWidth, dataLen int) int { + visible := dataLen + if visible > sparkWidth { + visible = sparkWidth + } + padding := sparkWidth - visible + if x < padding { + return -1 + } + offset := 0 + if dataLen > sparkWidth { + offset = dataLen - sparkWidth + } + return offset + (x - padding) +} + func (m Model) groupSparkline(groupID int, width int, bg lipgloss.Color) string { allSites := m.engine.GetAllSites() var childStatuses [][]bool diff --git a/internal/tui/sparkline_test.go b/internal/tui/sparkline_test.go index 0861aab..ef80a66 100644 --- a/internal/tui/sparkline_test.go +++ b/internal/tui/sparkline_test.go @@ -139,3 +139,34 @@ func TestHeartbeatSparkline_PaddedWidth(t *testing.T) { t.Errorf("should have dot padding for width > data, got %q", got) } } + +func TestResolveSparklineIndex(t *testing.T) { + tests := []struct { + name string + x int + sparkWidth int + dataLen int + want int + }{ + {"exact fit first", 0, 5, 5, 0}, + {"exact fit last", 4, 5, 5, 4}, + {"padding returns -1", 0, 10, 5, -1}, + {"padding boundary", 4, 10, 5, -1}, + {"first data after padding", 5, 10, 5, 0}, + {"last data after padding", 9, 10, 5, 4}, + {"truncated first visible", 0, 5, 20, 15}, + {"truncated last visible", 4, 5, 20, 19}, + {"single data point", 9, 10, 1, 0}, + {"single data point on padding", 0, 10, 1, -1}, + {"zero data", 0, 10, 0, -1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveSparklineIndex(tt.x, tt.sparkWidth, tt.dataLen) + if got != tt.want { + t.Errorf("resolveSparklineIndex(%d, %d, %d) = %d, want %d", + tt.x, tt.sparkWidth, tt.dataLen, got, tt.want) + } + }) + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index c964d27..7aeee69 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -144,6 +144,8 @@ type Model struct { filterMode bool filterText string + sparkTooltipIdx int // clicked sparkline data index, -1 = none + // demoMode renders a stable status dot instead of the animated pulse so // screenshots/recordings don't capture the spinner mid-frame. Set via UPTOP_DEMO=1. demoMode bool @@ -170,19 +172,20 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri applyTheme(theme) return Model{ - state: stateDashboard, - logViewport: vpLogs, - maxTableRows: 5, - isAdmin: isAdmin, - store: s, - engine: eng, - zones: z, - pulseSpring: spring, - collapsed: collapsed, - theme: theme, - themeIndex: themeIdx, - demoMode: os.Getenv("UPTOP_DEMO") == "1", - version: version, + state: stateDashboard, + logViewport: vpLogs, + maxTableRows: 5, + isAdmin: isAdmin, + store: s, + engine: eng, + zones: z, + pulseSpring: spring, + collapsed: collapsed, + theme: theme, + themeIndex: themeIdx, + demoMode: os.Getenv("UPTOP_DEMO") == "1", + version: version, + sparkTooltipIdx: -1, } } diff --git a/internal/tui/update.go b/internal/tui/update.go index f986c54..207ea59 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -162,6 +162,12 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } return m, nil } + if m.state == stateDetail { + if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { + return m.handleSparklineClick(msg) + } + return m, nil + } if m.state != stateDashboard && m.state != stateLogs && m.state != stateUsers { return m, nil } @@ -259,7 +265,15 @@ 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 "i", "esc": + case "esc": + if m.sparkTooltipIdx >= 0 { + m.sparkTooltipIdx = -1 + return m, nil + } + m.sparkTooltipIdx = -1 + m.state = stateDashboard + case "i": + m.sparkTooltipIdx = -1 m.state = stateDashboard case "e": return m.handleEditItem() @@ -286,6 +300,30 @@ func (m *Model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +func (m *Model) handleSparklineClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if m.cursor >= len(m.sites) { + return m, nil + } + site := m.sites[m.cursor] + hist, _ := m.engine.GetHistory(site.ID) + + const sparkWidth = 40 + + if zi := m.zones.Get("spark-latency"); zi != nil && !zi.IsZero() && zi.InBounds(msg) { + x, _ := zi.Pos(msg) + m.sparkTooltipIdx = resolveSparklineIndex(x, sparkWidth, len(hist.Latencies)) + return m, nil + } + if zi := m.zones.Get("spark-heartbeat"); zi != nil && !zi.IsZero() && zi.InBounds(msg) { + x, _ := zi.Pos(msg) + m.sparkTooltipIdx = resolveSparklineIndex(x, sparkWidth, len(hist.Statuses)) + return m, nil + } + + m.sparkTooltipIdx = -1 + return m, nil +} + func (m *Model) handleSLAKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "q", "esc": diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index b049faf..8ab4276 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -95,7 +95,7 @@ func (m Model) View() string { } return "" case stateDetail: - return m.viewDetailPanel() + return m.zones.Scan(m.viewDetailPanel()) case stateHistory: return m.viewHistoryPanel() case stateSLA: diff --git a/internal/tui/view_detail.go b/internal/tui/view_detail.go index 2ab41f9..1bb284d 100644 --- a/internal/tui/view_detail.go +++ b/internal/tui/view_detail.go @@ -6,6 +6,8 @@ import ( "strings" "time" + "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" + "gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" "github.com/charmbracelet/lipgloss" ) @@ -203,7 +205,7 @@ func (m Model) viewDetailPanel() string { b.WriteString(m.divider() + "\n") const sparkWidth = 40 if site.Type == "push" { - b.WriteString(" " + heartbeatSparkline(hist.Statuses, sparkWidth, "")) + b.WriteString(" " + m.zones.Mark("spark-heartbeat", heartbeatSparkline(hist.Statuses, sparkWidth, ""))) if len(hist.Statuses) > 0 { up := 0 for _, s := range hist.Statuses { @@ -216,7 +218,7 @@ func (m Model) viewDetailPanel() string { up, len(hist.Statuses)) } } else { - b.WriteString(" " + latencySparkline(hist.Latencies, hist.Statuses, sparkWidth, "")) + 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 { @@ -242,9 +244,53 @@ func (m Model) viewDetailPanel() string { } } + 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 [q] Quit")) + 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 +}