feat(tui): master-detail layout with sidebar SLA/history #163
@@ -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()
|
||||
}
|
||||
@@ -40,13 +40,13 @@ func (m Model) renderCompactLogLine(entry models.LogEntry, maxW int) string {
|
||||
return " " + m.st.subtleStyle.Render(ts) + " " + tag + " " + msg
|
||||
}
|
||||
|
||||
func (m Model) viewLogsSidebar(width, maxLines int) string {
|
||||
func (m Model) viewLogsStrip(width, maxLines int) string {
|
||||
logs := m.engine.GetLogs()
|
||||
if len(logs) == 0 {
|
||||
return m.st.subtleStyle.Render(" No logs yet")
|
||||
}
|
||||
|
||||
sidebarStyle := lipgloss.NewStyle().Width(width).MaxWidth(width)
|
||||
style := lipgloss.NewStyle().Width(width).MaxWidth(width)
|
||||
|
||||
var all []string
|
||||
for _, entry := range logs {
|
||||
@@ -69,7 +69,7 @@ func (m Model) viewLogsSidebar(width, maxLines int) string {
|
||||
}
|
||||
visible := all[start:end]
|
||||
|
||||
return sidebarStyle.Render(strings.Join(visible, "\n"))
|
||||
return style.Render(strings.Join(visible, "\n"))
|
||||
}
|
||||
|
||||
func (m *Model) scrollLogs(delta int) {
|
||||
|
||||
+17
-21
@@ -80,8 +80,6 @@ const (
|
||||
chromeFooter = 2 // footer: "\n" prefix + text line
|
||||
chromeTable = 3 // renderTable "\n" prefix + top border + header + bottom border (lipgloss collapses two into three rendered lines)
|
||||
chromeBase = chromePadV + chromeHeader + chromeGaps + chromeFooter + chromeTable
|
||||
|
||||
detailSparkWidth = 40
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -97,6 +95,12 @@ const (
|
||||
panelMaint = 3
|
||||
)
|
||||
|
||||
const (
|
||||
detailDefault = 0
|
||||
detailSLA = 1
|
||||
detailHistory = 2
|
||||
)
|
||||
|
||||
const (
|
||||
sortStatus = 0
|
||||
sortName = 1
|
||||
@@ -107,19 +111,16 @@ const (
|
||||
type sessionState int
|
||||
|
||||
const (
|
||||
stateDashboard sessionState = iota
|
||||
stateLogs
|
||||
stateDetail
|
||||
stateAlertDetail
|
||||
stateFormSite
|
||||
stateFormAlert
|
||||
stateFormUser
|
||||
stateConfirmDelete
|
||||
stateFormMaint
|
||||
stateHistory
|
||||
stateSLA
|
||||
stateSettings
|
||||
stateMaintDetail
|
||||
stateDashboard sessionState = 0
|
||||
stateLogs sessionState = 1
|
||||
stateAlertDetail sessionState = 3
|
||||
stateFormSite sessionState = 4
|
||||
stateFormAlert sessionState = 5
|
||||
stateFormUser sessionState = 6
|
||||
stateConfirmDelete sessionState = 7
|
||||
stateFormMaint sessionState = 8
|
||||
stateSettings sessionState = 11
|
||||
stateMaintDetail sessionState = 12
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
@@ -138,6 +139,7 @@ type Model struct {
|
||||
termHeight int
|
||||
contentWidth int
|
||||
focusedPanel int
|
||||
detailMode int
|
||||
logScrollOffset int
|
||||
editID int
|
||||
editToken string
|
||||
@@ -154,12 +156,10 @@ type Model struct {
|
||||
logTotal int
|
||||
logShown int
|
||||
|
||||
historyViewport viewport.Model
|
||||
historyChanges []models.StateChange
|
||||
historySiteName string
|
||||
historySiteID int
|
||||
|
||||
slaViewport viewport.Model
|
||||
slaReport monitor.SLAReport
|
||||
slaDailyBreakdown []monitor.DayReport
|
||||
slaSiteName string
|
||||
@@ -205,13 +205,10 @@ type Model struct {
|
||||
detailChanges []models.StateChange
|
||||
detailChangesSiteID int
|
||||
detailDailyDays []monitor.DayReport
|
||||
detailViewport viewport.Model
|
||||
|
||||
filterMode bool
|
||||
filterText string
|
||||
|
||||
sparkTooltipIdx int // clicked sparkline data index, -1 = none
|
||||
|
||||
// demoMode renders a stable status dot instead of the animated pulse so
|
||||
// screenshots/recordings don't capture the spinner mid-frame. Set via UPTOP_DEMO=1.
|
||||
demoMode bool
|
||||
@@ -255,7 +252,6 @@ func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor
|
||||
detailOpen: detailPref == "true",
|
||||
demoMode: os.Getenv("UPTOP_DEMO") == "1",
|
||||
version: version,
|
||||
sparkTooltipIdx: -1,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+45
-194
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
)
|
||||
@@ -20,9 +19,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case tabDataMsg:
|
||||
return m.handleTabData(msg)
|
||||
case detailDataMsg:
|
||||
// Drop replies for a site the user has already navigated away from,
|
||||
// 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 {
|
||||
if m.detailOpen && m.cursor < len(m.sites) && m.sites[m.cursor].ID != msg.siteID {
|
||||
return m, nil
|
||||
}
|
||||
m.detailChanges = msg.changes
|
||||
@@ -31,11 +28,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
case historyDataMsg:
|
||||
if msg.siteID != m.historySiteID {
|
||||
return m, nil // stale reply for a previously opened history
|
||||
return m, nil
|
||||
}
|
||||
m.historyChanges = msg.changes
|
||||
m.historyViewport.SetContent(m.buildHistoryContent())
|
||||
m.historyViewport.GotoTop()
|
||||
return m, nil
|
||||
case slaDataMsg:
|
||||
return m.handleSLAData(msg)
|
||||
@@ -155,15 +150,15 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
const detailInlineHeight = 12
|
||||
const logsStripHeight = 6
|
||||
|
||||
func (m *Model) recalcLayout() {
|
||||
chrome := chromeBase
|
||||
if m.filterMode || m.filterText != "" {
|
||||
chrome++
|
||||
}
|
||||
if m.detailOpen {
|
||||
chrome += detailInlineHeight
|
||||
if m.logsOpen {
|
||||
chrome += logsStripHeight
|
||||
}
|
||||
m.maxTableRows = m.termHeight - chrome
|
||||
if m.maxTableRows < 1 {
|
||||
@@ -177,10 +172,6 @@ func (m *Model) handleResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) {
|
||||
m.recalcLayout()
|
||||
m.logViewport.Width = msg.Width - chromePadH
|
||||
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 {
|
||||
formHeight := msg.Height - 7
|
||||
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
|
||||
// up without leaving and re-entering. Nil when no detail panel is open.
|
||||
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 m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||
@@ -259,7 +250,7 @@ func (m *Model) handleLogsFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc", "q":
|
||||
m.state = stateDashboard
|
||||
m.focusedPanel = panelLogs
|
||||
m.focusedPanel = panelMonitors
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "f":
|
||||
@@ -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) {
|
||||
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 {
|
||||
return m, nil
|
||||
}
|
||||
@@ -367,12 +334,6 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
switch m.state {
|
||||
case stateDetail:
|
||||
return m.handleDetailKey(msg)
|
||||
case stateHistory:
|
||||
return m.handleHistoryKey(msg)
|
||||
case stateSLA:
|
||||
return m.handleSLAKey(msg)
|
||||
case stateAlertDetail:
|
||||
return m.handleAlertDetailKey(msg)
|
||||
case stateSettings:
|
||||
@@ -417,115 +378,6 @@ func (m *Model) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
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
|
||||
// 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
|
||||
@@ -546,35 +398,6 @@ func (m *Model) handleSLAData(msg slaDataMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
m.slaReport = monitor.ComputeSLA(msg.changes, currentStatus, period.duration)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -756,7 +579,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.recalcLayout()
|
||||
}
|
||||
case "l":
|
||||
if m.focusedPanel == panelLogs {
|
||||
if m.logsOpen {
|
||||
m.logsOpen = false
|
||||
m.focusedPanel = panelMonitors
|
||||
} else {
|
||||
@@ -780,6 +603,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
m.syncSelectedID()
|
||||
if m.detailOpen && m.cursor < len(m.sites) {
|
||||
m.detailMode = detailDefault
|
||||
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||
}
|
||||
}
|
||||
@@ -800,6 +624,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
m.syncSelectedID()
|
||||
if m.detailOpen && m.cursor < len(m.sites) {
|
||||
m.detailMode = detailDefault
|
||||
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||
}
|
||||
}
|
||||
@@ -825,8 +650,23 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
if len(m.sites) > 0 {
|
||||
m.state = stateDetail
|
||||
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||
site := m.sites[m.cursor]
|
||||
if site.Type == "group" {
|
||||
m.collapsed[site.ID] = !m.collapsed[site.ID]
|
||||
payload := collapsedJSON(m.collapsed)
|
||||
st := m.store
|
||||
ctx := m.ctx
|
||||
m.refreshLive()
|
||||
return m, writeCmd("Save collapsed groups", func() error {
|
||||
return st.SetPreference(ctx, "collapsed_groups", payload)
|
||||
})
|
||||
}
|
||||
if !m.detailOpen {
|
||||
m.detailOpen = true
|
||||
m.detailMode = detailDefault
|
||||
m.recalcLayout()
|
||||
}
|
||||
return m, m.loadDetailCmd(site.ID)
|
||||
}
|
||||
case "e":
|
||||
return m.handleEditItem()
|
||||
@@ -879,8 +719,11 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
case "esc":
|
||||
if m.focusedPanel != panelMonitors {
|
||||
m.focusedPanel = panelMonitors
|
||||
} else if m.detailOpen && m.detailMode != detailDefault {
|
||||
m.detailMode = detailDefault
|
||||
} else if m.detailOpen {
|
||||
m.detailOpen = false
|
||||
m.detailMode = detailDefault
|
||||
m.recalcLayout()
|
||||
st := m.store
|
||||
ctx := m.ctx
|
||||
@@ -894,17 +737,25 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
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
|
||||
m.detailMode = detailHistory
|
||||
return m, m.loadHistoryCmd(site.ID)
|
||||
}
|
||||
case "s":
|
||||
if m.detailOpen && m.cursor < len(m.sites) {
|
||||
return m, m.openSLAView(m.sites[m.cursor])
|
||||
site := m.sites[m.cursor]
|
||||
m.slaSiteName = site.Name
|
||||
m.slaSiteID = site.ID
|
||||
m.slaPeriodIdx = 2
|
||||
m.detailMode = detailSLA
|
||||
return m, m.loadSLACmd(site.ID, m.slaPeriodIdx)
|
||||
}
|
||||
case "1", "2", "3", "4":
|
||||
if m.detailOpen && m.detailMode == detailSLA {
|
||||
idx := int(msg.String()[0]-'0') - 1
|
||||
if idx >= 0 && idx < len(slaPeriods) {
|
||||
m.slaPeriodIdx = idx
|
||||
return m, m.loadSLACmd(m.slaSiteID, idx)
|
||||
}
|
||||
}
|
||||
case "x":
|
||||
if m.focusedPanel == panelMaint {
|
||||
|
||||
+11
-20
@@ -118,11 +118,10 @@ func TestDetailLoad_CachesAndViewDoesNoIO(t *testing.T) {
|
||||
m := newTestModel(ms)
|
||||
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "DOWN"}}}
|
||||
m.cursor = 0
|
||||
m.state = stateDetail
|
||||
m.detailOpen = true
|
||||
m.termWidth = 120
|
||||
m.termHeight = 40
|
||||
|
||||
// Entering detail dispatches the load Cmd.
|
||||
cmd := m.loadDetailCmd(1)
|
||||
if cmd == 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)
|
||||
}
|
||||
|
||||
// Apply the msg through Update (caches into the model).
|
||||
updated, _ := m.Update(dd)
|
||||
m = updated.(Model)
|
||||
if m.detailChangesSiteID != 1 || len(m.detailChanges) != 1 {
|
||||
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++ {
|
||||
_ = m.viewDetailPanel()
|
||||
_ = m.viewDetailInline(80)
|
||||
}
|
||||
if ms.stateChangeCalls != 1 {
|
||||
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"}}}
|
||||
m := newTestModel(ms)
|
||||
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 7, Name: "site"}}}
|
||||
m.state = stateDetail
|
||||
m.detailOpen = true
|
||||
m.termWidth, m.termHeight = 120, 40
|
||||
|
||||
updated, cmd := (&m).handleDetailKey(keyMsg("h"))
|
||||
updated, cmd := (&m).handleDashboardKey(keyMsg("h"))
|
||||
if ms.stateChangeCalls != 0 {
|
||||
t.Fatal("history keypress hit the store synchronously in Update")
|
||||
}
|
||||
got := updated.(*Model)
|
||||
if got.state != stateHistory || got.historySiteID != 7 {
|
||||
t.Fatalf("history view not opened: state=%v siteID=%d", got.state, got.historySiteID)
|
||||
if got.detailMode != detailHistory || got.historySiteID != 7 {
|
||||
t.Fatalf("history mode not set: mode=%v siteID=%d", got.detailMode, got.historySiteID)
|
||||
}
|
||||
if cmd == nil {
|
||||
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")
|
||||
}
|
||||
|
||||
// A reply for a previously opened site must not clobber the current one.
|
||||
m2.historySiteID = 9
|
||||
stale, _ := m2.Update(historyDataMsg{siteID: 7, changes: nil})
|
||||
if m3 := stale.(Model); len(m3.historyChanges) != 1 {
|
||||
@@ -241,20 +237,15 @@ func TestSLAData_DropsStaleReply(t *testing.T) {
|
||||
m := newTestModel(&tuiMockStore{})
|
||||
m.termWidth, m.termHeight = 120, 40
|
||||
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})
|
||||
if mm := updated.(*Model); mm.slaDailyBreakdown != nil {
|
||||
t.Error("stale SLA reply (old period) was applied")
|
||||
}
|
||||
|
||||
// Matching reply → report computed.
|
||||
updated, _ = updated.(*Model).Update(slaDataMsg{siteID: 3, periodIdx: m.slaPeriodIdx})
|
||||
if mm := updated.(*Model); mm.slaDailyBreakdown == nil {
|
||||
t.Error("matching SLA reply was not applied")
|
||||
@@ -314,12 +305,12 @@ func TestDetailRefreshCmd_OnlyWhileDetailOpen(t *testing.T) {
|
||||
m := newTestModel(ms)
|
||||
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 5, Name: "site"}}}
|
||||
|
||||
m.state = stateDashboard
|
||||
m.detailOpen = false
|
||||
if (&m).detailRefreshCmd() != nil {
|
||||
t.Error("refresh Cmd issued outside the detail view")
|
||||
}
|
||||
|
||||
m.state = stateDetail
|
||||
m.detailOpen = true
|
||||
cmd := (&m).detailRefreshCmd()
|
||||
if cmd == nil {
|
||||
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 ""
|
||||
case stateDetail:
|
||||
return m.zones.Scan(m.viewDetailPanel())
|
||||
case stateLogs:
|
||||
return m.viewLogsFullscreen()
|
||||
case stateHistory:
|
||||
return m.viewHistoryPanel()
|
||||
case stateSLA:
|
||||
return m.viewSLAPanel()
|
||||
case stateAlertDetail:
|
||||
return m.viewAlertDetailPanel()
|
||||
case stateSettings:
|
||||
@@ -156,9 +150,9 @@ func (m Model) viewMonitorsLayout() string {
|
||||
wide := m.termWidth >= wideBreakpoint
|
||||
|
||||
showMaint := m.maintOpen && wide
|
||||
showLogs := m.logsOpen && wide
|
||||
showDetail := m.detailOpen && wide
|
||||
|
||||
var maintW, logsW, monW int
|
||||
var maintW, detailW, monW int
|
||||
if showMaint {
|
||||
maintW = maintSidebarW
|
||||
if maintW > availW/4 {
|
||||
@@ -166,9 +160,9 @@ func (m Model) viewMonitorsLayout() string {
|
||||
}
|
||||
}
|
||||
remaining := availW - maintW
|
||||
if showLogs {
|
||||
monW = remaining * 70 / 100
|
||||
logsW = remaining - monW
|
||||
if showDetail {
|
||||
monW = remaining * 45 / 100
|
||||
detailW = remaining - monW
|
||||
} else {
|
||||
monW = remaining
|
||||
}
|
||||
@@ -185,22 +179,28 @@ func (m Model) viewMonitorsLayout() string {
|
||||
topParts = append(topParts, maintPanel)
|
||||
}
|
||||
topParts = append(topParts, monPanel)
|
||||
if showLogs {
|
||||
logContent := m.viewLogsSidebar(logsW-2, m.maxTableRows)
|
||||
logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", logContent, logsW, m.focusedPanel == panelLogs))
|
||||
topParts = append(topParts, logPanel)
|
||||
if showDetail {
|
||||
title := ""
|
||||
if m.cursor < len(m.sites) {
|
||||
title = m.sites[m.cursor].Name
|
||||
}
|
||||
switch m.detailMode {
|
||||
case detailSLA:
|
||||
title = "SLA · " + title
|
||||
case detailHistory:
|
||||
title = "History · " + title
|
||||
}
|
||||
detail := m.viewDetailInline(detailW - 2)
|
||||
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(title, detail, detailW, m.focusedPanel == panelDetail))
|
||||
topParts = append(topParts, detailPanel)
|
||||
}
|
||||
|
||||
top := lipgloss.JoinHorizontal(lipgloss.Top, topParts...)
|
||||
|
||||
if m.detailOpen {
|
||||
site := ""
|
||||
if m.cursor < len(m.sites) {
|
||||
site = m.sites[m.cursor].Name
|
||||
}
|
||||
detail := m.viewDetailInline(availW - 2)
|
||||
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail))
|
||||
return top + "\n" + detailPanel
|
||||
if m.logsOpen {
|
||||
logContent := m.viewLogsStrip(availW-2, logsStripHeight-2)
|
||||
logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", logContent, availW, m.focusedPanel == panelLogs))
|
||||
return top + "\n" + logPanel
|
||||
}
|
||||
return top
|
||||
}
|
||||
@@ -288,8 +288,12 @@ func (m Model) renderFooter(_ dashboardStats) string {
|
||||
keys = "[n]New [x]End [d]Del [Esc]Back [S]Settings [T]Theme [q]Quit"
|
||||
} else if m.focusedPanel == panelLogs {
|
||||
keys = "[↑/↓]Scroll [Enter]Expand [l/Esc]Back [S]Settings [T]Theme [q]Quit"
|
||||
} else if m.detailOpen && m.detailMode == detailSLA {
|
||||
keys = "[1-4]Period [Esc]Back [l]Logs [S]Settings [T]Theme [q]Quit"
|
||||
} else if m.detailOpen && m.detailMode == detailHistory {
|
||||
keys = "[Esc]Back [l]Logs [S]Settings [T]Theme [q]Quit"
|
||||
} else if m.detailOpen {
|
||||
keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [m]Maint [l]Logs [S]Settings [T]Theme [q]Quit"
|
||||
keys = "[i]Close [h]History [s]SLA [e]Edit [m]Maint [l]Logs [S]Settings [T]Theme [q]Quit"
|
||||
} else {
|
||||
keys = "[/]Filter [i]Info [Enter]Detail [n]New [e]Edit [d]Del [m]Maint [l]Logs [S]Settings [T]Theme [q]Quit"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+223
-170
@@ -10,127 +10,111 @@ import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
const detailTwoColMinWidth = 80
|
||||
|
||||
func (m Model) viewDetailInline(width int) string {
|
||||
if m.cursor >= len(m.sites) {
|
||||
return ""
|
||||
}
|
||||
switch m.detailMode {
|
||||
case detailSLA:
|
||||
return m.viewSLASidebar(width)
|
||||
case detailHistory:
|
||||
return m.viewHistorySidebar(width)
|
||||
default:
|
||||
site := m.sites[m.cursor]
|
||||
hist, _ := m.engine.GetHistory(site.ID)
|
||||
|
||||
if width < detailTwoColMinWidth {
|
||||
return m.viewDetailSingleCol(site, hist, width)
|
||||
return m.viewDetailSidebar(site, hist, width)
|
||||
}
|
||||
return m.viewDetailTwoCol(site, hist, width)
|
||||
}
|
||||
|
||||
func (m Model) viewDetailTwoCol(site models.Site, hist monitor.SiteHistory, width int) string {
|
||||
leftW := width * 55 / 100
|
||||
rightW := width - leftW - 3 // 3 for " │ " divider
|
||||
|
||||
left := m.detailLeftCol(site, hist, leftW)
|
||||
right := m.detailRightCol(site, hist, rightW)
|
||||
|
||||
leftLines := strings.Split(left, "\n")
|
||||
rightLines := strings.Split(right, "\n")
|
||||
|
||||
lineCount := len(leftLines)
|
||||
if len(rightLines) > lineCount {
|
||||
lineCount = len(rightLines)
|
||||
}
|
||||
for len(leftLines) < lineCount {
|
||||
leftLines = append(leftLines, "")
|
||||
}
|
||||
for len(rightLines) < lineCount {
|
||||
rightLines = append(rightLines, "")
|
||||
}
|
||||
|
||||
divChar := m.st.subtleStyle.Render("│")
|
||||
leftStyle := lipgloss.NewStyle().Width(leftW).MaxWidth(leftW)
|
||||
rightStyle := lipgloss.NewStyle().Width(rightW).MaxWidth(rightW)
|
||||
|
||||
func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, width int) string {
|
||||
dot := m.st.subtleStyle.Render(" · ")
|
||||
label := m.st.subtleStyle
|
||||
var b strings.Builder
|
||||
for i := range lineCount {
|
||||
l := leftStyle.Render(leftLines[i])
|
||||
r := rightStyle.Render(rightLines[i])
|
||||
b.WriteString(l + " " + divChar + " " + r + "\n")
|
||||
innerW := width - 4
|
||||
if innerW < 20 {
|
||||
innerW = 20
|
||||
}
|
||||
|
||||
b.WriteString(" " + m.detailKeys() + "\n")
|
||||
// Status + latency + last check
|
||||
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
|
||||
statusParts := []string{status}
|
||||
if site.Latency > 0 {
|
||||
statusParts = append(statusParts, m.fmtLatency(site.Latency))
|
||||
}
|
||||
if !site.LastCheck.IsZero() {
|
||||
statusParts = append(statusParts, m.fmtTimeAgo(site.LastCheck))
|
||||
}
|
||||
b.WriteString(" " + strings.Join(statusParts, dot) + "\n")
|
||||
|
||||
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
|
||||
// Type-specific details
|
||||
typeParts := m.detailTypeLine(site)
|
||||
if len(typeParts) > 0 {
|
||||
b.WriteString(" " + strings.Join(typeParts, dot) + "\n")
|
||||
}
|
||||
|
||||
func (m Model) detailLeftCol(site models.Site, hist monitor.SiteHistory, width int) string {
|
||||
var b strings.Builder
|
||||
// Uptime + retries
|
||||
uptimeParts := []string{label.Render("Uptime") + " " + m.fmtUptime(hist.Statuses)}
|
||||
if site.Type != "group" && site.MaxRetries > 0 {
|
||||
uptimeParts = append(uptimeParts, label.Render("Retries")+" "+m.fmtRetries(site))
|
||||
}
|
||||
b.WriteString(" " + strings.Join(uptimeParts, dot) + "\n")
|
||||
|
||||
// Error line
|
||||
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp ||
|
||||
site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
|
||||
errW := innerW
|
||||
if errW < 20 {
|
||||
errW = 20
|
||||
}
|
||||
b.WriteString(" " + label.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
|
||||
// Latency chart
|
||||
if len(hist.Latencies) > 0 {
|
||||
chartW := width - 2
|
||||
if chartW < 20 {
|
||||
chartW = 20
|
||||
}
|
||||
chart := m.latencyChart(hist.Latencies, hist.Statuses, chartW, 3)
|
||||
chart := m.latencyChart(hist.Latencies, hist.Statuses, innerW, 3)
|
||||
if chart != "" {
|
||||
b.WriteString(chart + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// 30d uptime timeline
|
||||
if len(m.detailDailyDays) > 0 && m.detailChangesSiteID == site.ID {
|
||||
timelineW := width - 2
|
||||
if timelineW < 20 {
|
||||
timelineW = 20
|
||||
}
|
||||
b.WriteString(" " + m.st.subtleStyle.Render("30d") + " " + m.uptimeTimeline(m.detailDailyDays, timelineW) + "\n")
|
||||
b.WriteString(" " + label.Render("30d") + " " + m.uptimeTimeline(m.detailDailyDays, innerW) + "\n")
|
||||
}
|
||||
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
// Sparkline + min/avg/max
|
||||
if site.Type != "push" && len(hist.Latencies) > 0 {
|
||||
b.WriteString(" " + m.latencySparkline(hist.Latencies, hist.Statuses, innerW, nil) + "\n")
|
||||
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)
|
||||
fmt.Fprintf(&b, " %s %dms %s %dms %s %dms\n",
|
||||
label.Render("Min"), minL.Milliseconds(),
|
||||
label.Render("Avg"), avg.Milliseconds(),
|
||||
label.Render("Max"), maxL.Milliseconds())
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) detailRightCol(site models.Site, hist monitor.SiteHistory, width int) string {
|
||||
dot := m.st.subtleStyle.Render(" · ")
|
||||
label := m.st.subtleStyle
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
// Line 1: status + latency + last check
|
||||
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
|
||||
parts := []string{status}
|
||||
if site.Latency > 0 {
|
||||
parts = append(parts, m.fmtLatency(site.Latency))
|
||||
}
|
||||
if !site.LastCheck.IsZero() {
|
||||
parts = append(parts, m.fmtTimeAgo(site.LastCheck))
|
||||
}
|
||||
b.WriteString(strings.Join(parts, dot) + "\n")
|
||||
|
||||
// Line 2: type-specific details
|
||||
typeParts := m.detailTypeLine(site)
|
||||
if len(typeParts) > 0 {
|
||||
b.WriteString(strings.Join(typeParts, dot) + "\n")
|
||||
}
|
||||
|
||||
// Line 3: uptime + retries
|
||||
uptimeParts := []string{label.Render("Uptime") + " " + m.fmtUptime(hist.Statuses)}
|
||||
if site.Type != "group" && site.MaxRetries > 0 {
|
||||
uptimeParts = append(uptimeParts, label.Render("Retries")+" "+m.fmtRetries(site))
|
||||
}
|
||||
b.WriteString(strings.Join(uptimeParts, dot) + "\n")
|
||||
|
||||
// Error line (if down/broken)
|
||||
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp ||
|
||||
site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
|
||||
errW := width - 8
|
||||
if errW < 20 {
|
||||
errW = 20
|
||||
}
|
||||
b.WriteString(label.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\n")
|
||||
}
|
||||
|
||||
// Blank line before state changes
|
||||
b.WriteString("\n")
|
||||
|
||||
// State changes (one per line, compact)
|
||||
// State changes
|
||||
var stateChanges []models.StateChange
|
||||
if m.detailChangesSiteID == site.ID {
|
||||
stateChanges = m.detailChanges
|
||||
@@ -145,19 +129,23 @@ func (m Model) detailRightCol(site models.Site, hist monitor.SiteHistory, width
|
||||
arrow := m.st.subtleStyle.Render("→")
|
||||
from := m.fmtStatusWord(sc.FromStatus)
|
||||
to := m.fmtStatusWord(sc.ToStatus)
|
||||
entry := from + " " + arrow + " " + to + " " + m.st.subtleStyle.Render(ago+" ago")
|
||||
entry := from + " " + arrow + " " + to + " " + label.Render(ago+" ago")
|
||||
if sc.ErrorReason != "" {
|
||||
reasonW := width - 30
|
||||
reasonW := innerW - 25
|
||||
if reasonW < 15 {
|
||||
reasonW = 15
|
||||
}
|
||||
entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonW))
|
||||
}
|
||||
b.WriteString(entry + "\n")
|
||||
b.WriteString(" " + entry + "\n")
|
||||
}
|
||||
} else {
|
||||
b.WriteString(" " + label.Render("No state changes") + "\n")
|
||||
}
|
||||
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
b.WriteString("\n " + m.detailKeys() + "\n")
|
||||
|
||||
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
|
||||
}
|
||||
|
||||
func (m Model) detailTypeLine(site models.Site) []string {
|
||||
@@ -224,81 +212,8 @@ func (m Model) detailTypeLine(site models.Site) []string {
|
||||
return parts
|
||||
}
|
||||
|
||||
// viewDetailSingleCol is the narrow-terminal fallback (original stacked layout).
|
||||
func (m Model) viewDetailSingleCol(site models.Site, hist monitor.SiteHistory, width int) string {
|
||||
var b strings.Builder
|
||||
dot := m.st.subtleStyle.Render(" · ")
|
||||
|
||||
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
|
||||
parts := []string{status}
|
||||
if site.Latency > 0 {
|
||||
parts = append(parts, m.fmtLatency(site.Latency))
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("Uptime %s", m.fmtUptime(hist.Statuses)))
|
||||
if !site.LastCheck.IsZero() {
|
||||
parts = append(parts, m.fmtTimeAgo(site.LastCheck))
|
||||
}
|
||||
b.WriteString(" " + strings.Join(parts, dot) + "\n")
|
||||
|
||||
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp ||
|
||||
site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
|
||||
errW := width - 12
|
||||
if errW < 20 {
|
||||
errW = 20
|
||||
}
|
||||
b.WriteString(" " + m.st.subtleStyle.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\n")
|
||||
}
|
||||
|
||||
var stateChanges []models.StateChange
|
||||
if m.detailChangesSiteID == site.ID {
|
||||
stateChanges = m.detailChanges
|
||||
}
|
||||
if len(stateChanges) > 0 {
|
||||
limit := 3
|
||||
if len(stateChanges) < limit {
|
||||
limit = len(stateChanges)
|
||||
}
|
||||
var scParts []string
|
||||
for _, sc := range stateChanges[:limit] {
|
||||
ago := fmtDuration(time.Since(sc.ChangedAt))
|
||||
arrow := m.st.subtleStyle.Render("→")
|
||||
from := m.fmtStatusWord(sc.FromStatus)
|
||||
to := m.fmtStatusWord(sc.ToStatus)
|
||||
entry := from + " " + arrow + " " + to + " " + m.st.subtleStyle.Render(ago+" ago")
|
||||
if sc.ErrorReason != "" {
|
||||
entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, 30))
|
||||
}
|
||||
scParts = append(scParts, entry)
|
||||
}
|
||||
b.WriteString(" " + strings.Join(scParts, dot) + "\n")
|
||||
}
|
||||
|
||||
if len(hist.Latencies) > 0 {
|
||||
chartW := width - 4
|
||||
if chartW < 20 {
|
||||
chartW = 20
|
||||
}
|
||||
chart := m.latencyChart(hist.Latencies, hist.Statuses, chartW, 3)
|
||||
if chart != "" {
|
||||
b.WriteString(chart + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
b.WriteString(" " + m.detailKeys() + "\n")
|
||||
|
||||
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
|
||||
}
|
||||
|
||||
func (m Model) detailKeys() string {
|
||||
return m.st.subtleStyle.Render("[e] Edit [h] History [s] SLA [q/Esc] Back")
|
||||
return m.st.subtleStyle.Render("[e] Edit [h] History [s] SLA [Esc] Back")
|
||||
}
|
||||
|
||||
func (m Model) fmtStatusWord(status string) string {
|
||||
@@ -319,3 +234,141 @@ func (m Model) fmtStatusWord(status string) string {
|
||||
return m.st.subtleStyle.Render(status)
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) viewSLASidebar(width int) string {
|
||||
var b strings.Builder
|
||||
label := m.st.subtleStyle
|
||||
innerW := width - 4
|
||||
if innerW < 20 {
|
||||
innerW = 20
|
||||
}
|
||||
|
||||
period := slaPeriods[m.slaPeriodIdx]
|
||||
b.WriteString(" " + label.Render("Period: Last "+period.label) + "\n\n")
|
||||
|
||||
r := m.slaReport
|
||||
barWidth := innerW - 25
|
||||
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, " %-14s %s %s\n", label.Render("Uptime"), uptimeColor.Render(fmtPct(r.UptimePct)+"%"), bar)
|
||||
fmt.Fprintf(&b, " %-14s %s\n", label.Render("Downtime"), fmtDuration(r.Downtime))
|
||||
fmt.Fprintf(&b, " %-14s %d\n", label.Render("Outages"), r.OutageCount)
|
||||
|
||||
if r.OutageCount > 0 {
|
||||
fmt.Fprintf(&b, " %-14s %s\n", label.Render("Longest"), fmtDuration(r.LongestOut))
|
||||
fmt.Fprintf(&b, " %-14s %s\n", label.Render("MTTR"), fmtDuration(r.MTTR))
|
||||
fmt.Fprintf(&b, " %-14s %s\n", label.Render("MTBF"), fmtDuration(r.MTBF))
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
|
||||
if len(m.slaDailyBreakdown) > 0 {
|
||||
b.WriteString(" " + m.st.titleStyle.Render("DAILY BREAKDOWN") + "\n")
|
||||
dayBarW := innerW - 20
|
||||
if dayBarW < 10 {
|
||||
dayBarW = 10
|
||||
}
|
||||
for _, day := range m.slaDailyBreakdown {
|
||||
dateStr := day.Date.Format("Jan 02")
|
||||
dayBar := m.uptimeBar(day.UptimePct, dayBarW)
|
||||
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", label.Render(dateStr), dayBar, color.Render(pctStr))
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
|
||||
var keys []string
|
||||
for i, p := range slaPeriods {
|
||||
k := fmt.Sprintf("[%s] %s", p.key, p.label)
|
||||
if i == m.slaPeriodIdx {
|
||||
keys = append(keys, m.st.titleStyle.Render(k))
|
||||
} else {
|
||||
keys = append(keys, label.Render(k))
|
||||
}
|
||||
}
|
||||
b.WriteString(" " + strings.Join(keys, " ") + "\n")
|
||||
b.WriteString(" " + label.Render("[Esc] Back") + "\n")
|
||||
|
||||
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
|
||||
}
|
||||
|
||||
func (m Model) viewHistorySidebar(width int) string {
|
||||
var b strings.Builder
|
||||
label := m.st.subtleStyle
|
||||
innerW := width - 4
|
||||
if innerW < 20 {
|
||||
innerW = 20
|
||||
}
|
||||
|
||||
sparkline := m.stateChangeSparkline(m.historyChanges, innerW)
|
||||
if sparkline != "" {
|
||||
b.WriteString(" " + sparkline + "\n\n")
|
||||
}
|
||||
|
||||
if len(m.historyChanges) == 0 {
|
||||
b.WriteString(" " + label.Render("No state changes recorded") + "\n")
|
||||
} else {
|
||||
reasonW := innerW - 45
|
||||
if reasonW < 10 {
|
||||
reasonW = 10
|
||||
}
|
||||
for i, sc := range m.historyChanges {
|
||||
ts := sc.ChangedAt.Format("01/02 15:04")
|
||||
|
||||
arrow := label.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(fmtDuration(dur))
|
||||
}
|
||||
|
||||
reason := ""
|
||||
if sc.ErrorReason != "" && sc.ToStatus != string(models.StatusUp) {
|
||||
reason = m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonW))
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, " %-12s %s %s %s\n", ts, arrow, durStr, reason)
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
|
||||
stats := computeHistoryStats(m.historyChanges)
|
||||
statParts := []string{fmt.Sprintf("%d events", stats.totalEvents)}
|
||||
if stats.outageCount > 0 {
|
||||
statParts = append(statParts, fmt.Sprintf("%d outages", stats.outageCount))
|
||||
avg := stats.totalDowntime / time.Duration(stats.outageCount)
|
||||
statParts = append(statParts, "avg "+fmtDuration(avg))
|
||||
}
|
||||
b.WriteString(" " + label.Render(strings.Join(statParts, " │ ")) + "\n")
|
||||
b.WriteString(" " + label.Render("[Esc] Back") + "\n")
|
||||
|
||||
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type historyStats struct {
|
||||
@@ -105,87 +103,3 @@ func (m Model) stateChangeSparkline(changes []models.StateChange, width int) str
|
||||
}
|
||||
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"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var slaPeriods = []struct {
|
||||
@@ -21,91 +19,6 @@ var slaPeriods = []struct {
|
||||
{"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 {
|
||||
filled := int(math.Round(pct / 100 * float64(width)))
|
||||
if filled > width {
|
||||
|
||||
Reference in New Issue
Block a user