release: 2026.05.1 — distributed probing, config-as-code, TUI polish #15

Merged
lerko merged 47 commits from develop into main 2026-05-16 20:03:54 +00:00
3 changed files with 127 additions and 31 deletions
Showing only changes of commit 1b223b9725 - Show all commits
+1 -1
View File
@@ -2,7 +2,7 @@ package monitor
import "time" import "time"
const maxHistoryLen = 30 const maxHistoryLen = 60
type SiteHistory struct { type SiteHistory struct {
Latencies []time.Duration Latencies []time.Duration
+31 -20
View File
@@ -61,6 +61,9 @@ func latencySparkline(latencies []time.Duration, width int) string {
} }
var sb strings.Builder var sb strings.Builder
if remaining := width - len(samples); remaining > 0 {
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
}
spread := maxL - minL spread := maxL - minL
for _, l := range samples { for _, l := range samples {
idx := 0 idx := 0
@@ -80,10 +83,6 @@ func latencySparkline(latencies []time.Duration, width int) string {
sb.WriteString(dangerStyle.Render(ch)) sb.WriteString(dangerStyle.Render(ch))
} }
} }
if remaining := width - len(samples); remaining > 0 {
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
}
return sb.String() return sb.String()
} }
@@ -98,6 +97,9 @@ func heartbeatSparkline(statuses []bool, width int) string {
} }
var sb strings.Builder var sb strings.Builder
if remaining := width - len(samples); remaining > 0 {
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
}
for _, up := range samples { for _, up := range samples {
if up { if up {
sb.WriteString(specialStyle.Render("▁")) sb.WriteString(specialStyle.Render("▁"))
@@ -105,10 +107,6 @@ func heartbeatSparkline(statuses []bool, width int) string {
sb.WriteString(dangerStyle.Render("█")) sb.WriteString(dangerStyle.Render("█"))
} }
} }
if remaining := width - len(samples); remaining > 0 {
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
}
return sb.String() return sb.String()
} }
@@ -195,19 +193,31 @@ func fmtStatus(status string, paused bool) string {
} }
} }
func (m Model) nameWidth() int { func (m Model) dynamicWidths() (nameW, sparkW int) {
w := m.termWidth - 105 fixed := 6 + 10 + 10 + 8 + 8 + 7 + 9 // #, TYPE, STATUS, LATENCY, UPTIME, SSL, RETRY
if w < 13 { overhead := 30 // cell padding + borders
w = 13 avail := m.termWidth - 6 - fixed - overhead
if avail < 30 {
avail = 30
} }
if w > 40 { nameW = avail / 2
w = 40 sparkW = avail - nameW - 2 // -2 for spark column padding
if nameW < 13 {
nameW = 13
} }
return w if nameW > 40 {
nameW = 40
}
if sparkW < 10 {
sparkW = 10
}
if sparkW > 60 {
sparkW = 60
}
return
} }
func (m Model) viewSitesTab() string { func (m Model) viewSitesTab() string {
const sparkWidth = 20
if len(m.sites) == 0 { if len(m.sites) == 0 {
welcome := lipgloss.NewStyle(). welcome := lipgloss.NewStyle().
@@ -222,7 +232,8 @@ func (m Model) viewSitesTab() string {
return "\n" + welcome return "\n" + welcome
} }
colWidths := []int{6, 0, 10, 10, 8, 8, sparkWidth + 4, 7, 9} nameW, sparkWidth := m.dynamicWidths()
colWidths := []int{6, 0, 10, 10, 8, 8, sparkWidth + 2, 7, 9}
var groupRows map[int]bool var groupRows map[int]bool
return m.renderTable( return m.renderTable(
@@ -242,7 +253,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, m.nameWidth()-2)), m.zones.Mark(fmt.Sprintf("site-%d", i), arrow+" "+limitStr(site.Name, nameW-2)),
"group", "group",
fmtStatus(site.Status, site.Paused), fmtStatus(site.Status, site.Paused),
subtleStyle.Render("—"), subtleStyle.Render("—"),
@@ -260,9 +271,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, m.nameWidth()-2) name = prefix + " " + limitStr(name, nameW-2)
} else { } else {
name = limitStr(name, m.nameWidth()) name = limitStr(name, nameW)
} }
hist, _ := m.engine.GetHistory(site.ID) hist, _ := m.engine.GetHistory(site.ID)
+95 -10
View File
@@ -82,6 +82,9 @@ type Model struct {
alerts []models.AlertConfig alerts []models.AlertConfig
users []models.User users []models.User
nodes []models.ProbeNode nodes []models.ProbeNode
filterMode bool
filterText string
} }
func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model { func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
@@ -247,6 +250,36 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.ClearScreen return m, tea.ClearScreen
} }
if m.filterMode {
switch msg.String() {
case "esc":
m.filterMode = false
m.filterText = ""
m.cursor = 0
m.tableOffset = 0
m.refreshData()
case "enter":
m.filterMode = false
case "backspace":
if len(m.filterText) > 0 {
m.filterText = m.filterText[:len(m.filterText)-1]
m.cursor = 0
m.tableOffset = 0
m.refreshData()
}
case "ctrl+c":
return m, tea.Quit
default:
if len(msg.String()) == 1 {
m.filterText += msg.String()
m.cursor = 0
m.tableOffset = 0
m.refreshData()
}
}
return m, nil
}
switch m.state { switch m.state {
case stateDetail: case stateDetail:
switch msg.String() { switch msg.String() {
@@ -260,6 +293,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "q": case "q":
return m, tea.Quit return m, tea.Quit
case "/":
if m.currentTab == 0 {
m.filterMode = true
return m, nil
}
case "tab": case "tab":
m.switchTab(m.currentTab + 1) m.switchTab(m.currentTab + 1)
case "pgup", "pgdown": case "pgup", "pgdown":
@@ -471,9 +509,11 @@ func (m *Model) refreshData() {
for pid := range children { for pid := range children {
c := children[pid] c := children[pid]
sort.Slice(c, func(i, j int) bool { return c[i].ID < c[j].ID }) sort.Slice(c, func(i, j int) bool { return c[i].ID < c[j].ID })
sort.SliceStable(c, func(i, j int) bool { return siteOrder(c[i]) < siteOrder(c[j]) })
children[pid] = c children[pid] = c
} }
sort.Slice(ungrouped, func(i, j int) bool { return ungrouped[i].ID < ungrouped[j].ID }) sort.Slice(ungrouped, func(i, j int) bool { return ungrouped[i].ID < ungrouped[j].ID })
sort.SliceStable(ungrouped, func(i, j int) bool { return siteOrder(ungrouped[i]) < siteOrder(ungrouped[j]) })
var ordered []models.Site var ordered []models.Site
for _, g := range groups { for _, g := range groups {
@@ -483,6 +523,16 @@ func (m *Model) refreshData() {
} }
} }
ordered = append(ordered, ungrouped...) ordered = append(ordered, ungrouped...)
if m.filterText != "" {
var filtered []models.Site
needle := strings.ToLower(m.filterText)
for _, s := range ordered {
if strings.Contains(strings.ToLower(s.Name), needle) {
filtered = append(filtered, s)
}
}
ordered = filtered
}
m.sites = ordered m.sites = ordered
if alerts, err := m.store.GetAllAlerts(); err == nil { if alerts, err := m.store.GetAllAlerts(); err == nil {
m.alerts = alerts m.alerts = alerts
@@ -536,7 +586,19 @@ func (m Model) pulseIndicator() string {
if brightness > 255 { if brightness > 255 {
brightness = 255 brightness = 255
} }
color := fmt.Sprintf("#%02x%02x%02x", brightness/3, brightness, brightness/2) hasDown := false
for _, s := range m.sites {
if !s.Paused && (s.Status == "DOWN" || s.Status == "SSL EXP") {
hasDown = true
break
}
}
var color string
if hasDown {
color = fmt.Sprintf("#%02x%02x%02x", brightness, brightness/4, brightness/4)
} else {
color = fmt.Sprintf("#%02x%02x%02x", brightness/3, brightness, brightness/2)
}
return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(pulseFrames[frame]) return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(pulseFrames[frame])
} }
@@ -674,16 +736,25 @@ func (m Model) viewDashboard() string {
} }
statusLine := strings.Join(statusParts, subtleStyle.Render(" · ")) statusLine := strings.Join(statusParts, subtleStyle.Render(" · "))
var keys string var footer string
switch m.currentTab { if m.filterMode {
case 0: cursor := lipgloss.NewStyle().Foreground(lipgloss.Color("#7D56F4")).Render("│")
keys = "[n]New [e]Edit [i]Info [d]Del [p]Pause [Tab]Switch [q]Quit" footer = "\n" + titleStyle.Render("/") + " " + m.filterText + cursor + " " + subtleStyle.Render("[Enter]Apply [Esc]Clear")
case 4: } else {
keys = "[n]Add [d]Revoke [Tab]Switch [q]Quit" var keys string
default: switch m.currentTab {
keys = "[Tab]Switch [q]Quit" case 0:
keys = "[/]Filter [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)
if m.filterText != "" && m.currentTab == 0 {
footer = "\n" + subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + subtleStyle.Render(keys)
}
} }
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)
@@ -691,6 +762,20 @@ func (m Model) viewDashboard() string {
return s.Render(header + "\n" + content + "\n" + footer) return s.Render(header + "\n" + content + "\n" + footer)
} }
func siteOrder(s models.Site) int {
if s.Paused {
return 3
}
switch s.Status {
case "DOWN", "SSL EXP":
return 0
case "PENDING":
return 2
default:
return 1
}
}
func limitStr(text string, max int) string { func limitStr(text string, max int) string {
if len(text) > max { if len(text) > max {
return text[:max-3] + "..." return text[:max-3] + "..."