From d982359f25e3bb9273c9abee56281f2fb7a5f922 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sun, 21 Jun 2026 19:09:42 -0400 Subject: [PATCH] feat(tui): column sort with indicator on monitors table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- internal/tui/data.go | 26 ++++++++++++++++++++++---- internal/tui/data_test.go | 6 +++--- internal/tui/tab_sites.go | 18 ++++++++++++++++++ internal/tui/tui.go | 9 +++++++++ internal/tui/update.go | 17 +++++++++++++++++ internal/tui/view_dashboard.go | 2 +- 6 files changed, 70 insertions(+), 8 deletions(-) diff --git a/internal/tui/data.go b/internal/tui/data.go index 03c9532..cbc7b65 100644 --- a/internal/tui/data.go +++ b/internal/tui/data.go @@ -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 children := make(map[int][]models.Site) 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]) }) 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 for _, g := range groups { @@ -99,7 +117,7 @@ func filterSites(sites []models.Site, needle string) []models.Site { // separately via loadTabDataCmd. func (m *Model) refreshLive() { allSites := m.engine.GetAllSites() - ordered := sortSitesForDisplay(allSites, m.collapsed) + ordered := sortSitesForDisplay(allSites, m.collapsed, m.sortColumn, m.sortAsc) if m.filterText != "" { ordered = filterSites(ordered, m.filterText) } diff --git a/internal/tui/data_test.go b/internal/tui/data_test.go index d677298..e7c0687 100644 --- a/internal/tui/data_test.go +++ b/internal/tui/data_test.go @@ -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: 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 { 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"}}, } collapsed := map[int]bool{1: true} - result := sortSitesForDisplay(sites, collapsed) + result := sortSitesForDisplay(sites, collapsed, sortStatus, false) if len(result) != 1 { 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: 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" { t.Errorf("DOWN should sort first, got %s", result[0].Status) } diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index de1d01e..66890e3 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -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) borderOverhead := 2 + (numCols - 1) avail := cw - chromePadH - 2 - borderOverhead - fixed diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 6f39829..31e002d 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -102,6 +102,13 @@ const ( panelDetail = 2 ) +const ( + sortStatus = 0 + sortName = 1 + sortLatency = 2 + sortMax = 3 +) + type sessionState int const ( @@ -125,6 +132,8 @@ type Model struct { settingsSection int cursor int selectedID int + sortColumn int + sortAsc bool tableOffset int maxTableRows int termWidth int diff --git a/internal/tui/update.go b/internal/tui/update.go index 0e7c1b0..2e40428 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -532,6 +532,23 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.recalcLayout() 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": m.switchTab(m.currentTab + 1) case "left": diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index d218b73..197c1be 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -308,7 +308,7 @@ func (m Model) renderFooter(stats dashboardStats) string { } else if m.detailOpen { keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [l]Logs [↑/↓]Select [T]Theme [q]Quit" } 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: keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"