release: 2026.05.1 — distributed probing, config-as-code, TUI polish #15
+106
-4
@@ -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 {
|
func (m Model) viewSitesTab() string {
|
||||||
const sparkWidth = 20
|
const sparkWidth = 20
|
||||||
|
|
||||||
if len(m.sites) == 0 {
|
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}
|
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{
|
rows = append(rows, []string{
|
||||||
strconv.Itoa(i + 1),
|
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",
|
"group",
|
||||||
fmtStatus(site.Status, site.Paused),
|
fmtStatus(site.Status, site.Paused),
|
||||||
subtleStyle.Render("—"),
|
subtleStyle.Render("—"),
|
||||||
@@ -240,9 +260,9 @@ func (m Model) viewSitesTab() string {
|
|||||||
if i+1 >= len(m.sites) || m.sites[i+1].ParentID != site.ParentID {
|
if i+1 >= len(m.sites) || m.sites[i+1].ParentID != site.ParentID {
|
||||||
prefix = "└"
|
prefix = "└"
|
||||||
}
|
}
|
||||||
name = prefix + " " + limitStr(name, 11)
|
name = prefix + " " + limitStr(name, m.nameWidth()-2)
|
||||||
} else {
|
} else {
|
||||||
name = limitStr(name, 13)
|
name = limitStr(name, m.nameWidth())
|
||||||
}
|
}
|
||||||
|
|
||||||
hist, _ := m.engine.GetHistory(site.ID)
|
hist, _ := m.engine.GetHistory(site.ID)
|
||||||
@@ -550,3 +570,85 @@ func (m *Model) submitSiteForm() {
|
|||||||
}
|
}
|
||||||
m.state = stateDashboard
|
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
@@ -37,6 +37,7 @@ const (
|
|||||||
stateDashboard sessionState = iota
|
stateDashboard sessionState = iota
|
||||||
stateLogs
|
stateLogs
|
||||||
stateUsers
|
stateUsers
|
||||||
|
stateDetail
|
||||||
stateFormSite
|
stateFormSite
|
||||||
stateFormAlert
|
stateFormAlert
|
||||||
stateFormUser
|
stateFormUser
|
||||||
@@ -247,6 +248,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch m.state {
|
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:
|
case stateDashboard, stateLogs, stateUsers:
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "q":
|
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.store.UpdateSitePaused(site.ID, site.Paused)
|
||||||
m.refreshData()
|
m.refreshData()
|
||||||
}
|
}
|
||||||
|
case "i":
|
||||||
|
if m.currentTab == 0 && len(m.sites) > 0 {
|
||||||
|
m.state = stateDetail
|
||||||
|
}
|
||||||
case "d", "backspace":
|
case "d", "backspace":
|
||||||
if m.currentTab == 0 && len(m.sites) > 0 {
|
if m.currentTab == 0 && len(m.sites) > 0 {
|
||||||
m.deleteID = m.sites[m.cursor].ID
|
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))
|
msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName))
|
||||||
hint := subtleStyle.Render("[y] Confirm [n] Cancel")
|
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:
|
case stateFormSite, stateFormAlert, stateFormUser:
|
||||||
if m.huhForm != nil {
|
if m.huhForm != nil {
|
||||||
title := ""
|
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 lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer)
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
|
case stateDetail:
|
||||||
|
return m.viewDetailPanel()
|
||||||
default:
|
default:
|
||||||
return m.zones.Scan(m.viewDashboard())
|
return m.zones.Scan(m.viewDashboard())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) viewDashboard() string {
|
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 {
|
if m.isAdmin {
|
||||||
tabs = append(tabs, "Users")
|
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")
|
upCount := len(m.sites) - downCount
|
||||||
if m.currentTab == 4 {
|
var upStr string
|
||||||
footer = subtleStyle.Render("\n[n] Add User [d] Revoke [Tab/Click] Switch [Ctrl+L] Clear [q] Quit")
|
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)
|
s := lipgloss.NewStyle().Padding(1, 2)
|
||||||
if m.termHeight > 0 {
|
if m.termHeight > 0 {
|
||||||
s = s.MaxHeight(m.termHeight)
|
s = s.MaxHeight(m.termHeight)
|
||||||
|
|||||||
Reference in New Issue
Block a user