feat(tui): fullscreen detail view + enriched sidebar #171

Merged
lerko merged 2 commits from feat/detail-fullscreen-enrich into main 2026-07-01 20:55:04 +00:00
6 changed files with 448 additions and 17 deletions
Showing only changes of commit 989dd1fb39 - Show all commits
+89
View File
@@ -0,0 +1,89 @@
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()
}
+11 -10
View File
@@ -119,16 +119,17 @@ const (
type sessionState int
const (
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
stateDashboard sessionState = 0
stateLogs sessionState = 1
stateDetailFullscreen sessionState = 2
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 {
+82 -2
View File
@@ -19,7 +19,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tabDataMsg:
return m.handleTabData(msg)
case detailDataMsg:
if m.detailOpen && m.cursor < len(m.sites) && m.sites[m.cursor].ID != msg.siteID {
detailVisible := m.detailOpen || m.state == stateDetailFullscreen
if detailVisible && m.cursor < len(m.sites) && m.sites[m.cursor].ID != msg.siteID {
return m, nil
}
m.detailChanges = msg.changes
@@ -51,6 +52,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.state == stateLogs {
return m.handleLogsFullscreen(msg)
}
if m.state == stateDetailFullscreen {
return m.handleDetailFullscreen(msg)
}
switch msg := msg.(type) {
case tea.MouseMsg:
@@ -203,7 +207,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.detailOpen || m.cursor >= len(m.sites) {
if (!m.detailOpen && m.state != stateDetailFullscreen) || m.cursor >= len(m.sites) {
return nil
}
return m.loadDetailCmd(m.sites[m.cursor].ID)
@@ -278,6 +282,75 @@ func (m *Model) handleLogsFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m *Model) handleDetailFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc", "q":
m.state = stateDashboard
m.focusedPanel = panelMonitors
case "ctrl+c":
return m, tea.Quit
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.detailMode = detailHistory
m.detailScrollOffset = 0
return m, m.loadHistoryCmd(site.ID)
}
case "s":
if m.cursor < len(m.sites) {
site := m.sites[m.cursor]
m.slaSiteName = site.Name
m.slaSiteID = site.ID
m.slaPeriodIdx = 2
m.detailMode = detailSLA
m.detailScrollOffset = 0
return m, m.loadSLACmd(site.ID, m.slaPeriodIdx)
}
case "1", "2", "3", "4":
if m.detailMode == detailSLA {
idx := int(msg.String()[0]-'0') - 1
if idx >= 0 && idx < len(slaPeriods) {
m.slaPeriodIdx = idx
m.detailScrollOffset = 0
return m, m.loadSLACmd(m.slaSiteID, idx)
}
}
case "up", "k":
m.detailScrollOffset--
if m.detailScrollOffset < 0 {
m.detailScrollOffset = 0
}
case "down", "j":
m.detailScrollOffset++
case "pgup":
m.detailScrollOffset -= 10
if m.detailScrollOffset < 0 {
m.detailScrollOffset = 0
}
case "pgdown":
m.detailScrollOffset += 10
}
case tea.MouseMsg:
switch msg.Button {
case tea.MouseButtonWheelUp:
m.detailScrollOffset -= 3
if m.detailScrollOffset < 0 {
m.detailScrollOffset = 0
}
case tea.MouseButtonWheelDown:
m.detailScrollOffset += 3
}
}
return m, nil
}
func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.state != stateDashboard {
return m, nil
@@ -673,6 +746,13 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
if len(m.sites) > 0 {
narrow := m.termWidth < wideBreakpoint
if narrow {
m.detailMode = detailDefault
m.detailScrollOffset = 0
m.state = stateDetailFullscreen
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
m.detailOpen = !m.detailOpen
m.detailMode = detailDefault
m.detailScrollOffset = 0
+64
View File
@@ -300,6 +300,70 @@ func TestWriteDoneMsg_LogsErrorAndReloads(t *testing.T) {
}
}
func TestEnterNarrowTerminal_OpensFullscreen(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "UP"}}}
m.termWidth = 80 // narrow, below wideBreakpoint (120)
m.termHeight = 40
m.focusedPanel = panelMonitors
updated, cmd := (&m).handleDashboardKey(keyMsg("enter"))
got := updated.(*Model)
if got.state != stateDetailFullscreen {
t.Fatalf("expected stateDetailFullscreen, got %d", got.state)
}
if cmd == nil {
t.Fatal("expected a detail load Cmd")
}
}
func TestEnterWideTerminal_TogglesSidebar(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "UP"}}}
m.termWidth = 140 // wide, above wideBreakpoint (120)
m.termHeight = 40
m.focusedPanel = panelMonitors
updated, _ := (&m).handleDashboardKey(keyMsg("enter"))
got := updated.(*Model)
if got.state != stateDashboard {
t.Fatalf("expected stateDashboard, got %d", got.state)
}
if !got.detailOpen {
t.Fatal("expected detailOpen to be true")
}
}
func TestDetailFullscreen_EscReturnsToDashboard(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.state = stateDetailFullscreen
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}}}
updated, _ := (&m).handleDetailFullscreen(tea.KeyMsg{Type: tea.KeyEsc})
got := updated.(*Model)
if got.state != stateDashboard {
t.Fatalf("expected stateDashboard after Esc, got %d", got.state)
}
if got.focusedPanel != panelMonitors {
t.Fatalf("expected panelMonitors focus, got %d", got.focusedPanel)
}
}
func TestDetailRefreshCmd_FiresForFullscreen(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 5, Name: "site"}}}
m.state = stateDetailFullscreen
m.detailOpen = false
cmd := (&m).detailRefreshCmd()
if cmd == nil {
t.Fatal("detailRefreshCmd should fire for stateDetailFullscreen")
}
}
func TestDetailRefreshCmd_OnlyWhileDetailOpen(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
+2
View File
@@ -92,6 +92,8 @@ func (m Model) View() string {
return ""
case stateLogs:
return m.viewLogsFullscreen()
case stateDetailFullscreen:
return m.viewDetailFullscreen()
case stateAlertDetail:
return m.viewAlertDetailPanel()
case stateSettings:
+200 -5
View File
@@ -2,6 +2,7 @@ package tui
import (
"fmt"
"sort"
"strings"
"time"
@@ -22,20 +23,90 @@ func (m Model) viewDetailInline(width, height int) string {
default:
site := m.sites[m.cursor]
hist, _ := m.engine.GetHistory(site.ID)
return m.viewDetailSidebar(site, hist, width, height)
return m.buildDetailContent(site, hist, width, false)
}
}
func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, width, _ int) string {
func (m Model) viewDetailFullscreen() string {
if m.cursor >= len(m.sites) {
return ""
}
totalW := m.termWidth - chromePadH
site := m.sites[m.cursor]
var title string
switch m.detailMode {
case detailSLA:
title = "SLA · " + site.Name
case detailHistory:
title = "History · " + site.Name
default:
title = site.Name
}
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(title)
}
header := " " + breadcrumb + "\n" + m.divider()
var content string
switch m.detailMode {
case detailSLA:
content = m.viewSLASidebar(totalW, 0)
case detailHistory:
content = m.viewHistorySidebar(totalW, 0)
default:
hist, _ := m.engine.GetHistory(site.ID)
content = m.buildDetailContent(site, hist, totalW, true)
}
footer := m.divider() + "\n" + m.detailFooter(totalW)
contentLines := strings.Split(content, "\n")
contentH := m.termHeight - 8
if contentH < 5 {
contentH = 5
}
if m.detailScrollOffset > len(contentLines)-contentH {
m.detailScrollOffset = len(contentLines) - contentH
}
if m.detailScrollOffset < 0 {
m.detailScrollOffset = 0
}
end := m.detailScrollOffset + contentH
if end > len(contentLines) {
end = len(contentLines)
}
visible := strings.Join(contentLines[m.detailScrollOffset:end], "\n")
return lipgloss.NewStyle().Padding(1, 2).Render(
header + "\n" + visible + "\n" + footer)
}
func (m Model) buildDetailContent(site models.Site, hist monitor.SiteHistory, width int, fullscreen bool) string {
dot := m.st.subtleStyle.Render(" · ")
label := m.st.subtleStyle
var b strings.Builder
innerW := width - 4
if innerW < 20 {
innerW = 20
}
// Status + latency + last check
var b strings.Builder
// Status + latency + last check + state since
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
statusParts := []string{status}
if site.Latency > 0 {
@@ -44,6 +115,10 @@ func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, wid
if !site.LastCheck.IsZero() {
statusParts = append(statusParts, m.fmtTimeAgo(site.LastCheck))
}
if !site.StatusChangedAt.IsZero() {
dur := time.Since(site.StatusChangedAt)
statusParts = append(statusParts, label.Render("for")+" "+fmtDuration(dur))
}
b.WriteString(" " + strings.Join(statusParts, dot) + "\n")
// Type-specific details
@@ -52,7 +127,10 @@ func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, wid
b.WriteString(" " + strings.Join(typeParts, dot) + "\n")
}
// Uptime + retries
// Extended endpoint fields
m.writeEndpointFields(&b, site, label, innerW, fullscreen)
// Uptime + retries + last success
uptimeStr := m.fmtUptime(hist.Statuses)
if m.isMonitorInMaintenance(site.ID) {
uptimeStr = m.st.subtleStyle.Render("—")
@@ -61,8 +139,21 @@ func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, wid
if site.Type != "group" && site.MaxRetries > 0 {
uptimeParts = append(uptimeParts, label.Render("Retries")+" "+m.fmtRetries(site))
}
if site.Type != "push" && !site.LastSuccessAt.IsZero() {
uptimeParts = append(uptimeParts, label.Render("Last OK")+" "+m.fmtTimeAgo(site.LastSuccessAt))
}
b.WriteString(" " + strings.Join(uptimeParts, dot) + "\n")
// Maintenance window name
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) {
b.WriteString(" " + label.Render("Maint") + " " + m.st.maintStyle.Render(mw.Title) + "\n")
break
}
}
}
// Error line
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp ||
site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
@@ -73,8 +164,62 @@ func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, wid
b.WriteString(" " + label.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\n")
}
// Connection chain
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")
}
}
}
b.WriteString("\n")
// 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)
for _, nodeID := range nodeIDs {
result := probeResults[nodeID]
probeStatus := m.st.specialStyle.Render("UP")
if !result.IsUp {
probeStatus = 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, probeStatus, latency, ago)
if !result.IsUp && result.ErrorReason != "" {
line += " " + m.st.dangerStyle.Render(result.ErrorReason)
}
b.WriteString(line + "\n")
}
b.WriteString("\n")
}
// Latency chart
if len(hist.Latencies) > 0 {
chart := m.latencyChart(hist.Latencies, hist.Statuses, innerW, 3)
@@ -116,6 +261,15 @@ func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, wid
}
}
// Latency histogram
if site.Type != "push" && len(hist.Latencies) > 5 {
histContent := m.latencyHistogram(hist.Latencies, hist.Statuses, innerW)
if histContent != "" {
b.WriteString("\n")
b.WriteString(histContent)
}
}
b.WriteString("\n")
// State changes
@@ -150,6 +304,47 @@ func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, wid
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
}
func (m Model) writeEndpointFields(b *strings.Builder, site models.Site, label lipgloss.Style, innerW int, fullscreen bool) {
dot := m.st.subtleStyle.Render(" · ")
var fields []string
if site.Interval > 0 {
fields = append(fields, label.Render("Every")+" "+fmt.Sprintf("%ds", site.Interval))
}
if site.Timeout > 0 {
fields = append(fields, label.Render("Timeout")+" "+fmt.Sprintf("%ds", site.Timeout))
}
if site.Type == "http" && site.Method != "" && site.Method != "GET" {
fields = append(fields, label.Render("Method")+" "+site.Method)
}
if site.Type == "http" {
codes := site.AcceptedCodes
if codes == "" {
codes = "200-299"
}
fields = append(fields, label.Render("Codes")+" "+codes)
}
if site.Regions != "" {
fields = append(fields, label.Render("Regions")+" "+site.Regions)
}
if len(fields) > 0 {
b.WriteString(" " + strings.Join(fields, dot) + "\n")
}
if site.Description != "" {
maxDescW := innerW
if !fullscreen && maxDescW > 60 {
maxDescW = 60
}
b.WriteString(" " + label.Render(limitStr(site.Description, maxDescW)) + "\n")
}
if site.Type == "push" && site.Token != "" {
b.WriteString(" " + label.Render("Token") + " " + site.Token + "\n")
}
}
func (m Model) detailTypeLine(site models.Site) []string {
label := m.st.subtleStyle
var parts []string