Merge pull request 'feat(tui): polish pass — status bar, badges, detail panel, modals' (#10) from feat/tui-polish into develop

This commit was merged in pull request #10.
This commit is contained in:
2026-05-16 17:04:26 +00:00
2 changed files with 186 additions and 9 deletions
+106 -4
View File
@@ -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)
@@ -550,3 +570,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())
}
+80 -5
View File
@@ -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
@@ -538,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 := ""
@@ -564,13 +582,45 @@ 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++
}
}
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"
}
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}
if m.isAdmin {
tabs = append(tabs, "Users")
}
@@ -605,10 +655,35 @@ 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
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 {
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) < 60*time.Second {
online++
}
}
statusParts = append(statusParts, fmt.Sprintf("%d probes", online))
}
statusLine := strings.Join(statusParts, subtleStyle.Render(" · "))
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)