From 769954c8f509c792ce4deec41bfef319934cc1e8 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 12:25:46 -0400 Subject: [PATCH 1/3] feat(tui): add status bar, tab badges, and detail panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polish pass for TUI professionalism: - Status bar replaces generic footer with live stats (UP/DOWN count, online probes) plus contextual key hints - Tab badges show DOWN count on Sites tab and offline count on Nodes tab - Detail panel (press i) shows full monitor info: URL, latency, uptime, SSL, probe results, sparkline — without entering edit mode --- internal/tui/tab_sites.go | 82 +++++++++++++++++++++++++++++++++++++++ internal/tui/tui.go | 64 ++++++++++++++++++++++++++++-- 2 files changed, 142 insertions(+), 4 deletions(-) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 0672cbc..3f925dc 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -550,3 +550,85 @@ func (m *Model) submitSiteForm() { } m.state = stateDashboard } + +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 + + title := titleStyle.Render(fmt.Sprintf(" %s", site.Name)) + b.WriteString(title + "\n\n") + + row := func(label, value string) { + b.WriteString(fmt.Sprintf(" %-16s %s\n", subtleStyle.Render(label), value)) + } + + row("Status", fmtStatus(site.Status, site.Paused)) + row("Type", site.Type) + if site.URL != "" { + row("URL", site.URL) + } + if site.Hostname != "" { + row("Host", site.Hostname) + } + if site.Port > 0 { + row("Port", strconv.Itoa(site.Port)) + } + row("Interval", fmt.Sprintf("%ds", site.Interval)) + row("Timeout", fmt.Sprintf("%ds", site.Timeout)) + row("Latency", fmtLatency(site.Latency)) + row("Uptime", fmtUptime(hist.TotalChecks, hist.UpChecks)) + + if site.Type == "http" { + row("Method", site.Method) + row("Codes", site.AcceptedCodes) + row("SSL", fmtSSL(site)) + if site.IgnoreTLS { + row("TLS Verify", dangerStyle.Render("disabled")) + } + } + + if site.MaxRetries > 0 { + row("Retries", fmtRetries(site)) + } + if site.Regions != "" { + row("Regions", site.Regions) + } + if site.Description != "" { + row("Description", site.Description) + } + if !site.LastCheck.IsZero() { + row("Last Check", site.LastCheck.Format("15:04:05")) + } + + probeResults := m.engine.GetProbeResults(site.ID) + if len(probeResults) > 0 { + b.WriteString("\n" + subtleStyle.Render(" PROBE RESULTS") + "\n") + for nodeID, result := range probeResults { + status := specialStyle.Render("UP") + if !result.IsUp { + status = dangerStyle.Render("DN") + } + latency := time.Duration(result.LatencyNs).Milliseconds() + ago := time.Since(result.CheckedAt).Truncate(time.Second) + b.WriteString(fmt.Sprintf(" %-14s %s %dms %s ago\n", nodeID, status, latency, ago)) + } + } + + b.WriteString("\n") + const sparkWidth = 40 + if site.Type == "push" { + b.WriteString(" " + heartbeatSparkline(hist.Statuses, sparkWidth)) + } else { + b.WriteString(" " + latencySparkline(hist.Latencies, sparkWidth)) + } + + b.WriteString("\n\n") + b.WriteString(subtleStyle.Render(" [i/Esc] Back [e] Edit [q] Quit")) + + return lipgloss.NewStyle().Padding(1, 2).Render(b.String()) +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index f188254..b7af72b 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -37,6 +37,7 @@ const ( stateDashboard sessionState = iota stateLogs stateUsers + stateDetail stateFormSite stateFormAlert stateFormUser @@ -247,6 +248,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch m.state { + case stateDetail: + switch msg.String() { + case "i", "esc": + m.state = stateDashboard + case "q": + return m, tea.Quit + } + return m, nil case stateDashboard, stateLogs, stateUsers: switch msg.String() { case "q": @@ -330,6 +339,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { _ = m.store.UpdateSitePaused(site.ID, site.Paused) m.refreshData() } + case "i": + if m.currentTab == 0 && len(m.sites) > 0 { + m.state = stateDetail + } case "d", "backspace": if m.currentTab == 0 && len(m.sites) > 0 { m.deleteID = m.sites[m.cursor].ID @@ -564,13 +577,37 @@ 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.viewDetailPanel() default: return m.zones.Scan(m.viewDashboard()) } } func (m Model) viewDashboard() string { - tabs := []string{"Sites", "Alerts", "Logs", "Nodes"} + downCount := 0 + for _, s := range m.sites { + if !s.Paused && (s.Status == "DOWN" || s.Status == "SSL EXP") { + downCount++ + } + } + offlineNodes := 0 + for _, n := range m.nodes { + if !n.LastSeen.IsZero() && time.Since(n.LastSeen) > 5*time.Minute { + offlineNodes++ + } + } + + sitesLabel := "Sites" + if downCount > 0 { + sitesLabel = fmt.Sprintf("Sites (%d↓)", downCount) + } + nodesLabel := "Nodes" + if offlineNodes > 0 { + nodesLabel = fmt.Sprintf("Nodes (%d!)", offlineNodes) + } + + tabs := []string{sitesLabel, "Alerts", "Logs", nodesLabel} if m.isAdmin { tabs = append(tabs, "Users") } @@ -605,10 +642,29 @@ func (m Model) viewDashboard() string { } } - footer := subtleStyle.Render("\n[n] New [e/Enter] Edit [d] Delete [p] Pause [Space] Collapse [Tab/Click] Switch [q] Quit") - if m.currentTab == 4 { - footer = subtleStyle.Render("\n[n] Add User [d] Revoke [Tab/Click] Switch [Ctrl+L] Clear [q] Quit") + upCount := len(m.sites) - downCount + statusParts := []string{fmt.Sprintf("%d/%d UP", upCount, len(m.sites))} + if len(m.nodes) > 0 { + online := 0 + for _, n := range m.nodes { + if !n.LastSeen.IsZero() && time.Since(n.LastSeen) < 60*time.Second { + online++ + } + } + statusParts = append(statusParts, fmt.Sprintf("%d probes", online)) } + statusLine := subtleStyle.Render(strings.Join(statusParts, " · ")) + + var keys string + switch m.currentTab { + case 0: + keys = "[n]New [e]Edit [i]Info [d]Del [p]Pause [Tab]Switch [q]Quit" + case 4: + keys = "[n]Add [d]Revoke [Tab]Switch [q]Quit" + default: + keys = "[Tab]Switch [q]Quit" + } + footer := "\n" + statusLine + " " + subtleStyle.Render(keys) s := lipgloss.NewStyle().Padding(1, 2) if m.termHeight > 0 { s = s.MaxHeight(m.termHeight) From 3bc8e31b89050bc62704df85afc0609076fa908e Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 12:28:09 -0400 Subject: [PATCH 2/3] fix(tui): make status bar and tab badges visible - Tab badges now always show count (Sites (12)), not just on failure - Status bar UP count uses green/red coloring instead of subtle gray --- internal/tui/tui.go | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index b7af72b..18570aa 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -598,13 +598,21 @@ func (m Model) viewDashboard() string { } } - sitesLabel := "Sites" + var sitesLabel string if downCount > 0 { sitesLabel = fmt.Sprintf("Sites (%d↓)", downCount) + } else if len(m.sites) > 0 { + sitesLabel = fmt.Sprintf("Sites (%d)", len(m.sites)) + } else { + sitesLabel = "Sites" } - nodesLabel := "Nodes" + var nodesLabel string if offlineNodes > 0 { nodesLabel = fmt.Sprintf("Nodes (%d!)", offlineNodes) + } else if len(m.nodes) > 0 { + nodesLabel = fmt.Sprintf("Nodes (%d)", len(m.nodes)) + } else { + nodesLabel = "Nodes" } tabs := []string{sitesLabel, "Alerts", "Logs", nodesLabel} @@ -643,7 +651,13 @@ func (m Model) viewDashboard() string { } upCount := len(m.sites) - downCount - statusParts := []string{fmt.Sprintf("%d/%d UP", upCount, len(m.sites))} + var upStr string + if downCount > 0 { + upStr = dangerStyle.Render(fmt.Sprintf("%d/%d UP", upCount, len(m.sites))) + } else { + upStr = specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, len(m.sites))) + } + statusParts := []string{upStr} if len(m.nodes) > 0 { online := 0 for _, n := range m.nodes { @@ -653,7 +667,7 @@ func (m Model) viewDashboard() string { } statusParts = append(statusParts, fmt.Sprintf("%d probes", online)) } - statusLine := subtleStyle.Render(strings.Join(statusParts, " · ")) + statusLine := strings.Join(statusParts, subtleStyle.Render(" · ")) var keys string switch m.currentTab { From f2ea0dc758d2accb78d818e5754c53b9551c7ffa Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 12:56:09 -0400 Subject: [PATCH 3/3] feat(tui): bordered modals, welcome state, and dynamic name width - Delete confirmation wrapped in rounded border box with danger color - Empty sites view shows styled welcome box with onboarding hint - NAME column width scales with terminal width (13-40 chars) --- internal/tui/tab_sites.go | 28 ++++++++++++++++++++++++---- internal/tui/tui.go | 7 ++++++- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 3f925dc..faab54a 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -195,11 +195,31 @@ func fmtStatus(status string, paused bool) string { } } +func (m Model) nameWidth() int { + w := m.termWidth - 105 + if w < 13 { + w = 13 + } + if w > 40 { + w = 40 + } + return w +} + func (m Model) viewSitesTab() string { const sparkWidth = 20 if len(m.sites) == 0 { - return "\n No sites configured. Press [n] to add one." + welcome := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#7D56F4")). + Padding(1, 3). + Render( + titleStyle.Render("Go-Upkeep") + "\n\n" + + "No monitors configured yet.\n\n" + + subtleStyle.Render("[n] Add your first monitor"), + ) + return "\n" + welcome } colWidths := []int{6, 0, 10, 10, 8, 8, sparkWidth + 4, 7, 9} @@ -222,7 +242,7 @@ func (m Model) viewSitesTab() string { } rows = append(rows, []string{ strconv.Itoa(i + 1), - m.zones.Mark(fmt.Sprintf("site-%d", i), arrow+" "+limitStr(site.Name, 11)), + m.zones.Mark(fmt.Sprintf("site-%d", i), arrow+" "+limitStr(site.Name, m.nameWidth()-2)), "group", fmtStatus(site.Status, site.Paused), subtleStyle.Render("—"), @@ -240,9 +260,9 @@ func (m Model) viewSitesTab() string { if i+1 >= len(m.sites) || m.sites[i+1].ParentID != site.ParentID { prefix = "└" } - name = prefix + " " + limitStr(name, 11) + name = prefix + " " + limitStr(name, m.nameWidth()-2) } else { - name = limitStr(name, 13) + name = limitStr(name, m.nameWidth()) } hist, _ := m.engine.GetHistory(site.ID) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 18570aa..ddccd8b 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -551,7 +551,12 @@ func (m Model) View() string { } msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName)) hint := subtleStyle.Render("[y] Confirm [n] Cancel") - return lipgloss.NewStyle().Padding(2, 4).Render(msg + "\n\n" + hint) + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#F25D94")). + Padding(1, 3). + Render(msg + "\n\n" + hint) + return lipgloss.NewStyle().Padding(2, 4).Render(box) case stateFormSite, stateFormAlert, stateFormUser: if m.huhForm != nil { title := ""