diff --git a/top/config.go b/top/config.go index fe45b3d..0dce5ec 100644 --- a/top/config.go +++ b/top/config.go @@ -15,16 +15,18 @@ const defaultRefresh = time.Second // config defines 'top' program runtime configuration. type config struct { - view view.View // Current active view. - views view.Views // List of all available views. - queryOptions query.Options // Queries' settings that might depend on Postgres version. - viewCh chan view.View // Channel used for passing view settings to stats goroutine. - logtail stat.Logfile // Logfile used for working with Postgres log file. - dialog dialogType // Remember current user-started dialog, used for selecting needed dialog handler. - menu menuStyle // When working with menus, keep properties of the menu. - procMask int // Process mask used for selecting group of process. - scrollOffset int // Horizontal scroll position: index into scrollable columns (1..Ncols-1); 0 means no scroll. Ephemeral, reset on view switch. - verbose bool // Verbose display mode for the top summary panels. Persistent: unlike scrollOffset, it is NOT reset on view switch (mirrored into every views entry). + view view.View // Current active view. + views view.Views // List of all available views. + queryOptions query.Options // Queries' settings that might depend on Postgres version. + viewCh chan view.View // Channel used for passing view settings to stats goroutine. + redrawCh chan struct{} // Channel used for requesting a redraw without changing collector settings. + logtail stat.Logfile // Logfile used for working with Postgres log file. + dialog dialogType // Remember current user-started dialog, used for selecting needed dialog handler. + menu menuStyle // When working with menus, keep properties of the menu. + procMask int // Process mask used for selecting group of process. + scrollOffset int // Horizontal scroll position: index into scrollable columns (1..Ncols-1); 0 means no scroll. Ephemeral, reset on view switch. + verticalOffset int // Vertical scroll position in dbstat rows. Ephemeral, reset on view switch. + verbose bool // Verbose display mode for the top summary panels. Persistent: unlike scrollOffset, it is NOT reset on view switch (mirrored into every views entry). // autoScrollToOrderKey is a one-shot request to bring the sort column into the visible window. // It is set by the sort handlers (orderKeyLeft/orderKeyRight) and consumed — and cleared — by // renderDbstat on the next frame, so manual [ / ] scrolling afterwards is never undone by the @@ -45,7 +47,8 @@ func newConfig() *config { views := view.New() return &config{ - views: views, - viewCh: make(chan view.View), + views: views, + viewCh: make(chan view.View), + redrawCh: make(chan struct{}), } } diff --git a/top/config_view.go b/top/config_view.go index 90361a4..0deb1fd 100644 --- a/top/config_view.go +++ b/top/config_view.go @@ -30,6 +30,7 @@ func orderKeyLeft(config *config) func(_ *gocui.Gui, _ *gocui.View) error { // itself is not computed here: column widths are known only after alignment against real // data, which happens on the render path. config.autoScrollToOrderKey = true + config.verticalOffset = 0 config.viewCh <- config.view return nil @@ -46,6 +47,7 @@ func orderKeyRight(config *config) func(_ *gocui.Gui, _ *gocui.View) error { // See orderKeyLeft: the scroll is deferred to the next render, which knows the widths. config.autoScrollToOrderKey = true + config.verticalOffset = 0 config.viewCh <- config.view return nil @@ -83,6 +85,52 @@ func scrollRight(config *config) func(_ *gocui.Gui, _ *gocui.View) error { } } +// scrollPage moves the vertical origin of the main statistics view by one visible page. +// The upper bound is applied during rendering, when the filtered row count is available. +func scrollPage(config *config, direction int) func(g *gocui.Gui, _ *gocui.View) error { + return func(g *gocui.Gui, _ *gocui.View) error { + v, err := g.View("dbstat") + if err != nil { + return err + } + _, height := v.Size() + config.verticalOffset = pageOffset(config.verticalOffset, direction, scrollPageSize(height)) + requestRedraw(config) + return nil + } +} + +func requestRedraw(config *config) { + config.redrawCh <- struct{}{} +} + +// scrollPageSize reserves one row for the fixed header and keeps the scroll step positive. +func scrollPageSize(height int) int { + if height <= 1 { + return 1 + } + return height - 1 +} + +func pageOffset(current, direction, pageSize int) int { + if pageSize < 1 { + pageSize = 1 + } + next := current + direction*pageSize + if next < 0 { + return 0 + } + return next +} + +func scrollPageUp(config *config) func(*gocui.Gui, *gocui.View) error { + return scrollPage(config, -1) +} + +func scrollPageDown(config *config) func(*gocui.Gui, *gocui.View) error { + return scrollPage(config, 1) +} + // increaseWidth increases visible width of current column. func increaseWidth(config *config) func(_ *gocui.Gui, _ *gocui.View) error { return func(_ *gocui.Gui, _ *gocui.View) error { @@ -113,6 +161,7 @@ func decreaseWidth(config *config) func(_ *gocui.Gui, _ *gocui.View) error { func switchSortOrder(config *config) func(g *gocui.Gui, _ *gocui.View) error { return func(g *gocui.Gui, _ *gocui.View) error { config.view.OrderDesc = !config.view.OrderDesc + config.verticalOffset = 0 printCmdline(g, "Switch sort order") config.viewCh <- config.view @@ -197,6 +246,7 @@ func clearFilters(config *config) func(g *gocui.Gui, _ *gocui.View) error { // Notify the stats goroutine only when something has been removed. viewCh is // unbuffered and nobody is expected to read an update that changes nothing. if n > 0 { + config.verticalOffset = 0 config.viewCh <- config.view } @@ -316,6 +366,7 @@ func viewSwitchHandler(config *config, c string) { config.views[config.view.Name] = config.view config.view = config.views[c] config.scrollOffset = 0 // horizontal scroll is ephemeral; reset on view switch + config.verticalOffset = 0 // vertical scroll is ephemeral; reset on view switch config.autoScrollToOrderKey = false // a pending auto-scroll must not fire on the new screen config.viewCh <- config.view } @@ -331,6 +382,7 @@ func switchViewToProcPidStat(app *app) func(g *gocui.Gui, _ *gocui.View) error { // Horizontal scroll is ephemeral; reset it when entering the per-process // screen. This path bypasses viewSwitchHandler, so the reset is done here. app.config.scrollOffset = 0 + app.config.verticalOffset = 0 // Same for a pending auto-scroll request: it belongs to the outgoing screen's sort // column. Reset before the local-mode guard below, so the switch cannot leave it armed. diff --git a/top/dialog.go b/top/dialog.go index 2303370..9ba8363 100644 --- a/top/dialog.go +++ b/top/dialog.go @@ -215,6 +215,7 @@ func dialogFinish(app *app) func(g *gocui.Gui, v *gocui.View) error { message = doReload(answer, app.db) case dialogFilter: message = setFilter(answer, app.config.view) + app.config.verticalOffset = 0 case dialogCancelQuery: message = killSingle(app.db, "cancel", answer) case dialogTerminateBackend: diff --git a/top/help.go b/top/help.go index 5bb585f..db372af 100644 --- a/top/help.go +++ b/top/help.go @@ -22,6 +22,7 @@ general actions: \ '\' clear all filters of the current screen. Up,Down 'Up' increase column width, 'Down' decrease column width. [,] '[' scroll columns left, ']' scroll columns right. + PgUp,PgDn previous/next page of statistics. C,E,R config: 'C' show config, 'E' edit configs, 'R' reload config. ~ start psql session. l open log file with pager. diff --git a/top/keybindings.go b/top/keybindings.go index 75c1c2e..86c627b 100644 --- a/top/keybindings.go +++ b/top/keybindings.go @@ -23,6 +23,8 @@ func keybindings(app *app) error { {"sysstat", gocui.KeyArrowRight, orderKeyRight(app.config)}, {"sysstat", gocui.KeyArrowUp, increaseWidth(app.config)}, {"sysstat", gocui.KeyArrowDown, decreaseWidth(app.config)}, + {"sysstat", gocui.KeyPgup, scrollPageUp(app.config)}, + {"sysstat", gocui.KeyPgdn, scrollPageDown(app.config)}, {"sysstat", '[', scrollLeft(app.config)}, {"sysstat", ']', scrollRight(app.config)}, {"sysstat", '<', switchSortOrder(app.config)}, diff --git a/top/scroll_test.go b/top/scroll_test.go new file mode 100644 index 0000000..95bba00 --- /dev/null +++ b/top/scroll_test.go @@ -0,0 +1,216 @@ +package top + +import ( + "bytes" + "database/sql" + "fmt" + "regexp" + "testing" + + "github.com/jroimartin/gocui" + "github.com/lesovsky/pgcenter/internal/stat" + "github.com/lesovsky/pgcenter/internal/view" +) + +func TestPageOffset(t *testing.T) { + tests := []struct { + name, direction string + current, size int + want int + }{ + {name: "down", direction: "down", current: 0, size: 10, want: 10}, + {name: "multiple down", direction: "down", current: 10, size: 10, want: 20}, + {name: "up", direction: "up", current: 20, size: 10, want: 10}, + {name: "up clamps at top", direction: "up", current: 3, size: 10, want: 0}, + {name: "zero page size", direction: "down", current: 0, size: 0, want: 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + direction := 1 + if tt.direction == "up" { + direction = -1 + } + if got := pageOffset(tt.current, direction, tt.size); got != tt.want { + t.Fatalf("pageOffset(%d, %d, %d) = %d, want %d", tt.current, direction, tt.size, got, tt.want) + } + }) + } +} + +func TestRenderedDbstatRowsHonorsFilters(t *testing.T) { + s := stat.Stat{Pgstat: stat.Pgstat{Result: stat.PGresult{ + Nrows: 3, + Ncols: 2, + Values: [][]sql.NullString{ + {{String: "keep", Valid: true}, {String: "one", Valid: true}}, + {{String: "drop", Valid: true}, {String: "two", Valid: true}}, + {{String: "keep", Valid: true}, {String: "three", Valid: true}}, + }, + }}} + + filters := map[int]*regexp.Regexp{0: regexp.MustCompile("keep")} + if got := renderedDbstatRows(s, filters); got != 2 { + t.Fatalf("renderedDbstatRows(filtered) = %d, want 2", got) + } + if got := renderedDbstatRows(s, nil); got != 3 { + t.Fatalf("renderedDbstatRows(unfiltered) = %d, want 3", got) + } +} + +func TestRenderedDbstatRowsIgnoresFiltersOutsideResult(t *testing.T) { + s := makeRenderResult(2, 20) + filters := map[int]*regexp.Regexp{7: regexp.MustCompile("keep")} + + if got := renderedDbstatRows(s, filters); got != s.Result.Nrows { + t.Fatalf("renderedDbstatRows(stale filter) = %d, want %d", got, s.Result.Nrows) + } + + cfg := makeRenderConfig(2, 10) + cfg.view.Filters = filters + var buf bytes.Buffer + win := visibleColumns(s.Result.Ncols, cfg.view.ColsWidth, 80, 0) + if err := printStatDataRange(&buf, s, cfg, true, win, 0, -1); err != nil { + t.Fatal(err) + } + if got := bytes.Count(buf.Bytes(), []byte("\n")); got != s.Result.Nrows { + t.Fatalf("renderer printed %d rows with stale filter, want %d", got, s.Result.Nrows) + } +} + +func TestRenderDbstatWindowPreservesSnapshotForRedraw(t *testing.T) { + cfg := makeRenderConfig(2, 10) + cfg.view.Filters[0] = regexp.MustCompile("suffix$") + s := makeRenderResult(2, 1) + s.Result.Values[0][0].String = "long-relation-suffix" + + for redraw := 1; redraw <= 2; redraw++ { + var buf bytes.Buffer + if err := renderDbstatWindow(&buf, cfg, s, 80, 0, 1); err != nil { + t.Fatal(err) + } + if got := renderedDbstatRows(s, cfg.view.Filters); got != 1 { + t.Fatalf("redraw %d changed filtered row count to %d, want 1", redraw, got) + } + if got := s.Result.Values[0][0].String; got != "long-relation-suffix" { + t.Fatalf("redraw %d changed snapshot value to %q", redraw, got) + } + } +} + +func TestRenderDbstatWindowKeepsHeaderWhenPaged(t *testing.T) { + cfg := makeRenderConfig(2, 10) + s := makeRenderResult(2, 20) + var buf bytes.Buffer + + if err := renderDbstatWindow(&buf, cfg, s, 80, 10, 3); err != nil { + t.Fatal(err) + } + out := buf.String() + if !bytes.Contains([]byte(out), []byte("col0")) { + t.Fatal("paged render must keep the header") + } + if !bytes.Contains([]byte(out), []byte(fmt.Sprintf("r%d-c0", 10))) { + t.Fatal("paged render must start at the requested data row") + } + if bytes.Contains([]byte(out), []byte("r0-c0")) { + t.Fatal("paged render must omit rows before the requested offset") + } +} + +func TestRenderDbstatWindowKeepsHorizontalOffset(t *testing.T) { + cfg := makeRenderConfig(7, 10) + cfg.scrollOffset = 1 + s := makeRenderResult(7, 20) + var buf bytes.Buffer + + wantOffset := visibleColumns(s.Result.Ncols, cfg.view.ColsWidth, 40, cfg.scrollOffset).clamped + if err := renderDbstatWindow(&buf, cfg, s, 40, 10, 3); err != nil { + t.Fatal(err) + } + if cfg.scrollOffset != wantOffset { + t.Fatalf("vertical paging changed horizontal offset to %d, want %d", cfg.scrollOffset, wantOffset) + } + if !bytes.Contains(buf.Bytes(), []byte("col2")) { + t.Fatal("paged render must keep the horizontally selected columns") + } + if bytes.Contains(buf.Bytes(), []byte("col1")) { + t.Fatal("paged render must not reset the horizontal window") + } +} + +func TestClampVerticalOffset(t *testing.T) { + tests := []struct { + name string + offset int + dataRows int + visibleRows int + want int + }{ + {name: "empty", offset: 4, dataRows: 0, visibleRows: 10, want: 0}, + {name: "fits", offset: 2, dataRows: 5, visibleRows: 10, want: 0}, + {name: "exact page", offset: 1, dataRows: 10, visibleRows: 10, want: 1}, + {name: "clamp bottom", offset: 99, dataRows: 20, visibleRows: 10, want: 11}, + {name: "clamp top", offset: -1, dataRows: 20, visibleRows: 10, want: 0}, + {name: "zero height", offset: 3, dataRows: 2, visibleRows: 0, want: 3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := clampVerticalOffset(tt.offset, tt.dataRows, tt.visibleRows); got != tt.want { + t.Fatalf("clampVerticalOffset(%d, %d, %d) = %d, want %d", tt.offset, tt.dataRows, tt.visibleRows, got, tt.want) + } + }) + } +} + +func TestPageSizeLeavesRoomForHeader(t *testing.T) { + for _, tt := range []struct { + height, want int + }{{20, 19}, {2, 1}, {1, 1}, {0, 1}, {-1, 1}} { + pageSize := scrollPageSize(tt.height) + if pageSize != tt.want { + t.Fatalf("page size for height %d = %d, want %d", tt.height, pageSize, tt.want) + } + } +} + +func TestScrollPageHandlersRequestRedrawWithoutChangingView(t *testing.T) { + g := &gocui.Gui{} + if _, err := g.SetView("dbstat", 0, 0, 80, 21); err != gocui.ErrUnknownView { + t.Fatal(err) + } + + cfg := newConfig() + cfg.redrawCh = make(chan struct{}, 2) + cfg.viewCh = make(chan view.View, 2) + + if err := scrollPageDown(cfg)(g, nil); err != nil { + t.Fatal(err) + } + if cfg.verticalOffset != 19 { + t.Fatalf("verticalOffset = %d, want 19", cfg.verticalOffset) + } + select { + case <-cfg.redrawCh: + default: + t.Fatal("page down did not request redraw") + } + + if err := scrollPageUp(cfg)(g, nil); err != nil { + t.Fatal(err) + } + if cfg.verticalOffset != 0 { + t.Fatalf("verticalOffset after page up = %d, want 0", cfg.verticalOffset) + } + select { + case <-cfg.redrawCh: + default: + t.Fatal("page up did not request redraw") + } + + select { + case <-cfg.viewCh: + t.Fatal("page scroll must not send a view update to the collector") + default: + } +} diff --git a/top/stat.go b/top/stat.go index 0fef409..29c6eac 100644 --- a/top/stat.go +++ b/top/stat.go @@ -694,9 +694,57 @@ func printDbstat(v *gocui.View, config *config, s stat.Stat) error { // Terminal width drives the visible-column window. dbstat is created with // Frame=false, so Size() returns the true drawing width. - termWidth, _ := v.Size() + termWidth, termHeight := v.Size() + config.verticalOffset = clampVerticalOffset(config.verticalOffset, renderedDbstatRows(s, config.view.Filters), termHeight) + ox, _ := v.Origin() + if err := v.SetOrigin(ox, 0); err != nil { + return fmt.Errorf("set dbstat vertical origin failed: %w", err) + } + + return renderDbstatWindow(v, config, s, termWidth, config.verticalOffset, termHeight-1) +} + +// renderedDbstatRows returns the number of data rows that printStatData will emit after +// filtering. The header is accounted for by clampVerticalOffset separately. +func renderedDbstatRows(s stat.Stat, filters map[int]*regexp.Regexp) int { + rows := 0 + for rownum := 0; rownum < s.Result.Nrows; rownum++ { + if dbstatRowMatchesFilters(s, rownum, filters) { + rows++ + } + } + return rows +} + +// dbstatRowMatchesFilters reports whether a row passes the active filters. Filter indexes +// that do not exist in the current result are ignored, as they are during rendering of a +// transition frame after a view switch. +func dbstatRowMatchesFilters(s stat.Stat, rownum int, filters map[int]*regexp.Regexp) bool { + active := false + for i, re := range filters { + if re == nil || i < 0 || i >= s.Result.Ncols { + continue + } + active = true + if re.MatchString(s.Result.Values[rownum][i].String) { + return true + } + } + return !active +} - return renderDbstat(v, config, s, termWidth) +func clampVerticalOffset(offset, dataRows, visibleRows int) int { + if offset < 0 { + offset = 0 + } + maxOffset := dataRows + 1 - visibleRows // one header row precedes the data + if maxOffset < 0 { + maxOffset = 0 + } + if offset > maxOffset { + return maxOffset + } + return offset } // renderDbstat is the writer-based core of printDbstat: it clamps the scroll offset, @@ -704,6 +752,12 @@ func printDbstat(v *gocui.View, config *config, s stat.Stat) error { // resolves the terminal width from the gocui view) so the render can be unit-tested // without a live terminal. func renderDbstat(w io.Writer, config *config, s stat.Stat, termWidth int) error { + return renderDbstatWindow(w, config, s, termWidth, 0, -1) +} + +// renderDbstatWindow renders the header and a window of filtered data rows. startRow and +// rowLimit apply only to data rows, so the header remains visible while paging. +func renderDbstatWindow(w io.Writer, config *config, s stat.Stat, termWidth, startRow, rowLimit int) error { // One-shot auto-scroll: a sort-column change asked for that column to be brought into the // window. It is consumed here rather than in the key handler because column widths are known // only after alignViewToResult has run against real data (printDbstat). The flag is cleared @@ -740,7 +794,7 @@ func renderDbstat(w io.Writer, config *config, s stat.Stat, termWidth int) error } // Print data. - return printStatData(w, s, config, isFilterRequired(config.view.Filters), win) + return printStatDataRange(w, s, config, isFilterRequired(config.view.Filters), win, startRow, rowLimit) } // formatError returns formatted error string depending on its type. @@ -1029,6 +1083,10 @@ func printHeaderCell(w io.Writer, s stat.Stat, config *config, i int) error { // colnum counter is removed) so windowed rendering keeps each value aligned with its // column. func printStatData(w io.Writer, s stat.Stat, config *config, filter bool, win columnWindow) error { + return printStatDataRange(w, s, config, filter, win, 0, -1) +} + +func printStatDataRange(w io.Writer, s stat.Stat, config *config, filter bool, win columnWindow, startRow, rowLimit int) error { // Blank fillers mirroring the header's edge markers: the header prints a marker rune on // each hidden side, so each data row prints markerWidth spaces in the same place. This is // the alignment invariant — the visible width of the header row equals that of every data @@ -1042,27 +1100,19 @@ func printStatData(w io.Writer, s stat.Stat, config *config, filter bool, win co rightMarker = strings.Repeat(" ", markerWidth) } - var doPrint bool + renderedRows := 0 for rownum := 0; rownum < s.Result.Nrows; rownum++ { - // be optimistic, we want to print the row. - doPrint = true - - // apply filters using regexp - if filter { - for i := 0; i < s.Result.Ncols; i++ { - if config.view.Filters[i] != nil { - if config.view.Filters[i].MatchString(s.Result.Values[rownum][i].String) { - doPrint = true - break - } - doPrint = false - } - } + if filter && !dbstatRowMatchesFilters(s, rownum, config.view.Filters) { + continue } - - if !doPrint { + if renderedRows < startRow { + renderedRows++ continue } + if rowLimit >= 0 && renderedRows >= startRow+rowLimit { + break + } + renderedRows++ // print frozen column 0 value first, then the windowed columns. if err := printDataCell(w, s, config, rownum, 0); err != nil { @@ -1101,8 +1151,10 @@ func printStatData(w io.Writer, s stat.Stat, config *config, filter bool, win co // than the column width (replacing the last character with '~') and padding to the column // width plus the +2 gap. Returns an error for a zero or negative column width. func printDataCell(w io.Writer, s stat.Stat, config *config, rownum, i int) error { + value := s.Result.Values[rownum][i].String + // truncate values that are longer than column width - valuelen := len(s.Result.Values[rownum][i].String) + valuelen := len(value) if valuelen > config.view.ColsWidth[i] { width := config.view.ColsWidth[i] if width <= 0 { @@ -1110,11 +1162,11 @@ func printDataCell(w io.Writer, s stat.Stat, config *config, rownum, i int) erro } // truncate value up to column width and replace last character with '~' symbol - s.Result.Values[rownum][i].String = s.Result.Values[rownum][i].String[:width-1] + "~" + value = value[:width-1] + "~" } // print value - _, err := fmt.Fprintf(w, "%-*s", config.view.ColsWidth[i]+2, s.Result.Values[rownum][i].String) + _, err := fmt.Fprintf(w, "%-*s", config.view.ColsWidth[i]+2, value) return err } diff --git a/top/ui.go b/top/ui.go index 6c026b3..95446c3 100644 --- a/top/ui.go +++ b/top/ui.go @@ -106,6 +106,8 @@ func mainLoop(ctx context.Context, app *app) error { func doWork(ctx context.Context, app *app) { var wg sync.WaitGroup statCh := make(chan stat.Stat) + var latest stat.Stat + haveLatest := false wg.Add(1) go func() { @@ -126,7 +128,13 @@ func doWork(ctx context.Context, app *app) { // used for exit from UI (not the program) in case when need to open $PAGER or $EDITOR programs. return case s := <-statCh: + latest = s + haveLatest = true printStat(app, s, app.postgresProps) + case <-app.config.redrawCh: + if haveLatest { + printStat(app, latest, app.postgresProps) + } case <-ctx.Done(): wg.Wait() return