Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 15 additions & 12 deletions top/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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{}),
}
}
52 changes: 52 additions & 0 deletions top/config_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions top/dialog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions top/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions top/keybindings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)},
Expand Down
216 changes: 216 additions & 0 deletions top/scroll_test.go
Original file line number Diff line number Diff line change
@@ -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:
}
}
Loading