refactor(tui): remove full-page detail, SLA, and history views
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:
@@ -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()
|
|
||||||
}
|
|
||||||
+10
-21
@@ -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
|
||||||
@@ -262,7 +252,6 @@ func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor
|
|||||||
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
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user