7 Commits

Author SHA1 Message Date
lerko 1cfa0571c8 feat(tui): improve scrollbar visibility and extend to all panels
CI / test (pull_request) Successful in 1m46s
CI / lint (pull_request) Successful in 1m16s
CI / vulncheck (pull_request) Successful in 56s
Use block characters (█/░) with accent coloring for scrollbar thumb/track
instead of box-drawing chars that blend with panel borders. Auto-derive
scrollbar from content overflow in titledPanelH. Add scrollbar to logs
and maintenance bottom panels.
2026-07-02 11:09:07 -04:00
lerko d6ba7d9af8 feat(tui): add scrollbar gutter to titledPanelH
Opt-in scrollbar track on the right border edge when totalItems > bodyH.
Thin track (│) with muted thumb (┃) showing viewport position.

Monitors panel passes len(sites) to enable it. Detail and fullscreen
panels pass 0 to opt out. Any panel can opt in via the totalItems param.
2026-07-01 21:51:07 -04:00
lerko 14cec4283d feat(tui): persist bottom panel preference across restarts
Save bottom_panel pref (logs/maint/none) to store on toggle.
Restore on startup via InitialModel, same pattern as detail_open.
2026-07-01 21:02:45 -04:00
lerko a32a443a4a perf(tui): precompute maintenance set, eliminate redundant GetAllSites
CI / test (pull_request) Successful in 1m45s
CI / lint (pull_request) Successful in 1m16s
CI / vulncheck (pull_request) Successful in 46s
Replace O(windows × sites) isMonitorInMaintenance with O(1) map lookup.
buildMaintSet runs once per refreshLive/handleTabData, not per call site.

groupSparkline/groupUptime now use m.sites (already on model) instead of
calling engine.GetAllSites() which copies the full map under a mutex.
2026-07-01 19:37:27 -04:00
lerko b90033c7f0 fix(tui): use titledPanelH chrome for fullscreen detail view
CI / test (pull_request) Successful in 1m52s
CI / lint (pull_request) Successful in 1m22s
CI / vulncheck (pull_request) Successful in 56s
Match the sidebar's bordered panel style (╭─ Title ─╮) so the
fullscreen detail view on narrow terminals looks cohesive with the
rest of the UI. Breadcrumb now renders as the panel title.
2026-07-01 16:22:49 -04:00
lerko 989dd1fb39 feat(tui): fullscreen detail view + enriched sidebar content
On narrow terminals (<120 cols), Enter opens a fullscreen detail view
instead of the sidebar that can't be seen. Wide terminals keep existing
sidebar toggle behavior.

