feat(tui): polish pass — status bar, badges, detail panel, modals #10

Merged
lerko merged 3 commits from feat/tui-polish into develop 2026-05-16 17:04:27 +00:00
2 changed files with 142 additions and 4 deletions
Showing only changes of commit 769954c8f5 - Show all commits
+82
View File
@@ -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())
}
+60 -4
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
@@ -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)