refactor(tui): remove full-page detail, SLA, and history views
CI / test (pull_request) Successful in 1m49s
CI / lint (pull_request) Successful in 1m16s
CI / vulncheck (pull_request) Successful in 56s

All monitor detail content now lives in the sidebar panel.
Delete the full-page viewDetailPanel, viewSLAPanel, viewHistoryPanel,
handleDetailKey, handleSLAKey, handleHistoryKey, openSLAView, and
related dead code (histogram, sparkline tooltip, detailViewport).

Remove stateDetail, stateSLA, stateHistory session states.
Update tests to use detailOpen/detailMode instead of removed states.
This commit was merged in pull request #163.
This commit is contained in:
2026-06-30 09:28:12 -04:00
parent 3e02833df4
commit dc79e2baaa
8 changed files with 41 additions and 848 deletions
-89
View File
@@ -1,89 +0,0 @@
package tui
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
)
type latencyBucket struct {
label string
min int64
max int64
count int
}
func (m Model) latencyHistogram(latencies []time.Duration, statuses []bool, width int) string {
if len(latencies) == 0 || width < 30 {
return ""
}
buckets := []latencyBucket{
{"0-50ms", 0, 50, 0},
{"50-100ms", 50, 100, 0},
{"100-200ms", 100, 200, 0},
{"200-500ms", 200, 500, 0},
{"500ms+", 500, 999999, 0},
}
for i, l := range latencies {
if i < len(statuses) && !statuses[i] {
continue
}
ms := l.Milliseconds()
for j := range buckets {
if ms >= buckets[j].min && ms < buckets[j].max {
buckets[j].count++
break
}
}
}
maxCount := 0
for _, b := range buckets {
if b.count > maxCount {
maxCount = b.count
}
}
if maxCount == 0 {
return ""
}
labelW := 10
countW := len(fmt.Sprintf("%d", maxCount))
barW := width - labelW - countW - 4
if barW < 5 {
barW = 5
}
var sb strings.Builder
for _, b := range buckets {
fill := 0
if maxCount > 0 {
fill = b.count * barW / maxCount
}
var barColor lipgloss.Style
switch {
case b.min < 100:
barColor = m.st.specialStyle
case b.min < 500:
barColor = m.st.warnStyle
default:
barColor = m.st.dangerStyle
}
bar := barColor.Render(strings.Repeat("█", fill))
if fill < barW {
bar += m.st.subtleStyle.Render(strings.Repeat("░", barW-fill))
}
label := fmt.Sprintf("%*s", labelW, b.label)
count := fmt.Sprintf("%*d", countW, b.count)
sb.WriteString(" " + m.st.subtleStyle.Render(label) + " " + bar + " " + count + "\n")
}
return sb.String()
}
+27 -38
View File
@@ -80,8 +80,6 @@ const (
chromeFooter = 2 // footer: "\n" prefix + text line chromeFooter = 2 // footer: "\n" prefix + text line
chromeTable = 3 // renderTable "\n" prefix + top border + header + bottom border (lipgloss collapses two into three rendered lines) chromeTable = 3 // renderTable "\n" prefix + top border + header + bottom border (lipgloss collapses two into three rendered lines)
chromeBase = chromePadV + chromeHeader + chromeGaps + chromeFooter + chromeTable chromeBase = chromePadV + chromeHeader + chromeGaps + chromeFooter + chromeTable
detailSparkWidth = 40
) )
const ( const (
@@ -113,19 +111,16 @@ const (
type sessionState int type sessionState int
const ( const (
stateDashboard sessionState = iota stateDashboard sessionState = 0
stateLogs stateLogs sessionState = 1
stateDetail stateAlertDetail sessionState = 3
stateAlertDetail stateFormSite sessionState = 4
stateFormSite stateFormAlert sessionState = 5
stateFormAlert stateFormUser sessionState = 6
stateFormUser stateConfirmDelete sessionState = 7
stateConfirmDelete stateFormMaint sessionState = 8
stateFormMaint stateSettings sessionState = 11
stateHistory stateMaintDetail sessionState = 12
stateSLA
stateSettings
stateMaintDetail
) )
type Model struct { type Model struct {
@@ -161,12 +156,10 @@ type Model struct {
logTotal int logTotal int
logShown int logShown int
historyViewport viewport.Model
historyChanges []models.StateChange historyChanges []models.StateChange
historySiteName string historySiteName string
historySiteID int historySiteID int
slaViewport viewport.Model
slaReport monitor.SLAReport slaReport monitor.SLAReport
slaDailyBreakdown []monitor.DayReport slaDailyBreakdown []monitor.DayReport
slaSiteName string slaSiteName string
@@ -212,13 +205,10 @@ type Model struct {
detailChanges []models.StateChange detailChanges []models.StateChange
detailChangesSiteID int detailChangesSiteID int
detailDailyDays []monitor.DayReport detailDailyDays []monitor.DayReport
detailViewport viewport.Model
filterMode bool filterMode bool
filterText string filterText string
sparkTooltipIdx int // clicked sparkline data index, -1 = none
// demoMode renders a stable status dot instead of the animated pulse so // 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. // screenshots/recordings don't capture the spinner mid-frame. Set via UPTOP_DEMO=1.
demoMode bool demoMode bool
@@ -245,24 +235,23 @@ func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor
detailPref, _ := s.GetPreference(ctx, "detail_open") detailPref, _ := s.GetPreference(ctx, "detail_open")
return Model{ return Model{
ctx: ctx, ctx: ctx,
state: stateDashboard, state: stateDashboard,
logViewport: vpLogs, logViewport: vpLogs,
maxTableRows: 5, maxTableRows: 5,
isAdmin: isAdmin, isAdmin: isAdmin,
store: s, store: s,
engine: eng, engine: eng,
zones: z, zones: z,
pulseSpring: spring, pulseSpring: spring,
collapsed: collapsed, collapsed: collapsed,
theme: theme, theme: theme,
themeIndex: themeIdx, themeIndex: themeIdx,
st: newStyles(theme), st: newStyles(theme),
logsOpen: true, logsOpen: true,
detailOpen: detailPref == "true", detailOpen: detailPref == "true",
demoMode: os.Getenv("UPTOP_DEMO") == "1", demoMode: os.Getenv("UPTOP_DEMO") == "1",
version: version, version: version,
sparkTooltipIdx: -1,
} }
} }
+3 -180
View File
@@ -6,7 +6,6 @@ import (
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" "gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
) )
@@ -20,9 +19,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tabDataMsg: case tabDataMsg:
return m.handleTabData(msg) return m.handleTabData(msg)
case detailDataMsg: case detailDataMsg:
// Drop replies for a site the user has already navigated away from, if m.detailOpen && m.cursor < len(m.sites) && m.sites[m.cursor].ID != msg.siteID {
// so a slow load can't clobber the panel currently on screen.
if m.state == stateDetail && m.cursor < len(m.sites) && m.sites[m.cursor].ID != msg.siteID {
return m, nil return m, nil
} }
m.detailChanges = msg.changes m.detailChanges = msg.changes
@@ -31,11 +28,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case historyDataMsg: case historyDataMsg:
if msg.siteID != m.historySiteID { if msg.siteID != m.historySiteID {
return m, nil // stale reply for a previously opened history return m, nil
} }
m.historyChanges = msg.changes m.historyChanges = msg.changes
m.historyViewport.SetContent(m.buildHistoryContent())
m.historyViewport.GotoTop()
return m, nil return m, nil
case slaDataMsg: case slaDataMsg:
return m.handleSLAData(msg) return m.handleSLAData(msg)
@@ -177,10 +172,6 @@ func (m *Model) handleResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) {
m.recalcLayout() m.recalcLayout()
m.logViewport.Width = msg.Width - chromePadH m.logViewport.Width = msg.Width - chromePadH
m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeFooter + 2) m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeFooter + 2)
m.historyViewport.Width = msg.Width - chromePadH
m.historyViewport.Height = msg.Height - 10
m.slaViewport.Width = msg.Width - chromePadH
m.slaViewport.Height = msg.Height - 16
if m.huhForm != nil { if m.huhForm != nil {
formHeight := msg.Height - 7 formHeight := msg.Height - 7
if formHeight < 5 { if formHeight < 5 {
@@ -212,7 +203,7 @@ func (m *Model) handleTick(t time.Time) (tea.Model, tea.Cmd) {
// tab-data cadence, so a flap that happens while the panel is on screen shows // tab-data cadence, so a flap that happens while the panel is on screen shows
// up without leaving and re-entering. Nil when no detail panel is open. // up without leaving and re-entering. Nil when no detail panel is open.
func (m *Model) detailRefreshCmd() tea.Cmd { func (m *Model) detailRefreshCmd() tea.Cmd {
if m.state != stateDetail || m.cursor >= len(m.sites) { if !m.detailOpen || m.cursor >= len(m.sites) {
return nil return nil
} }
return m.loadDetailCmd(m.sites[m.cursor].ID) return m.loadDetailCmd(m.sites[m.cursor].ID)
@@ -288,30 +279,6 @@ func (m *Model) handleLogsFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.state == stateHistory {
switch msg.Button {
case tea.MouseButtonWheelUp:
m.historyViewport.ScrollUp(3)
case tea.MouseButtonWheelDown:
m.historyViewport.ScrollDown(3)
}
return m, nil
}
if m.state == stateSLA {
switch msg.Button {
case tea.MouseButtonWheelUp:
m.slaViewport.ScrollUp(3)
case tea.MouseButtonWheelDown:
m.slaViewport.ScrollDown(3)
}
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 { if m.state != stateDashboard {
return m, nil return m, nil
} }
@@ -367,12 +334,6 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
switch m.state { switch m.state {
case stateDetail:
return m.handleDetailKey(msg)
case stateHistory:
return m.handleHistoryKey(msg)
case stateSLA:
return m.handleSLAKey(msg)
case stateAlertDetail: case stateAlertDetail:
return m.handleAlertDetailKey(msg) return m.handleAlertDetailKey(msg)
case stateSettings: case stateSettings:
@@ -417,115 +378,6 @@ func (m *Model) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
func (m *Model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "up", "k":
m.detailViewport.ScrollUp(1)
return m, nil
case "down", "j":
m.detailViewport.ScrollDown(1)
return m, nil
case "pgup":
m.detailViewport.ScrollUp(m.detailViewport.Height / 2)
return m, nil
case "pgdown":
m.detailViewport.ScrollDown(m.detailViewport.Height / 2)
return m, nil
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()
case "h":
if m.cursor < len(m.sites) {
site := m.sites[m.cursor]
m.historySiteName = site.Name
m.historySiteID = site.ID
m.historyChanges = nil
m.historyViewport = viewport.New(
m.termWidth-chromePadH,
m.termHeight-10,
)
m.historyViewport.SetContent("\n Loading state history...")
m.state = stateHistory
return m, m.loadHistoryCmd(site.ID)
}
case "s":
if m.cursor < len(m.sites) {
return m, m.openSLAView(m.sites[m.cursor])
}
case "q":
m.state = stateDashboard
}
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)
if zi := m.zones.Get("spark-latency"); zi != nil && !zi.IsZero() && zi.InBounds(msg) {
x, _ := zi.Pos(msg)
m.sparkTooltipIdx = resolveSparklineIndex(x, detailSparkWidth, 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, detailSparkWidth, 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":
m.state = stateDetail
case "1", "2", "3", "4":
idx := int(msg.String()[0]-'0') - 1
if idx >= 0 && idx < len(slaPeriods) {
m.slaPeriodIdx = idx
return m, m.loadSLACmd(m.slaSiteID, idx)
}
case "up", "k":
m.slaViewport.ScrollUp(1)
case "down", "j":
m.slaViewport.ScrollDown(1)
case "pgup":
m.slaViewport.HalfPageUp()
case "pgdown":
m.slaViewport.HalfPageDown()
case "ctrl+c":
return m, tea.Quit
}
return m, nil
}
func (m *Model) openSLAView(site models.Site) tea.Cmd {
m.slaSiteName = site.Name
m.slaSiteID = site.ID
m.slaPeriodIdx = 2 // default 30d
m.slaViewport = viewport.New(
m.termWidth-chromePadH,
m.termHeight-16,
)
m.slaViewport.SetContent("\n Loading SLA report...")
m.state = stateSLA
return m.loadSLACmd(site.ID, m.slaPeriodIdx)
}
// handleSLAData folds an async SLA load into the model. The SLA math itself is // handleSLAData folds an async SLA load into the model. The SLA math itself is
// pure CPU and cheap, so it runs here; only the state-change read happens in // pure CPU and cheap, so it runs here; only the state-change read happens in
// the Cmd. Replies for a different site or period than currently selected are // the Cmd. Replies for a different site or period than currently selected are
@@ -546,35 +398,6 @@ func (m *Model) handleSLAData(msg slaDataMsg) (tea.Model, tea.Cmd) {
m.slaReport = monitor.ComputeSLA(msg.changes, currentStatus, period.duration) m.slaReport = monitor.ComputeSLA(msg.changes, currentStatus, period.duration)
m.slaDailyBreakdown = monitor.ComputeDailyBreakdown(msg.changes, currentStatus, period.days, time.Now()) m.slaDailyBreakdown = monitor.ComputeDailyBreakdown(msg.changes, currentStatus, period.days, time.Now())
m.slaViewport = viewport.New(
m.termWidth-chromePadH,
m.termHeight-16,
)
m.slaViewport.SetContent(m.buildSLADailyContent())
m.slaViewport.GotoTop()
return m, nil
}
func (m *Model) handleHistoryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q", "esc":
m.state = stateDetail
case "up", "k":
m.historyViewport.ScrollUp(1)
case "down", "j":
m.historyViewport.ScrollDown(1)
case "pgup":
m.historyViewport.HalfPageUp()
case "pgdown":
m.historyViewport.HalfPageDown()
case "home", "g":
m.historyViewport.GotoTop()
case "end", "G":
m.historyViewport.GotoBottom()
case "ctrl+c":
return m, tea.Quit
}
return m, nil return m, nil
} }
+11 -20
View File
@@ -118,11 +118,10 @@ func TestDetailLoad_CachesAndViewDoesNoIO(t *testing.T) {
m := newTestModel(ms) m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "DOWN"}}} m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "DOWN"}}}
m.cursor = 0 m.cursor = 0
m.state = stateDetail m.detailOpen = true
m.termWidth = 120 m.termWidth = 120
m.termHeight = 40 m.termHeight = 40
// Entering detail dispatches the load Cmd.
cmd := m.loadDetailCmd(1) cmd := m.loadDetailCmd(1)
if cmd == nil { if cmd == nil {
t.Fatal("loadDetailCmd returned nil") t.Fatal("loadDetailCmd returned nil")
@@ -136,16 +135,14 @@ func TestDetailLoad_CachesAndViewDoesNoIO(t *testing.T) {
t.Fatalf("expected exactly 1 store hit from the load Cmd, got %d", ms.stateChangeCalls) t.Fatalf("expected exactly 1 store hit from the load Cmd, got %d", ms.stateChangeCalls)
} }
// Apply the msg through Update (caches into the model).
updated, _ := m.Update(dd) updated, _ := m.Update(dd)
m = updated.(Model) m = updated.(Model)
if m.detailChangesSiteID != 1 || len(m.detailChanges) != 1 { if m.detailChangesSiteID != 1 || len(m.detailChanges) != 1 {
t.Fatalf("detail changes not cached: id=%d n=%d", m.detailChangesSiteID, len(m.detailChanges)) t.Fatalf("detail changes not cached: id=%d n=%d", m.detailChangesSiteID, len(m.detailChanges))
} }
// Render the detail panel several times — it must read the cache, not the DB.
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
_ = m.viewDetailPanel() _ = m.viewDetailInline(80)
} }
if ms.stateChangeCalls != 1 { if ms.stateChangeCalls != 1 {
t.Errorf("View performed DB IO: store hit %d times (want 1, from the Cmd only)", ms.stateChangeCalls) t.Errorf("View performed DB IO: store hit %d times (want 1, from the Cmd only)", ms.stateChangeCalls)
@@ -202,16 +199,16 @@ func TestHistoryKey_LoadsOffUIGoroutine(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}} ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms) m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 7, Name: "site"}}} m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 7, Name: "site"}}}
m.state = stateDetail m.detailOpen = true
m.termWidth, m.termHeight = 120, 40 m.termWidth, m.termHeight = 120, 40
updated, cmd := (&m).handleDetailKey(keyMsg("h")) updated, cmd := (&m).handleDashboardKey(keyMsg("h"))
if ms.stateChangeCalls != 0 { if ms.stateChangeCalls != 0 {
t.Fatal("history keypress hit the store synchronously in Update") t.Fatal("history keypress hit the store synchronously in Update")
} }
got := updated.(*Model) got := updated.(*Model)
if got.state != stateHistory || got.historySiteID != 7 { if got.detailMode != detailHistory || got.historySiteID != 7 {
t.Fatalf("history view not opened: state=%v siteID=%d", got.state, got.historySiteID) t.Fatalf("history mode not set: mode=%v siteID=%d", got.detailMode, got.historySiteID)
} }
if cmd == nil { if cmd == nil {
t.Fatal("expected a history load Cmd") t.Fatal("expected a history load Cmd")
@@ -229,7 +226,6 @@ func TestHistoryKey_LoadsOffUIGoroutine(t *testing.T) {
t.Fatal("history reply not folded into the model") t.Fatal("history reply not folded into the model")
} }
// A reply for a previously opened site must not clobber the current one.
m2.historySiteID = 9 m2.historySiteID = 9
stale, _ := m2.Update(historyDataMsg{siteID: 7, changes: nil}) stale, _ := m2.Update(historyDataMsg{siteID: 7, changes: nil})
if m3 := stale.(Model); len(m3.historyChanges) != 1 { if m3 := stale.(Model); len(m3.historyChanges) != 1 {
@@ -241,20 +237,15 @@ func TestSLAData_DropsStaleReply(t *testing.T) {
m := newTestModel(&tuiMockStore{}) m := newTestModel(&tuiMockStore{})
m.termWidth, m.termHeight = 120, 40 m.termWidth, m.termHeight = 120, 40
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 3}, SiteState: models.SiteState{Status: "UP"}}} m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 3}, SiteState: models.SiteState{Status: "UP"}}}
m.detailOpen = true
m.slaSiteID = 3
m.slaPeriodIdx = 2
if cmd := (&m).openSLAView(m.sites[0]); cmd == nil {
t.Fatal("openSLAView should return a load Cmd")
}
// Reply for a different period than currently selected → dropped.
// (slaDataMsg routes through a pointer-receiver handler, so Update
// returns *Model on this path.)
updated, _ := m.Update(slaDataMsg{siteID: 3, periodIdx: 0}) updated, _ := m.Update(slaDataMsg{siteID: 3, periodIdx: 0})
if mm := updated.(*Model); mm.slaDailyBreakdown != nil { if mm := updated.(*Model); mm.slaDailyBreakdown != nil {
t.Error("stale SLA reply (old period) was applied") t.Error("stale SLA reply (old period) was applied")
} }
// Matching reply → report computed.
updated, _ = updated.(*Model).Update(slaDataMsg{siteID: 3, periodIdx: m.slaPeriodIdx}) updated, _ = updated.(*Model).Update(slaDataMsg{siteID: 3, periodIdx: m.slaPeriodIdx})
if mm := updated.(*Model); mm.slaDailyBreakdown == nil { if mm := updated.(*Model); mm.slaDailyBreakdown == nil {
t.Error("matching SLA reply was not applied") t.Error("matching SLA reply was not applied")
@@ -314,12 +305,12 @@ func TestDetailRefreshCmd_OnlyWhileDetailOpen(t *testing.T) {
m := newTestModel(ms) m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 5, Name: "site"}}} m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 5, Name: "site"}}}
m.state = stateDashboard m.detailOpen = false
if (&m).detailRefreshCmd() != nil { if (&m).detailRefreshCmd() != nil {
t.Error("refresh Cmd issued outside the detail view") t.Error("refresh Cmd issued outside the detail view")
} }
m.state = stateDetail m.detailOpen = true
cmd := (&m).detailRefreshCmd() cmd := (&m).detailRefreshCmd()
if cmd == nil { if cmd == nil {
t.Fatal("open detail panel should refresh on the tab-data cadence") t.Fatal("open detail panel should refresh on the tab-data cadence")
-6
View File
@@ -90,14 +90,8 @@ func (m Model) View() string {
return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer) return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer)
} }
return "" return ""
case stateDetail:
return m.zones.Scan(m.viewDetailPanel())
case stateLogs: case stateLogs:
return m.viewLogsFullscreen() return m.viewLogsFullscreen()
case stateHistory:
return m.viewHistoryPanel()
case stateSLA:
return m.viewSLAPanel()
case stateAlertDetail: case stateAlertDetail:
return m.viewAlertDetailPanel() return m.viewAlertDetailPanel()
case stateSettings: case stateSettings:
-342
View File
@@ -1,342 +0,0 @@
package tui
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"github.com/charmbracelet/lipgloss"
)
func (m Model) viewDetailPanel() string {
if m.cursor >= len(m.sites) {
return ""
}
site := m.sites[m.cursor]
hist, _ := m.engine.GetHistory(site.ID)
var b strings.Builder
totalW := m.termWidth - chromePadH
var breadcrumb string
if site.ParentID > 0 {
for _, s := range m.sites {
if s.ID == site.ParentID {
breadcrumb = m.st.subtleStyle.Render(" Monitors > "+s.Name+" > ") + m.st.titleStyle.Render(site.Name)
break
}
}
}
if breadcrumb == "" {
breadcrumb = m.st.subtleStyle.Render(" Monitors > ") + m.st.titleStyle.Render(site.Name)
}
b.WriteString(breadcrumb + "\n")
b.WriteString(m.divider() + "\n")
// Two-column layout for key info
colW := (totalW - 4) / 2
if colW < 30 {
colW = 30
}
row := func(label, value string) string {
return fmt.Sprintf(" %-16s %s", m.st.subtleStyle.Render(label), value)
}
divW := totalW - 4
if divW < 20 {
divW = 20
}
sectionDiv := m.st.subtleStyle.Render(strings.Repeat("─", divW))
sectionHead := func(title string) string {
return m.st.titleStyle.Render(" "+title) + " " + m.st.subtleStyle.Render(strings.Repeat("─", divW-len(title)-3))
}
// Left column: endpoint details
var left []string
left = append(left, m.st.titleStyle.Render(" ENDPOINT"))
left = append(left, row("Type", site.Type))
if site.URL != "" {
left = append(left, row("URL", limitStr(site.URL, colW-19)))
}
if site.Hostname != "" {
left = append(left, row("Host", site.Hostname))
}
if site.Port > 0 {
left = append(left, row("Port", strconv.Itoa(site.Port)))
}
if site.Interval > 0 {
left = append(left, row("Interval", fmt.Sprintf("%ds", site.Interval)))
}
if site.MaxRetries > 0 {
left = append(left, row("Retries", m.fmtRetries(site)))
}
if site.Regions != "" {
left = append(left, row("Regions", site.Regions))
}
if site.Description != "" {
left = append(left, row("Description", limitStr(site.Description, colW-19)))
}
// Right column: status + timing + HTTP
var right []string
right = append(right, m.st.titleStyle.Render(" STATUS"))
right = append(right, row("Status", m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))))
right = append(right, row("Latency", m.fmtLatency(site.Latency)))
right = append(right, row("Uptime", m.fmtUptime(hist.Statuses)))
if !site.StatusChangedAt.IsZero() {
dur := time.Since(site.StatusChangedAt)
right = append(right, row("State Since", fmtDuration(dur)+" ago"))
}
if !site.LastCheck.IsZero() {
right = append(right, row("Last Check", m.fmtTimeAgo(site.LastCheck)))
}
if !site.LastSuccessAt.IsZero() {
right = append(right, row("Last Success", m.fmtTimeAgo(site.LastSuccessAt)))
}
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp || site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
errW := colW - 19
if errW < 20 {
errW = 20
}
right = append(right, row("Error", m.st.dangerStyle.Render(limitStr(site.LastError, errW))))
}
if site.Type == "http" {
if site.StatusCode > 0 {
right = append(right, row("HTTP Code", strconv.Itoa(site.StatusCode)))
}
codes := site.AcceptedCodes
if codes == "" {
codes = "200-299"
}
right = append(right, row("Codes", codes))
right = append(right, row("SSL", m.fmtSSL(site)))
if site.Method != "" && site.Method != "GET" {
right = append(right, row("Method", site.Method))
}
}
// Pad shorter column
for len(left) < len(right) {
left = append(left, "")
}
for len(right) < len(left) {
right = append(right, "")
}
leftCol := lipgloss.NewStyle().Width(colW).Render(strings.Join(left, "\n"))
rightCol := lipgloss.NewStyle().Width(colW).Render(strings.Join(right, "\n"))
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol) + "\n")
b.WriteString("\n" + sectionDiv + "\n")
// Connection chain (full width, only on errors)
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp) && site.LastError != "" {
chain := connectionChain(site.LastError, site.Type, site.StatusCode, strings.HasPrefix(site.URL, "https"))
if len(chain) > 0 {
b.WriteString("\n")
for _, step := range chain {
var icon string
switch step.Status {
case stepPassed:
icon = m.st.specialStyle.Render("✓")
case stepFailed:
icon = m.st.dangerStyle.Render("✗")
case stepSkipped:
icon = m.st.subtleStyle.Render("·")
}
line := fmt.Sprintf(" %s %-16s", icon, step.Name)
if step.Detail != "" {
switch step.Status {
case stepFailed:
line += " " + m.st.dangerStyle.Render(step.Detail)
case stepSkipped:
line += " " + m.st.subtleStyle.Render(step.Detail)
}
}
b.WriteString(line + "\n")
}
}
}
// Maintenance
if m.isMonitorInMaintenance(site.ID) {
for _, mw := range m.maintenanceWindows {
if mw.Type == "maintenance" && (mw.MonitorID == 0 || mw.MonitorID == site.ID || mw.MonitorID == site.ParentID) {
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Maintenance"), m.st.maintStyle.Render(mw.Title))
break
}
}
}
// Push token
if site.Type == "push" && site.Token != "" {
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Token"), site.Token)
}
// Probe results
probeResults := m.engine.GetProbeResults(site.ID)
if len(probeResults) > 0 {
nodeIDs := make([]string, 0, len(probeResults))
for id := range probeResults {
nodeIDs = append(nodeIDs, id)
}
sort.Strings(nodeIDs)
b.WriteString("\n" + sectionHead("PROBE RESULTS") + "\n")
for _, nodeID := range nodeIDs {
result := probeResults[nodeID]
status := m.st.specialStyle.Render("UP")
if !result.IsUp {
status = m.st.dangerStyle.Render("DN")
}
latency := time.Duration(result.LatencyNs).Milliseconds()
ago := time.Since(result.CheckedAt).Truncate(time.Second)
line := fmt.Sprintf(" %-14s %s %dms %s ago", nodeID, status, latency, ago)
if !result.IsUp && result.ErrorReason != "" {
line += " " + m.st.dangerStyle.Render(result.ErrorReason)
}
b.WriteString(line + "\n")
}
}
// Bottom two-column: graphs left, state changes right
graphW := (totalW - 4) * 70 / 100
changeW := totalW - 4 - graphW
if graphW < 30 {
graphW = 30
}
if changeW < 20 {
changeW = 20
}
bottomColW := graphW
sectionLabel := func(title string) string {
return m.st.titleStyle.Render(" " + title)
}
// Left: latency + histogram (skip for groups — they have no own checks)
var graphLines []string
if site.Type != "group" {
graphLines = append(graphLines, sectionLabel("LATENCY"))
if site.Type == "push" {
sparkW := bottomColW - 4
if sparkW > detailSparkWidth {
sparkW = detailSparkWidth
}
graphLines = append(graphLines, " "+m.heartbeatSparkline(hist.Statuses, sparkW, nil))
if len(hist.Statuses) > 0 {
up := 0
for _, s := range hist.Statuses {
if s {
up++
}
}
graphLines = append(graphLines, fmt.Sprintf(" %s %d/%d checks up",
m.st.subtleStyle.Render("Heartbeats"), up, len(hist.Statuses)))
}
} else {
sparkW := bottomColW - 4
if sparkW > detailSparkWidth {
sparkW = detailSparkWidth
}
graphLines = append(graphLines, " "+m.latencySparkline(hist.Latencies, hist.Statuses, sparkW, nil))
var minL, maxL, total time.Duration
count := 0
for i, l := range hist.Latencies {
if i < len(hist.Statuses) && !hist.Statuses[i] {
continue
}
if count == 0 {
minL, maxL = l, l
} else if l < minL {
minL = l
} else if l > maxL {
maxL = l
}
total += l
count++
}
if count > 0 {
avg := total / time.Duration(count)
graphLines = append(graphLines, fmt.Sprintf(" %s %dms %s %dms %s %dms",
m.st.subtleStyle.Render("Min"), minL.Milliseconds(),
m.st.subtleStyle.Render("Avg"), avg.Milliseconds(),
m.st.subtleStyle.Render("Max"), maxL.Milliseconds()))
}
}
if site.Type != "push" && len(hist.Latencies) > 5 {
graphLines = append(graphLines, "")
graphLines = append(graphLines, sectionLabel("DISTRIBUTION"))
graphLines = append(graphLines, m.latencyHistogram(hist.Latencies, hist.Statuses, bottomColW))
}
}
// Right: state changes
var changeLines []string
var stateChanges []models.StateChange
if m.detailChangesSiteID == site.ID {
stateChanges = m.detailChanges
}
changeLines = append(changeLines, sectionLabel("STATE CHANGES"))
if len(stateChanges) > 0 {
for i, sc := range stateChanges {
from := m.fmtStatusWord(string(sc.FromStatus))
to := m.fmtStatusWord(string(sc.ToStatus))
ago := fmtDuration(time.Since(sc.ChangedAt))
line := fmt.Sprintf(" %s → %s %s ago", from, to, ago)
if sc.ToStatus == "UP" {
dur := computeOutageDuration(stateChanges, i)
if dur > 0 {
line += " " + m.st.warnStyle.Render("outage "+fmtDuration(dur))
}
}
if sc.ErrorReason != "" {
line += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, changeW-30))
}
changeLines = append(changeLines, line)
}
} else {
changeLines = append(changeLines, m.st.subtleStyle.Render(" No state changes"))
}
// Pad and join
for len(graphLines) < len(changeLines) {
graphLines = append(graphLines, "")
}
for len(changeLines) < len(graphLines) {
changeLines = append(changeLines, "")
}
if len(graphLines) > 0 {
graphCol := lipgloss.NewStyle().Width(graphW).Render(strings.Join(graphLines, "\n"))
changeCol := lipgloss.NewStyle().Width(changeW).Render(strings.Join(changeLines, "\n"))
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, graphCol, changeCol) + "\n")
} else {
b.WriteString(strings.Join(changeLines, "\n") + "\n")
}
b.WriteString("\n")
b.WriteString(m.divider() + "\n")
b.WriteString(m.st.subtleStyle.Render(" [e] Edit [h] History [s] SLA [click] Inspect [q/Esc] Back"))
// Wrap in a viewport for scrolling
content := b.String()
contentH := m.termHeight - 4
if contentH < 10 {
contentH = 10
}
lines := strings.Split(content, "\n")
if len(lines) > contentH {
m.detailViewport.SetContent(content)
m.detailViewport.Width = totalW
m.detailViewport.Height = contentH
return lipgloss.NewStyle().Padding(1, 2).Render(m.detailViewport.View())
}
return lipgloss.NewStyle().Padding(1, 2).Render(content)
}
-86
View File
@@ -1,12 +1,10 @@
package tui package tui
import ( import (
"fmt"
"strings" "strings"
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"github.com/charmbracelet/lipgloss"
) )
type historyStats struct { type historyStats struct {
@@ -105,87 +103,3 @@ func (m Model) stateChangeSparkline(changes []models.StateChange, width int) str
} }
return sb.String() return sb.String()
} }
func (m Model) buildHistoryContent() string {
var b strings.Builder
reasonWidth := m.termWidth - chromePadH - 55
if reasonWidth < 10 {
reasonWidth = 10
}
if reasonWidth > 60 {
reasonWidth = 60
}
for i, sc := range m.historyChanges {
ts := sc.ChangedAt.Format("2006-01-02 15:04")
arrow := m.st.subtleStyle.Render(sc.FromStatus) + " → "
switch sc.ToStatus {
case string(models.StatusUp):
arrow += m.st.specialStyle.Render(sc.ToStatus)
case string(models.StatusLate):
arrow += m.st.warnStyle.Render(sc.ToStatus)
case string(models.StatusStale):
arrow += m.st.staleStyle.Render(sc.ToStatus)
default:
arrow += m.st.dangerStyle.Render(sc.ToStatus)
}
durStr := ""
if dur := computeOutageDuration(m.historyChanges, i); dur > 0 {
durStr = m.st.warnStyle.Render("outage " + fmtDuration(dur))
}
reason := ""
if sc.ErrorReason != "" && sc.ToStatus != string(models.StatusUp) {
reason = m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonWidth))
}
fmt.Fprintf(&b, " %-18s %s %-12s %s\n", ts, arrow, durStr, reason)
}
return b.String()
}
func (m Model) viewHistoryPanel() string {
var b strings.Builder
header := " " + m.st.subtleStyle.Render("History >") + " " + m.st.titleStyle.Render(m.historySiteName)
b.WriteString(header + "\n")
divWidth := m.dividerWidth()
b.WriteString(m.divider() + "\n")
sparkline := m.stateChangeSparkline(m.historyChanges, divWidth)
if sparkline != "" {
b.WriteString(" " + sparkline + "\n")
b.WriteString(m.divider() + "\n")
}
fmt.Fprintf(&b, " %-18s %-17s %-12s %s\n",
m.st.subtleStyle.Render("TIME"),
m.st.subtleStyle.Render("TRANSITION"),
m.st.subtleStyle.Render("DURATION"),
m.st.subtleStyle.Render("REASON"))
if len(m.historyChanges) == 0 {
b.WriteString("\n " + m.st.subtleStyle.Render("No state changes recorded") + "\n")
} else {
b.WriteString(m.historyViewport.View())
}
b.WriteString("\n" + m.divider() + "\n")
stats := computeHistoryStats(m.historyChanges)
parts := []string{fmt.Sprintf("%d events", stats.totalEvents)}
if stats.outageCount > 0 {
parts = append(parts, fmt.Sprintf("%d outages", stats.outageCount))
avg := stats.totalDowntime / time.Duration(stats.outageCount)
parts = append(parts, "avg outage "+fmtDuration(avg))
}
b.WriteString(" " + m.st.subtleStyle.Render(strings.Join(parts, " │ ")) + "\n")
b.WriteString(" " + m.st.subtleStyle.Render("[↑/↓] Scroll [q/Esc] Back"))
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
}
-87
View File
@@ -5,8 +5,6 @@ import (
"math" "math"
"strings" "strings"
"time" "time"
"github.com/charmbracelet/lipgloss"
) )
var slaPeriods = []struct { var slaPeriods = []struct {
@@ -21,91 +19,6 @@ var slaPeriods = []struct {
{"90d", "4", 90 * 24 * time.Hour, 90}, {"90d", "4", 90 * 24 * time.Hour, 90},
} }
func (m Model) viewSLAPanel() string {
var b strings.Builder
header := " " + m.st.subtleStyle.Render("SLA >") + " " + m.st.titleStyle.Render(m.slaSiteName)
b.WriteString(header + "\n")
b.WriteString(m.divider() + "\n")
period := slaPeriods[m.slaPeriodIdx]
b.WriteString(" " + m.st.subtleStyle.Render("Period: Last "+period.label) + "\n\n")
r := m.slaReport
barWidth := m.dividerWidth() - 30
if barWidth < 10 {
barWidth = 10
}
bar := m.uptimeBar(r.UptimePct, barWidth)
uptimeColor := m.st.specialStyle
if r.UptimePct < uptimeExcellentPct {
uptimeColor = m.st.warnStyle
}
if r.UptimePct < uptimeGoodPct {
uptimeColor = m.st.dangerStyle
}
fmt.Fprintf(&b, " %-16s %s %s\n", m.st.subtleStyle.Render("Uptime"), uptimeColor.Render(fmt.Sprintf("%s%%", fmtPct(r.UptimePct))), bar)
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Downtime"), fmtDuration(r.Downtime))
fmt.Fprintf(&b, " %-16s %d\n", m.st.subtleStyle.Render("Outages"), r.OutageCount)
if r.OutageCount > 0 {
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Longest"), fmtDuration(r.LongestOut))
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("MTTR"), fmtDuration(r.MTTR))
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("MTBF"), fmtDuration(r.MTBF))
}
b.WriteString("\n" + m.divider() + "\n")
if len(m.slaDailyBreakdown) > 0 {
b.WriteString(m.slaViewport.View())
}
b.WriteString("\n" + m.divider() + "\n")
var keys []string
for i, p := range slaPeriods {
label := fmt.Sprintf("[%s] %s", p.key, p.label)
if i == m.slaPeriodIdx {
keys = append(keys, m.st.titleStyle.Render(label))
} else {
keys = append(keys, m.st.subtleStyle.Render(label))
}
}
b.WriteString(" " + strings.Join(keys, " "))
b.WriteString(" " + m.st.subtleStyle.Render("[↑/↓] Scroll [q/Esc] Back"))
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
}
func (m Model) buildSLADailyContent() string {
var b strings.Builder
barWidth := m.dividerWidth() - 30
if barWidth < 10 {
barWidth = 10
}
b.WriteString(" " + m.st.titleStyle.Render("DAILY BREAKDOWN") + "\n")
for _, day := range m.slaDailyBreakdown {
dateStr := day.Date.Format("Jan 02")
bar := m.uptimeBar(day.UptimePct, barWidth)
pctStr := fmtPct(day.UptimePct) + "%"
color := m.st.specialStyle
if day.UptimePct < uptimeExcellentPct {
color = m.st.warnStyle
}
if day.UptimePct < uptimeGoodPct {
color = m.st.dangerStyle
}
fmt.Fprintf(&b, " %-8s %s %s\n", m.st.subtleStyle.Render(dateStr), bar, color.Render(pctStr))
}
return b.String()
}
func (m Model) uptimeBar(pct float64, width int) string { func (m Model) uptimeBar(pct float64, width int) string {
filled := int(math.Round(pct / 100 * float64(width))) filled := int(math.Round(pct / 100 * float64(width)))
if filled > width { if filled > width {