Enrich detail panel with: state since, last success, connection chain
diagnostic, probe results, latency histogram, extended endpoint fields
(interval/timeout/method/codes/regions/description), push token,
maintenance window name. Breadcrumb shown in fullscreen mode only.
2026-07-01 16:12:45 -04:00
lerko faf7d36c64 refactor(tui): replace # column with colored status dot
CI / test (pull_request) Successful in 2m7s
CI / lint (pull_request) Successful in 1m27s
CI / vulncheck (pull_request) Successful in 1m1s
Drop the row-number column and add a colored status dot as the first
column. Dot uses the same icon/color as the existing status indicators
(▲ green for up, ▼ red for down, ◼ purple for maint, etc). Matches
the maint strip visual pattern. Status text column retained for
explicit label.
2026-06-30 23:38:05 -04:00
12 changed files with 689 additions and 60 deletions
+16
View File
@@ -50,6 +50,21 @@ func writeCmd(op string, fn func() error) tea.Cmd {
}
}
func (m *Model) saveBottomPanelPref() tea.Cmd {
v := "logs"
switch m.bottomPanel {
case bottomNone:
v = "none"
case bottomMaint:
v = "maint"
}
st := m.store
ctx := m.ctx
return writeCmd("Save bottom panel preference", func() error {
return st.SetPreference(ctx, "bottom_panel", v)
})
}
func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool, sortCol int, sortAsc bool) []models.Site {
var groups, ungrouped []models.Site
children := make(map[int][]models.Site)
@@ -123,6 +138,7 @@ func (m *Model) refreshLive() {
ordered = filterSites(ordered, m.filterText)
}
m.sites = ordered
m.buildMaintSet()
m.refreshLogContent()
if m.selectedID != 0 {
+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()
}
+55 -9
View File
@@ -6,7 +6,13 @@ import (
"github.com/charmbracelet/lipgloss"
)
func (m Model) titledPanelH(title, content, footer string, width, height, scrollOffset int, focused bool) string {
type scrollbar struct {
pos int
total int
visible int
}
func (m Model) titledPanelH(title, content, footer string, width, height, scrollOffset int, sb scrollbar, focused bool) string {
if height <= 0 {
return m.titledPanel(title, content, width, focused)
}
@@ -64,21 +70,61 @@ func (m Model) titledPanelH(title, content, footer string, width, height, scroll
}
visible := contentLines[scrollOffset:end]
borderLine := func(line string) string {
return bc.Render("│") + line + strings.Repeat(" ", max(0, innerW-lipgloss.Width(line))) + bc.Render("│")
if sb.total == 0 && len(contentLines) > bodyH {
sb = scrollbar{pos: scrollOffset, total: len(contentLines), visible: bodyH}
}
sbVisible := sb.visible
if sbVisible <= 0 {
sbVisible = bodyH
}
showScrollbar := sb.total > 0 && sb.total > sbVisible
var thumbStart, thumbEnd int
if showScrollbar {
thumbSize := bodyH * sbVisible / sb.total
if thumbSize < 1 {
thumbSize = 1
}
scrollRange := sb.total - sbVisible
if scrollRange < 1 {
scrollRange = 1
}
trackSpace := bodyH - thumbSize
thumbStart = sb.pos * trackSpace / scrollRange
if thumbStart < 0 {
thumbStart = 0
}
thumbEnd = thumbStart + thumbSize
if thumbEnd > bodyH {
thumbEnd = bodyH
}
}
scrollTrack := lipgloss.NewStyle().Foreground(m.theme.Border).Render("░")
scrollThumb := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("█")
borderLine := func(line string, idx int) string {
rightBorder := bc.Render("│")
if showScrollbar && idx >= thumbStart && idx < thumbEnd {
rightBorder = scrollThumb
} else if showScrollbar {
rightBorder = scrollTrack
}
return bc.Render("│") + line + strings.Repeat(" ", max(0, innerW-lipgloss.Width(line))) + rightBorder
}
emptyLine := func(idx int) string {
return borderLine(strings.Repeat(" ", innerW), idx)
}
emptyLine := borderLine(strings.Repeat(" ", innerW))
var lines []string
lines = append(lines, top)
for _, line := range visible {
lines = append(lines, borderLine(line))
for i, line := range visible {
lines = append(lines, borderLine(line, i))
}
for len(lines) < height-1-len(footerLines) {
lines = append(lines, emptyLine)
for i := len(visible); len(lines) < height-1-len(footerLines); i++ {
lines = append(lines, emptyLine(i))
}
for _, line := range footerLines {
lines = append(lines, borderLine(line))
lines = append(lines, borderLine(line, -1))
}
lines = append(lines, bottom)
+2 -4
View File
@@ -156,9 +156,8 @@ func resolveSparklineIndex(x, sparkWidth, dataLen int) int {
}
func (m Model) groupSparkline(groupID int, width int, bg lipgloss.TerminalColor) string {
allSites := m.engine.GetAllSites()
var childStatuses [][]bool
for _, s := range allSites {
for _, s := range m.sites {
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
hist, _ := m.engine.GetHistory(s.ID)
if len(hist.Statuses) > 0 {
@@ -209,9 +208,8 @@ func (m Model) groupSparkline(groupID int, width int, bg lipgloss.TerminalColor)
}
func (m Model) groupUptime(groupID int) string {
allSites := m.engine.GetAllSites()
var allStatuses [][]bool
for _, s := range allSites {
for _, s := range m.sites {
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
hist, _ := m.engine.GetHistory(s.ID)
if len(hist.Statuses) > 0 {
+14
View File
@@ -72,6 +72,20 @@ func (m Model) viewLogsStrip(width, maxLines int) string {
return style.Render(strings.Join(visible, "\n"))
}
func (m Model) filteredLogCount() int {
count := 0
for _, entry := range m.engine.GetLogs() {
if strings.TrimSpace(entry.Message) == "" {
continue
}
if m.logFilterImportant && !isImportantLog(classifyLog(entry.Message)) {
continue
}
count++
}
return count
}
func (m *Model) scrollLogs(delta int) {
logs := m.engine.GetLogs()
total := 0
+15 -6
View File
@@ -21,27 +21,36 @@ type maintFormData struct {
}
func (m Model) isMonitorInMaintenance(monitorID int) bool {
return m.maintSet[monitorID]
}
func (m *Model) buildMaintSet() {
set := make(map[int]bool)
now := time.Now()
for _, mw := range m.maintenanceWindows {
if mw.Type != "maintenance" {
continue
}
now := time.Now()
if mw.StartTime.After(now) {
continue
}
if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
continue
}
if mw.MonitorID == 0 || mw.MonitorID == monitorID {
return true
if mw.MonitorID == 0 {
for _, s := range m.sites {
set[s.ID] = true
}
break
}
set[mw.MonitorID] = true
for _, s := range m.sites {
if s.ID == monitorID && s.ParentID > 0 && mw.MonitorID == s.ParentID {
return true
if s.ParentID == mw.MonitorID {
set[s.ID] = true
}
}
}
return false
m.maintSet = set
}
func (m *Model) initMaintHuhForm() tea.Cmd {
+49 -17
View File
@@ -37,7 +37,6 @@ const (
colDot colKey = iota
colName
colType
colStatus
colLatency
colUptime
colHistory
@@ -57,15 +56,16 @@ type columnDef struct {
var siteColumns = []columnDef{
{colDot, "", "", 3, 3, 0},
{colName, "NAME", "NAME", 0, 0, 0},
{colType, "TYPE", "TYPE", 10, 8, mediumBreakpoint},
{colStatus, "STATUS", "STATUS", 10, 10, 0},
{colType, "TYPE", "TYPE", 10, 8, 0},
{colLatency, "LATENCY", "LAT", 10, 7, 0},
{colUptime, "UPTIME", "UP%", 8, 8, mediumBreakpoint},
{colHistory, "HISTORY", "HISTORY", 0, 0, mediumBreakpoint},
{colSSL, "SSL", "SSL", 7, 5, wideBreakpoint},
{colRetries, "RETRIES", "RT", 9, 5, wideBreakpoint},
{colUptime, "UPTIME", "UP%", 8, 8, 0},
{colHistory, "HISTORY", "HISTORY", 0, 0, 0},
{colSSL, "SSL", "SSL", 7, 5, 0},
{colRetries, "RETRIES", "RT", 9, 5, 0},
}
var columnDropOrder = []colKey{colHistory, colRetries, colSSL, colUptime, colType}
type tableLayout struct {
nameW, sparkW int
headers []string
@@ -76,17 +76,51 @@ type tableLayout struct {
func (m Model) computeLayout() tableLayout {
wide := m.isWide()
var active []colKey
var headers []string
var widths []int
var fixed int
cw := m.contentWidth
if cw == 0 {
cw = m.termWidth
}
dropped := make(map[colKey]bool)
minNameW := 20
minSparkW := 12
for attempt := 0; attempt <= len(columnDropOrder); attempt++ {
var fixed int
var flexCount int
for _, c := range siteColumns {
if dropped[c.key] {
continue
}
w := c.narrowW
if wide {
w = c.wideW
}
if w > 0 {
fixed += w
} else {
flexCount++
}
}
numCols := len(siteColumns) - len(dropped)
borderOverhead := 2 + (numCols - 1)
avail := cw - chromePadH - 2 - borderOverhead - fixed
minFlex := minNameW
if flexCount > 1 {
minFlex += minSparkW
}
if avail >= minFlex || attempt >= len(columnDropOrder) {
break
}
dropped[columnDropOrder[attempt]] = true
}
var active []colKey
var headers []string
var widths []int
var fixed int
for _, c := range siteColumns {
if c.minTerm > 0 && cw < c.minTerm {
if dropped[c.key] {
continue
}
active = append(active, c.key)
@@ -106,12 +140,12 @@ func (m Model) computeLayout() tableLayout {
}
sortColMap := map[int]colKey{
sortStatus: colStatus,
sortStatus: colDot,
sortName: colName,
sortLatency: colLatency,
}
sortableKeys := map[colKey]string{
colStatus: "sort-status",
colDot: "sort-status",
colName: "sort-name",
colLatency: "sort-latency",
}
@@ -250,7 +284,6 @@ func (m Model) viewSitesTab() string {
colDot: m.fmtStatusDot(site.Status, site.Paused, inMaint),
colName: m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-4)),
colType: "group",
colStatus: m.fmtStatus(site.Status, site.Paused, inMaint),
colLatency: m.st.subtleStyle.Render("—"),
colUptime: m.groupUptime(site.ID),
colHistory: m.groupSparkline(site.ID, sparkWidth, rowBg),
@@ -299,7 +332,6 @@ func (m Model) viewSitesTab() string {
colDot: m.fmtStatusDot(site.Status, site.Paused, inMaint),
colName: m.zones.Mark(fmt.Sprintf("site-%d", i), name),
colType: typeIcon(site.Type, false) + " " + site.Type,
colStatus: m.fmtStatus(site.Status, site.Paused, inMaint),
colLatency: m.fmtLatency(site.Latency),
colUptime: m.fmtUptimeMaint(hist.Statuses, site.ID),
colHistory: spark,
+23 -11
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 {
@@ -206,6 +207,7 @@ type Model struct {
lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle)
tabSeq int // seq of the newest issued tab-data load
maintSet map[int]bool
bottomPanel bottomPanel
detailOpen bool
maintCursor int
@@ -241,6 +243,16 @@ func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor
detailPref, _ := s.GetPreference(ctx, "detail_open")
bp := bottomLogs
if bpPref, _ := s.GetPreference(ctx, "bottom_panel"); bpPref != "" {
switch bpPref {
case "none":
bp = bottomNone
case "maint":
bp = bottomMaint
}
}
return Model{
ctx: ctx,
state: stateDashboard,
@@ -255,7 +267,7 @@ func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor
theme: theme,
themeIndex: themeIdx,
st: newStyles(theme),
bottomPanel: bottomLogs,
bottomPanel: bp,
detailOpen: detailPref == "true",
demoMode: os.Getenv("UPTOP_DEMO") == "1",
version: version,
+85 -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)
@@ -227,6 +231,7 @@ func (m *Model) handleTabData(msg tabDataMsg) (tea.Model, tea.Cmd) {
}
m.nodes = msg.nodes
m.maintenanceWindows = msg.maint
m.buildMaintSet()
m.clampCursor()
return m, nil
}
@@ -278,6 +283,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
@@ -588,6 +662,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.focusedPanel = panelMaint
}
m.recalcLayout()
return m, m.saveBottomPanelPref()
case "l":
if m.bottomPanel == bottomLogs {
m.bottomPanel = bottomNone
@@ -597,6 +672,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.focusedPanel = panelLogs
}
m.recalcLayout()
return m, m.saveBottomPanelPref()
case "up", "k":
if m.focusedPanel == panelDetail && m.detailOpen {
m.detailScrollOffset--
@@ -673,6 +749,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
+147
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)
@@ -325,3 +389,86 @@ func TestDetailRefreshCmd_OnlyWhileDetailOpen(t *testing.T) {
t.Error("refresh Cmd issued for an out-of-range cursor")
}
}
func TestBuildMaintSet_GlobalWindow(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.sites = []models.Site{
{SiteConfig: models.SiteConfig{ID: 1, Name: "a"}},
{SiteConfig: models.SiteConfig{ID: 2, Name: "b"}},
}
m.maintenanceWindows = []models.MaintenanceWindow{
{ID: 1, MonitorID: 0, Type: "maintenance", StartTime: time.Now().Add(-time.Hour)},
}
m.buildMaintSet()
if !m.maintSet[1] || !m.maintSet[2] {
t.Error("global maint window should mark all monitors")
}
}
func TestBuildMaintSet_TargetedWindow(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.sites = []models.Site{
{SiteConfig: models.SiteConfig{ID: 1, Name: "a"}},
{SiteConfig: models.SiteConfig{ID: 2, Name: "b"}},
}
m.maintenanceWindows = []models.MaintenanceWindow{
{ID: 1, MonitorID: 1, Type: "maintenance", StartTime: time.Now().Add(-time.Hour)},
}
m.buildMaintSet()
if !m.maintSet[1] {
t.Error("targeted window should mark monitor 1")
}
if m.maintSet[2] {
t.Error("targeted window should NOT mark monitor 2")
}
}
func TestBuildMaintSet_GroupPropagates(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.sites = []models.Site{
{SiteConfig: models.SiteConfig{ID: 10, Name: "group", Type: "group"}},
{SiteConfig: models.SiteConfig{ID: 11, Name: "child1", ParentID: 10}},
{SiteConfig: models.SiteConfig{ID: 12, Name: "child2", ParentID: 10}},
{SiteConfig: models.SiteConfig{ID: 20, Name: "other"}},
}
m.maintenanceWindows = []models.MaintenanceWindow{
{ID: 1, MonitorID: 10, Type: "maintenance", StartTime: time.Now().Add(-time.Hour)},
}
m.buildMaintSet()
if !m.maintSet[10] || !m.maintSet[11] || !m.maintSet[12] {
t.Error("group maint window should mark group + children")
}
if m.maintSet[20] {
t.Error("unrelated monitor should NOT be marked")
}
}
func TestBuildMaintSet_ExpiredIgnored(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.sites = []models.Site{
{SiteConfig: models.SiteConfig{ID: 1, Name: "a"}},
}
m.maintenanceWindows = []models.MaintenanceWindow{
{ID: 1, MonitorID: 1, Type: "maintenance",
StartTime: time.Now().Add(-2 * time.Hour),
EndTime: time.Now().Add(-1 * time.Hour)},
}
m.buildMaintSet()
if m.maintSet[1] {
t.Error("expired maint window should not mark monitor")
}
}
func TestBuildMaintSet_IncidentIgnored(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.sites = []models.Site{
{SiteConfig: models.SiteConfig{ID: 1, Name: "a"}},
}
m.maintenanceWindows = []models.MaintenanceWindow{
{ID: 1, MonitorID: 0, Type: "incident", StartTime: time.Now().Add(-time.Hour)},
}
m.buildMaintSet()
if m.maintSet[1] {
t.Error("incident windows should not mark monitors as in maintenance")
}
}
+12 -6
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:
@@ -161,7 +163,7 @@ func (m Model) viewMonitorsLayout() string {
monTargetH := m.maxTableRows + 5
monitors := m.viewSitesTab()
monPanel := m.zones.Mark("panel-monitors", m.titledPanelH("Monitors", monitors, "", monW, monTargetH, 0, m.focusedPanel == panelMonitors))
monPanel := m.zones.Mark("panel-monitors", m.titledPanelH("Monitors", monitors, "", monW, monTargetH, 0, scrollbar{pos: m.tableOffset, total: len(m.sites), visible: m.maxTableRows}, m.focusedPanel == panelMonitors))
var topParts []string
topParts = append(topParts, monPanel)
@@ -179,7 +181,7 @@ func (m Model) viewMonitorsLayout() string {
monHeight := lipgloss.Height(monPanel)
detail := m.viewDetailInline(detailW-2, monHeight)
footer := m.detailFooter(detailW - 2)
detailPanel := m.zones.Mark("panel-detail", m.titledPanelH(title, detail, footer, detailW, monHeight, m.detailScrollOffset, m.focusedPanel == panelDetail))
detailPanel := m.zones.Mark("panel-detail", m.titledPanelH(title, detail, footer, detailW, monHeight, m.detailScrollOffset, scrollbar{}, m.focusedPanel == panelDetail))
topParts = append(topParts, detailPanel)
}
@@ -187,12 +189,16 @@ func (m Model) viewMonitorsLayout() string {
switch m.bottomPanel {
case bottomLogs:
logContent := m.viewLogsStrip(availW-2, logsStripHeight-2)
logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", logContent, availW, m.focusedPanel == panelLogs))
maxLines := logsStripHeight - 2
logContent := m.viewLogsStrip(availW-2, maxLines)
totalLogs := m.filteredLogCount()
logPanel := m.zones.Mark("panel-logs", m.titledPanelH("Logs", logContent, "", availW, logsStripHeight, 0, scrollbar{pos: m.logScrollOffset, total: totalLogs, visible: maxLines}, m.focusedPanel == panelLogs))
return top + "\n" + logPanel
case bottomMaint:
maintContent := m.viewMaintStrip(availW-2, logsStripHeight-2)
maintPanel := m.zones.Mark("panel-maint", m.titledPanel("Maint", maintContent, availW, m.focusedPanel == panelMaint))
maxLines := logsStripHeight - 2
maintContent := m.viewMaintStrip(availW-2, maxLines)
totalMaint := len(m.activeMaintWindows())
maintPanel := m.zones.Mark("panel-maint", m.titledPanelH("Maint", maintContent, "", availW, logsStripHeight, 0, scrollbar{pos: 0, total: totalMaint, visible: maxLines}, m.focusedPanel == panelMaint))
return top + "\n" + maintPanel
}
return top
+182 -5
View File
@@ -2,6 +2,7 @@ package tui
import (
"fmt"
"sort"
"strings"
"time"
@@ -22,20 +23,72 @@ 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 ""
}
availW := 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
}
if site.ParentID > 0 {
for _, s := range m.sites {
if s.ID == site.ParentID {
title = s.Name + " > " + title
break
}
}
}
innerW := availW - 2
var content string
switch m.detailMode {
case detailSLA:
content = m.viewSLASidebar(innerW, 0)
case detailHistory:
content = m.viewHistorySidebar(innerW, 0)
default:
hist, _ := m.engine.GetHistory(site.ID)
content = m.buildDetailContent(site, hist, innerW, true)
}
footer := m.detailFooter(innerW)
panelH := m.termHeight - chromePadV
if panelH < 10 {
panelH = 10
}
return lipgloss.NewStyle().Padding(1, 2).Render(
m.titledPanelH(title, content, footer, availW, panelH, m.detailScrollOffset, scrollbar{}, true))
}
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 +97,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 +109,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 +121,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 +146,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 +243,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 +286,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