feat(tui): inline detail panel below monitors table
Press i to toggle a compact detail panel below the monitors+logs split. Shows status, latency, uptime, state changes, sparkline, and key hints in ~6 lines. Auto-updates when cursor moves between monitors. h/s/e keys work from the inline detail for history, SLA, and edit. Escape closes the panel. No more full-screen detail takeover for the common case. The old stateDetail path remains for h/s sub-views which still go full-screen.
This commit is contained in:
+1
-1
@@ -179,7 +179,7 @@ type Model struct {
|
||||
lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle)
|
||||
tabSeq int // seq of the newest issued tab-data load
|
||||
|
||||
// detail-panel state-change history, loaded on enter so View does no DB IO
|
||||
detailOpen bool
|
||||
detailChanges []models.StateChange
|
||||
detailChangesSiteID int
|
||||
|
||||
|
||||
+33
-3
@@ -517,7 +517,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
case "tab":
|
||||
m.switchTab(m.currentTab + 1)
|
||||
case "left", "h":
|
||||
case "left":
|
||||
if m.currentTab == tabSettings {
|
||||
m.switchSettingsSection(m.settingsSection - 1)
|
||||
}
|
||||
@@ -532,6 +532,9 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.tableOffset = m.cursor
|
||||
}
|
||||
m.syncSelectedID()
|
||||
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
||||
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||
}
|
||||
}
|
||||
case "down", "j":
|
||||
max := m.currentListLen() - 1
|
||||
@@ -541,6 +544,9 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.tableOffset++
|
||||
}
|
||||
m.syncSelectedID()
|
||||
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
||||
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||
}
|
||||
}
|
||||
case "n":
|
||||
return m.handleNewItem()
|
||||
@@ -574,11 +580,35 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
case "i":
|
||||
if m.currentTab == tabMonitors && len(m.sites) > 0 {
|
||||
m.state = stateDetail
|
||||
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||
m.detailOpen = !m.detailOpen
|
||||
if m.detailOpen {
|
||||
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||
}
|
||||
} else if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 {
|
||||
m.state = stateAlertDetail
|
||||
}
|
||||
case "esc":
|
||||
if m.currentTab == tabMonitors && m.detailOpen {
|
||||
m.detailOpen = false
|
||||
}
|
||||
case "h":
|
||||
if m.detailOpen && m.currentTab == tabMonitors && 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.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
||||
return m, m.openSLAView(m.sites[m.cursor])
|
||||
}
|
||||
case "x":
|
||||
if m.currentTab == tabMaint && len(m.maintenanceWindows) > 0 {
|
||||
mw := m.maintenanceWindows[m.cursor]
|
||||
|
||||
@@ -162,10 +162,22 @@ func (m Model) viewDashboard() string {
|
||||
left := lipgloss.NewStyle().Width(leftW).Render(monitors)
|
||||
sidebar := m.viewLogsSidebar(rightW)
|
||||
right := lipgloss.NewStyle().Width(rightW).Render(sidebar)
|
||||
content = lipgloss.JoinHorizontal(lipgloss.Top, left, right)
|
||||
top := lipgloss.JoinHorizontal(lipgloss.Top, left, right)
|
||||
if m.detailOpen {
|
||||
detail := m.viewDetailInline(availW)
|
||||
content = top + "\n" + detail
|
||||
} else {
|
||||
content = top
|
||||
}
|
||||
} else {
|
||||
m.contentWidth = m.termWidth
|
||||
content = m.viewSitesTab()
|
||||
monitors := m.viewSitesTab()
|
||||
if m.detailOpen {
|
||||
detail := m.viewDetailInline(m.termWidth - chromePadH)
|
||||
content = monitors + "\n" + detail
|
||||
} else {
|
||||
content = monitors
|
||||
}
|
||||
}
|
||||
case tabMaint:
|
||||
m.contentWidth = m.termWidth
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func (m Model) viewDetailInline(width int) string {
|
||||
if m.cursor >= len(m.sites) {
|
||||
return ""
|
||||
}
|
||||
site := m.sites[m.cursor]
|
||||
hist, _ := m.engine.GetHistory(site.ID)
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
title := m.st.titleStyle.Render(site.Name)
|
||||
b.WriteString(" " + title + "\n")
|
||||
|
||||
divW := width - 4
|
||||
if divW < 20 {
|
||||
divW = 20
|
||||
}
|
||||
b.WriteString(" " + m.st.subtleStyle.Render(strings.Repeat("─", divW)) + "\n")
|
||||
|
||||
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
|
||||
latency := m.fmtLatency(site.Latency)
|
||||
uptime := m.fmtUptime(hist.Statuses)
|
||||
|
||||
line1Parts := []string{status}
|
||||
if site.Latency > 0 {
|
||||
line1Parts = append(line1Parts, latency)
|
||||
}
|
||||
line1Parts = append(line1Parts, fmt.Sprintf("Uptime %s", uptime))
|
||||
if !site.LastCheck.IsZero() {
|
||||
line1Parts = append(line1Parts, fmt.Sprintf("Checked %s", m.fmtTimeAgo(site.LastCheck)))
|
||||
}
|
||||
b.WriteString(" " + strings.Join(line1Parts, m.st.subtleStyle.Render(" · ")) + "\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
|
||||
}
|
||||
errMsg := limitStr(site.LastError, errW)
|
||||
b.WriteString(" " + m.st.subtleStyle.Render("Error") + " " + m.st.dangerStyle.Render(errMsg) + "\n")
|
||||
}
|
||||
|
||||
var stateChanges []models.StateChange
|
||||
if m.detailChangesSiteID == site.ID {
|
||||
stateChanges = m.detailChanges
|
||||
}
|
||||
if len(stateChanges) > 0 {
|
||||
var parts []string
|
||||
limit := 3
|
||||
if len(stateChanges) < limit {
|
||||
limit = len(stateChanges)
|
||||
}
|
||||
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))
|
||||
}
|
||||
parts = append(parts, entry)
|
||||
}
|
||||
b.WriteString(" " + strings.Join(parts, m.st.subtleStyle.Render(" · ")) + "\n")
|
||||
}
|
||||
|
||||
if len(hist.Latencies) > 0 {
|
||||
sparkW := width - 30
|
||||
if sparkW < 10 {
|
||||
sparkW = 10
|
||||
}
|
||||
if sparkW > detailSparkWidth {
|
||||
sparkW = detailSparkWidth
|
||||
}
|
||||
spark := m.latencySparkline(hist.Latencies, hist.Statuses, sparkW, m.theme.Bg)
|
||||
minMs := hist.Latencies[0].Milliseconds()
|
||||
maxMs := hist.Latencies[0].Milliseconds()
|
||||
var sumMs int64
|
||||
for _, l := range hist.Latencies {
|
||||
ms := l.Milliseconds()
|
||||
if ms < minMs {
|
||||
minMs = ms
|
||||
}
|
||||
if ms > maxMs {
|
||||
maxMs = ms
|
||||
}
|
||||
sumMs += ms
|
||||
}
|
||||
avgMs := sumMs / int64(len(hist.Latencies))
|
||||
stats := fmt.Sprintf("Min %s Avg %s Max %s",
|
||||
m.fmtLatency(time.Duration(minMs)*time.Millisecond),
|
||||
m.fmtLatency(time.Duration(avgMs)*time.Millisecond),
|
||||
m.fmtLatency(time.Duration(maxMs)*time.Millisecond))
|
||||
b.WriteString(" " + spark + " " + stats + "\n")
|
||||
}
|
||||
|
||||
keys := m.st.subtleStyle.Render("[h] History [s] SLA [e] Edit [esc] Close")
|
||||
b.WriteString(" " + keys + "\n")
|
||||
|
||||
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
|
||||
}
|
||||
|
||||
func (m Model) fmtStatusWord(status string) string {
|
||||
switch status {
|
||||
case "DOWN":
|
||||
return m.st.dangerStyle.Render("DOWN")
|
||||
case "UP":
|
||||
return m.st.specialStyle.Render("UP")
|
||||
default:
|
||||
return m.st.subtleStyle.Render(status)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user