feat(tui): click-to-inspect sparkline tooltips in detail view
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user