2 Commits

Author SHA1 Message Date
lerko 28fb6c8889 feat(tui): alert failure badge on Settings tab
Settings tab warn count now includes failed alert channels — alerts
where the last send failed (LastSendOK=false, LastSendAt not zero).
Shows as a red badge number alongside offline node count. Surfaces
broken alert channels without navigating to Settings.
2026-06-21 19:10:18 -04:00
lerko d982359f25 feat(tui): column sort with indicator on monitors table
Press < / > to cycle sort column (Status, Name, Latency). Press r to
reverse direction. Sorted column shows ▲/▼ arrow in the header.

Groups always float to top. Sort applies to ungrouped monitors and
group children independently. Default: Status descending (DOWN first).
2026-06-21 19:09:42 -04:00
6 changed files with 76 additions and 8 deletions
+22 -4
View File
@@ -49,7 +49,7 @@ func writeCmd(op string, fn func() error) tea.Cmd {
} }
} }
func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool) []models.Site { func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool, sortCol int, sortAsc bool) []models.Site {
var groups, ungrouped []models.Site var groups, ungrouped []models.Site
children := make(map[int][]models.Site) children := make(map[int][]models.Site)
for _, s := range allSites { for _, s := range allSites {
@@ -68,8 +68,26 @@ func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool) []model
sort.SliceStable(c, func(i, j int) bool { return siteOrder(c[i]) < siteOrder(c[j]) }) 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.SliceStable(ungrouped, func(i, j int) bool { return siteOrder(ungrouped[i]) < siteOrder(ungrouped[j]) }) sortSlice := func(s []models.Site) {
sort.Slice(s, func(i, j int) bool { return s[i].ID < s[j].ID })
sort.SliceStable(s, func(i, j int) bool {
var less bool
switch sortCol {
case sortName:
less = strings.ToLower(s[i].Name) < strings.ToLower(s[j].Name)
case sortLatency:
less = s[i].Latency < s[j].Latency
default:
less = siteOrder(s[i]) < siteOrder(s[j])
}
if !sortAsc {
return less
}
return !less
})
}
sortSlice(ungrouped)
var ordered []models.Site var ordered []models.Site
for _, g := range groups { for _, g := range groups {
@@ -99,7 +117,7 @@ func filterSites(sites []models.Site, needle string) []models.Site {
// separately via loadTabDataCmd. // separately via loadTabDataCmd.
func (m *Model) refreshLive() { func (m *Model) refreshLive() {
allSites := m.engine.GetAllSites() allSites := m.engine.GetAllSites()
ordered := sortSitesForDisplay(allSites, m.collapsed) ordered := sortSitesForDisplay(allSites, m.collapsed, m.sortColumn, m.sortAsc)
if m.filterText != "" { if m.filterText != "" {
ordered = filterSites(ordered, m.filterText) ordered = filterSites(ordered, m.filterText)
} }
+3 -3
View File
@@ -12,7 +12,7 @@ func TestSortSitesForDisplay_GroupsFirst(t *testing.T) {
{SiteConfig: models.SiteConfig{ID: 1, Name: "group-a", Type: "group"}, SiteState: models.SiteState{Status: "UP"}}, {SiteConfig: models.SiteConfig{ID: 1, Name: "group-a", Type: "group"}, SiteState: models.SiteState{Status: "UP"}},
{SiteConfig: models.SiteConfig{ID: 2, Name: "child", Type: "http", ParentID: 1}, SiteState: models.SiteState{Status: "UP"}}, {SiteConfig: models.SiteConfig{ID: 2, Name: "child", Type: "http", ParentID: 1}, SiteState: models.SiteState{Status: "UP"}},
} }
result := sortSitesForDisplay(sites, nil) result := sortSitesForDisplay(sites, nil, sortStatus, false)
if len(result) != 3 { if len(result) != 3 {
t.Fatalf("expected 3 sites, got %d", len(result)) t.Fatalf("expected 3 sites, got %d", len(result))
} }
@@ -34,7 +34,7 @@ func TestSortSitesForDisplay_CollapsedHidesChildren(t *testing.T) {
{SiteConfig: models.SiteConfig{ID: 3, Name: "child-2", Type: "http", ParentID: 1}, SiteState: models.SiteState{Status: "UP"}}, {SiteConfig: models.SiteConfig{ID: 3, Name: "child-2", Type: "http", ParentID: 1}, SiteState: models.SiteState{Status: "UP"}},
} }
collapsed := map[int]bool{1: true} collapsed := map[int]bool{1: true}
result := sortSitesForDisplay(sites, collapsed) result := sortSitesForDisplay(sites, collapsed, sortStatus, false)
if len(result) != 1 { if len(result) != 1 {
t.Fatalf("collapsed group should hide children, got %d items", len(result)) t.Fatalf("collapsed group should hide children, got %d items", len(result))
} }
@@ -49,7 +49,7 @@ func TestSortSitesForDisplay_StatusOrdering(t *testing.T) {
{SiteConfig: models.SiteConfig{ID: 2, Name: "down-site", Type: "http"}, SiteState: models.SiteState{Status: "DOWN"}}, {SiteConfig: models.SiteConfig{ID: 2, Name: "down-site", Type: "http"}, SiteState: models.SiteState{Status: "DOWN"}},
{SiteConfig: models.SiteConfig{ID: 3, Name: "late-site", Type: "http"}, SiteState: models.SiteState{Status: "LATE"}}, {SiteConfig: models.SiteConfig{ID: 3, Name: "late-site", Type: "http"}, SiteState: models.SiteState{Status: "LATE"}},
} }
result := sortSitesForDisplay(sites, nil) result := sortSitesForDisplay(sites, nil, sortStatus, false)
if result[0].Status != "DOWN" { if result[0].Status != "DOWN" {
t.Errorf("DOWN should sort first, got %s", result[0].Status) t.Errorf("DOWN should sort first, got %s", result[0].Status)
} }
+18
View File
@@ -106,6 +106,24 @@ func (m Model) computeLayout() tableLayout {
} }
} }
sortColMap := map[int]colKey{
sortStatus: colStatus,
sortName: colName,
sortLatency: colLatency,
}
if sortedKey, ok := sortColMap[m.sortColumn]; ok {
arrow := "▼"
if m.sortAsc {
arrow = "▲"
}
for i, k := range active {
if k == sortedKey {
headers[i] = headers[i] + arrow
break
}
}
}
numCols := len(headers) numCols := len(headers)
borderOverhead := 2 + (numCols - 1) borderOverhead := 2 + (numCols - 1)
avail := cw - chromePadH - 2 - borderOverhead - fixed avail := cw - chromePadH - 2 - borderOverhead - fixed
+9
View File
@@ -102,6 +102,13 @@ const (
panelDetail = 2 panelDetail = 2
) )
const (
sortStatus = 0
sortName = 1
sortLatency = 2
sortMax = 3
)
type sessionState int type sessionState int
const ( const (
@@ -125,6 +132,8 @@ type Model struct {
settingsSection int settingsSection int
cursor int cursor int
selectedID int selectedID int
sortColumn int
sortAsc bool
tableOffset int tableOffset int
maxTableRows int maxTableRows int
termWidth int termWidth int
+17
View File
@@ -532,6 +532,23 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.recalcLayout() m.recalcLayout()
return m, nil return m, nil
} }
case ">", ".":
if m.currentTab == tabMonitors {
m.sortColumn = (m.sortColumn + 1) % sortMax
m.sortAsc = false
m.refreshLive()
}
case "<", ",":
if m.currentTab == tabMonitors {
m.sortColumn = (m.sortColumn - 1 + sortMax) % sortMax
m.sortAsc = false
m.refreshLive()
}
case "r":
if m.currentTab == tabMonitors {
m.sortAsc = !m.sortAsc
m.refreshLive()
}
case "tab": case "tab":
m.switchTab(m.currentTab + 1) m.switchTab(m.currentTab + 1)
case "left": case "left":
+7 -1
View File
@@ -233,6 +233,12 @@ type tabEntry struct {
func (m Model) renderTabBar(stats dashboardStats) string { func (m Model) renderTabBar(stats dashboardStats) string {
settingsCount := len(m.alerts) + len(m.nodes) settingsCount := len(m.alerts) + len(m.nodes)
settingsWarn := stats.offlineNodes settingsWarn := stats.offlineNodes
for _, a := range m.alerts {
h := m.engine.GetAlertHealth(a.ID)
if !h.LastSendOK && !h.LastSendAt.IsZero() {
settingsWarn++
}
}
if m.isAdmin { if m.isAdmin {
settingsCount += len(m.users) settingsCount += len(m.users)
} }
@@ -308,7 +314,7 @@ func (m Model) renderFooter(stats dashboardStats) string {
} else if m.detailOpen { } else if m.detailOpen {
keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [l]Logs [↑/↓]Select [T]Theme [q]Quit" keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [l]Logs [↑/↓]Select [T]Theme [q]Quit"
} else { } else {
keys = "[/]Filter [i]Info [Enter]Detail [n]New [e]Edit [d]Del [l]Logs [T]Theme [Tab]Switch [q]Quit" keys = "[/]Filter [i]Info [Enter]Detail [</>]Sort [r]Reverse [n]New [e]Edit [d]Del [l]Logs [T]Theme [Tab]Switch [q]Quit"
} }
case tabMaint: case tabMaint:
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit" keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"