From a59edf84102e918739293820ae4b4883b5036dfd Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Wed, 24 Jun 2026 20:16:01 -0400 Subject: [PATCH] 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")