feat(tui): 30-day uptime timeline in inline detail panel

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.
This commit is contained in:
2026-06-24 20:16:01 -04:00
parent 6799163cd4
commit a59edf8410
6 changed files with 78 additions and 4 deletions
+13 -1
View File
@@ -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}
}
}
+2
View File
@@ -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,
@@ -36,6 +37,7 @@ type tabDataMsg struct {
type detailDataMsg struct {
siteID int
changes []models.StateChange
dailyDays []monitor.DayReport
}
// historyDataMsg carries the full state-change history for the history view.
+1
View File
@@ -199,6 +199,7 @@ type Model struct {
detailOpen bool
detailChanges []models.StateChange
detailChangesSiteID int
detailDailyDays []monitor.DayReport
filterMode bool
filterText string
+2 -1
View File
@@ -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
+50
View File
@@ -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()
}
+8
View File
@@ -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")