feat(tui): SLA and history as sidebar detail modes
SLA and History views now render inside the detail sidebar panel instead of taking over the full screen. Press s/h to switch modes, Esc to return to default detail view. Cursor movement resets to default mode. - Add detailMode state (detailDefault/detailSLA/detailHistory) - SLA sidebar: period selector, uptime bars, daily breakdown - History sidebar: sparkline density, transition list, stats - Panel title shows "SLA · name" or "History · name" in sub-modes - Period keys [1]-[4] work in SLA mode from dashboard state
This commit is contained in:
@@ -97,6 +97,12 @@ const (
|
|||||||
panelMaint = 3
|
panelMaint = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
detailDefault = 0
|
||||||
|
detailSLA = 1
|
||||||
|
detailHistory = 2
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
sortStatus = 0
|
sortStatus = 0
|
||||||
sortName = 1
|
sortName = 1
|
||||||
@@ -138,6 +144,7 @@ type Model struct {
|
|||||||
termHeight int
|
termHeight int
|
||||||
contentWidth int
|
contentWidth int
|
||||||
focusedPanel int
|
focusedPanel int
|
||||||
|
detailMode int
|
||||||
logScrollOffset int
|
logScrollOffset int
|
||||||
editID int
|
editID int
|
||||||
editToken string
|
editToken string
|
||||||
|
|||||||
+20
-7
@@ -771,6 +771,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.syncSelectedID()
|
m.syncSelectedID()
|
||||||
if m.detailOpen && m.cursor < len(m.sites) {
|
if m.detailOpen && m.cursor < len(m.sites) {
|
||||||
|
m.detailMode = detailDefault
|
||||||
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -791,6 +792,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.syncSelectedID()
|
m.syncSelectedID()
|
||||||
if m.detailOpen && m.cursor < len(m.sites) {
|
if m.detailOpen && m.cursor < len(m.sites) {
|
||||||
|
m.detailMode = detailDefault
|
||||||
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -870,8 +872,11 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "esc":
|
case "esc":
|
||||||
if m.focusedPanel != panelMonitors {
|
if m.focusedPanel != panelMonitors {
|
||||||
m.focusedPanel = panelMonitors
|
m.focusedPanel = panelMonitors
|
||||||
|
} else if m.detailOpen && m.detailMode != detailDefault {
|
||||||
|
m.detailMode = detailDefault
|
||||||
} else if m.detailOpen {
|
} else if m.detailOpen {
|
||||||
m.detailOpen = false
|
m.detailOpen = false
|
||||||
|
m.detailMode = detailDefault
|
||||||
m.recalcLayout()
|
m.recalcLayout()
|
||||||
st := m.store
|
st := m.store
|
||||||
ctx := m.ctx
|
ctx := m.ctx
|
||||||
@@ -885,17 +890,25 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.historySiteName = site.Name
|
m.historySiteName = site.Name
|
||||||
m.historySiteID = site.ID
|
m.historySiteID = site.ID
|
||||||
m.historyChanges = nil
|
m.historyChanges = nil
|
||||||
m.historyViewport = viewport.New(
|
m.detailMode = detailHistory
|
||||||
m.termWidth-chromePadH,
|
|
||||||
m.termHeight-10,
|
|
||||||
)
|
|
||||||
m.historyViewport.SetContent("\n Loading state history...")
|
|
||||||
m.state = stateHistory
|
|
||||||
return m, m.loadHistoryCmd(site.ID)
|
return m, m.loadHistoryCmd(site.ID)
|
||||||
}
|
}
|
||||||
case "s":
|
case "s":
|
||||||
if m.detailOpen && m.cursor < len(m.sites) {
|
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":
|
case "x":
|
||||||
if m.focusedPanel == panelMaint {
|
if m.focusedPanel == panelMaint {
|
||||||
|
|||||||
@@ -186,12 +186,18 @@ func (m Model) viewMonitorsLayout() string {
|
|||||||
}
|
}
|
||||||
topParts = append(topParts, monPanel)
|
topParts = append(topParts, monPanel)
|
||||||
if showDetail {
|
if showDetail {
|
||||||
site := ""
|
title := ""
|
||||||
if m.cursor < len(m.sites) {
|
if m.cursor < len(m.sites) {
|
||||||
site = m.sites[m.cursor].Name
|
title = m.sites[m.cursor].Name
|
||||||
|
}
|
||||||
|
switch m.detailMode {
|
||||||
|
case detailSLA:
|
||||||
|
title = "SLA · " + title
|
||||||
|
case detailHistory:
|
||||||
|
title = "History · " + title
|
||||||
}
|
}
|
||||||
detail := m.viewDetailInline(detailW - 2)
|
detail := m.viewDetailInline(detailW - 2)
|
||||||
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, detailW, m.focusedPanel == panelDetail))
|
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(title, detail, detailW, m.focusedPanel == panelDetail))
|
||||||
topParts = append(topParts, detailPanel)
|
topParts = append(topParts, detailPanel)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +285,10 @@ func (m Model) renderFooter(_ dashboardStats) string {
|
|||||||
var keys string
|
var keys string
|
||||||
if m.focusedPanel == panelMaint {
|
if m.focusedPanel == panelMaint {
|
||||||
keys = "[n]New [x]End [d]Del [Esc]Back [S]Settings [T]Theme [q]Quit"
|
keys = "[n]New [x]End [d]Del [Esc]Back [S]Settings [T]Theme [q]Quit"
|
||||||
|
} else if m.detailOpen && m.detailMode == detailSLA {
|
||||||
|
keys = "[1-4]Period [Esc]Back [S]Settings [T]Theme [q]Quit"
|
||||||
|
} else if m.detailOpen && m.detailMode == detailHistory {
|
||||||
|
keys = "[Esc]Back [S]Settings [T]Theme [q]Quit"
|
||||||
} else if m.detailOpen {
|
} 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 [Enter]Expand [h]History [s]SLA [e]Edit [m]Maint [l]Logs [S]Settings [T]Theme [q]Quit"
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -14,9 +14,16 @@ func (m Model) viewDetailInline(width int) string {
|
|||||||
if m.cursor >= len(m.sites) {
|
if m.cursor >= len(m.sites) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
site := m.sites[m.cursor]
|
switch m.detailMode {
|
||||||
hist, _ := m.engine.GetHistory(site.ID)
|
case detailSLA:
|
||||||
return m.viewDetailSidebar(site, hist, width)
|
return m.viewSLASidebar(width)
|
||||||
|
case detailHistory:
|
||||||
|
return m.viewHistorySidebar(width)
|
||||||
|
default:
|
||||||
|
site := m.sites[m.cursor]
|
||||||
|
hist, _ := m.engine.GetHistory(site.ID)
|
||||||
|
return m.viewDetailSidebar(site, hist, width)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, width int) string {
|
func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, width int) string {
|
||||||
@@ -206,7 +213,7 @@ func (m Model) detailTypeLine(site models.Site) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) detailKeys() 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 {
|
func (m Model) fmtStatusWord(status string) string {
|
||||||
@@ -227,3 +234,141 @@ func (m Model) fmtStatusWord(status string) string {
|
|||||||
return m.st.subtleStyle.Render(status)
|
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())
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user