feat(tui): click-to-inspect sparkline tooltips in detail view
CI / test (pull_request) Successful in 2m47s
CI / lint (pull_request) Successful in 56s
CI / vulncheck (pull_request) Successful in 46s

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.
This commit was merged in pull request #97.
This commit is contained in:
2026-06-10 11:28:29 -04:00
parent 21a1563e53
commit f97ea3d66b
6 changed files with 152 additions and 18 deletions
+16
View File
@@ -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
+31
View File
@@ -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)
}
})
}
}
+3
View File
@@ -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
@@ -183,6 +185,7 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri
themeIndex: themeIdx,
demoMode: os.Getenv("UPTOP_DEMO") == "1",
version: version,
sparkTooltipIdx: -1,
}
}
+39 -1
View File
@@ -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":
+1 -1
View File
@@ -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:
+49 -3
View File
@@ -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
}