diff --git a/Makefile b/Makefile index 35ccfc9..85e24d2 100644 --- a/Makefile +++ b/Makefile @@ -43,11 +43,14 @@ test: test-integration: go test -tags=integration -p 1 ./... -race $(GOTESTFLAGS) +COMPOSE_PROFILE ?= +compose_profile_args = $(foreach p,$(COMPOSE_PROFILE),--profile $(p)) + compose-up: - $(DOCKER_COMPOSE) up -d --wait + $(DOCKER_COMPOSE) $(compose_profile_args) up -d --wait compose-down: - $(DOCKER_COMPOSE) down + $(DOCKER_COMPOSE) $(compose_profile_args) down lint: @command -v golangci-lint >/dev/null 2>&1 || { \ diff --git a/cmd/workload/main.go b/cmd/workload/main.go new file mode 100644 index 0000000..c40db63 --- /dev/null +++ b/cmd/workload/main.go @@ -0,0 +1,227 @@ +package main + +import ( + "context" + "fmt" + "log" + "math/rand/v2" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +const ( + // defaultDSN points at the local compose database; password is the dev one. + defaultDSN = "postgres://postgres:pass@postgres:5432/dev" //nolint:gosec // dev-only default DSN + poolSize = 20 + seedRows = 1000 + normalWorkers = 8 + extraWorkers = 4 // nPlusOne, longQueries, blocker, contender + hotRowID = 1 // the single row the blocker and contenders fight over +) + +func main() { + if err := run(); err != nil { + log.Fatalf("workload: %v", err) + } +} + +func run() error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + dsn := os.Getenv("WORKLOAD_DSN") + if dsn == "" { + dsn = defaultDSN + } + + pool, err := connect(ctx, dsn) + if err != nil { + return err + } + defer pool.Close() + + if err := setup(ctx, pool); err != nil { + return err + } + + log.Println("workload: generating traffic (ctrl-c to stop)") + + workers := make([]func(context.Context, *pgxpool.Pool), 0, normalWorkers+extraWorkers) + for range normalWorkers { + workers = append(workers, normalLoad) + } + + workers = append(workers, nPlusOne, longQueries, blocker, contender) + + var wg sync.WaitGroup + + for _, w := range workers { + wg.Go(func() { + w(ctx, pool) + }) + } + + <-ctx.Done() + wg.Wait() + log.Println("workload: stopped") + + return nil +} + +func connect(ctx context.Context, dsn string) (*pgxpool.Pool, error) { + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, fmt.Errorf("parse dsn: %w", err) + } + + cfg.MaxConns = poolSize + + for { + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err == nil { + if pingErr := pool.Ping(ctx); pingErr == nil { + return pool, nil + } else { + pool.Close() + err = pingErr + } + } + + log.Printf("workload: waiting for db: %v", err) + + if !wait(ctx, time.Second) { + return nil, fmt.Errorf("canceled while connecting: %w", ctx.Err()) + } + } +} + +func setup(ctx context.Context, pool *pgxpool.Pool) error { + // pg_stat_statements needs shared_preload_libraries; tolerate its absence. + if _, err := pool.Exec(ctx, `CREATE EXTENSION IF NOT EXISTS pg_stat_statements`); err != nil { + log.Printf("workload: pg_stat_statements unavailable: %v", err) + } + + schema := []string{ + `CREATE TABLE IF NOT EXISTS items ( + id int PRIMARY KEY, + name text NOT NULL, + value int NOT NULL + )`, + fmt.Sprintf(`INSERT INTO items + SELECT g, 'item-' || g, g + FROM generate_series(1, %d) g + ON CONFLICT (id) DO NOTHING`, seedRows), + } + + for _, stmt := range schema { + if _, err := pool.Exec(ctx, stmt); err != nil { + return fmt.Errorf("setup: %w", err) + } + } + + return nil +} + +// normalLoad does steady point reads and writes across random rows. +func normalLoad(ctx context.Context, pool *pgxpool.Pool) { + for wait(ctx, jitter(5*time.Millisecond, 45*time.Millisecond)) { + id := rand.IntN(seedRows) + 1 //nolint:gosec // weak RNG is fine for a load generator + + var ( + name string + value int + ) + + _ = pool.QueryRow(ctx, `SELECT name, value FROM items WHERE id = $1`, id).Scan(&name, &value) + _, _ = pool.Exec(ctx, `UPDATE items SET value = value + 1 WHERE id = $1`, id) + } +} + +// nPlusOne fetches a batch of ids then queries each one separately, the classic +// pattern that is cheap per call but dominates pg_stat_statements by count. +func nPlusOne(ctx context.Context, pool *pgxpool.Pool) { + for wait(ctx, 500*time.Millisecond) { + rows, err := pool.Query(ctx, `SELECT id FROM items ORDER BY random() LIMIT 50`) + if err != nil { + continue + } + + var ids []int + + for rows.Next() { + var id int + if err := rows.Scan(&id); err == nil { + ids = append(ids, id) + } + } + + rows.Close() + + for _, id := range ids { + var name string + + _ = pool.QueryRow(ctx, `SELECT name FROM items WHERE id = $1`, id).Scan(&name) + } + } +} + +// longQueries runs an occasional multi-second query so the Activity screen has +// a long DURATION to surface. +func longQueries(ctx context.Context, pool *pgxpool.Pool) { + for wait(ctx, 2*time.Second) { + seconds := rand.IntN(8) + 3 //nolint:gosec // weak RNG is fine for a load generator + + _, _ = pool.Exec(ctx, `SELECT pg_sleep($1)`, seconds) + } +} + +// blocker holds a row lock inside an open transaction, then sits idle, creating +// an idle-in-transaction backend that blocks the contender. +func blocker(ctx context.Context, pool *pgxpool.Pool) { + for wait(ctx, 3*time.Second) { + holdLock(ctx, pool) + } +} + +func holdLock(ctx context.Context, pool *pgxpool.Pool) { + tx, err := pool.Begin(ctx) + if err != nil { + return + } + defer func() { _ = tx.Rollback(ctx) }() + + if _, err := tx.Exec(ctx, `UPDATE items SET value = value WHERE id = $1`, hotRowID); err != nil { + return + } + + wait(ctx, 5*time.Second) + + _ = tx.Commit(ctx) +} + +// contender repeatedly updates the hot row, so it blocks whenever the blocker +// is holding the lock. +func contender(ctx context.Context, pool *pgxpool.Pool) { + for wait(ctx, time.Second) { + _, _ = pool.Exec(ctx, `UPDATE items SET value = value + 1 WHERE id = $1`, hotRowID) + } +} + +// wait sleeps for d, returning false if the context is canceled first. +func wait(ctx context.Context, d time.Duration) bool { + select { + case <-ctx.Done(): + return false + case <-time.After(d): + return true + } +} + +func jitter(minimum, maximum time.Duration) time.Duration { + return minimum + rand.N(maximum-minimum) //nolint:gosec // weak RNG is fine for a load generator +} diff --git a/codecov.yaml b/codecov.yaml new file mode 100644 index 0000000..e0ee7e5 --- /dev/null +++ b/codecov.yaml @@ -0,0 +1,2 @@ +ignore: + - "cmd/workload" diff --git a/compose.yaml b/compose.yaml index 18d1d70..1c65e7a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,11 +1,14 @@ services: postgres: image: postgres:16 + command: ["postgres", "-c", "shared_preload_libraries=pg_stat_statements"] environment: POSTGRES_PASSWORD: pass POSTGRES_DB: dev ports: - "5432:5432" + volumes: + - ./compose/initdb:/docker-entrypoint-initdb.d:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 2s @@ -24,3 +27,19 @@ services: interval: 2s timeout: 3s retries: 10 + + # Synthetic workload for developing dbtop. Opt-in via the "load" profile: + # make compose-up COMPOSE_PROFILE=load + workload: + image: golang:1.26-alpine + profiles: ["load"] + network_mode: "service:postgres" + working_dir: /src + command: ["go", "run", "./cmd/workload"] + environment: + WORKLOAD_DSN: postgres://postgres:pass@localhost:5432/dev + volumes: + - .:/src:ro + depends_on: + postgres: + condition: service_healthy diff --git a/compose/initdb/init.sql b/compose/initdb/init.sql new file mode 100644 index 0000000..5d869a9 --- /dev/null +++ b/compose/initdb/init.sql @@ -0,0 +1,4 @@ +-- Runs once on a fresh data directory (after postgres starts with +-- shared_preload_libraries=pg_stat_statements), so the Statements screen works +-- out of the box in local and CI databases. +CREATE EXTENSION IF NOT EXISTS pg_stat_statements; diff --git a/go.mod b/go.mod index 80f42f2..a0b6955 100644 --- a/go.mod +++ b/go.mod @@ -2,12 +2,36 @@ module github.com/mickamy/dbtop go 1.26.3 -require github.com/jackc/pgx/v5 v5.9.2 +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/jackc/pgx/v5 v5.9.2 +) require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.29.0 // indirect ) diff --git a/go.sum b/go.sum index f5b2410..2c62dc2 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,30 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -9,15 +33,39 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index b2957b3..0b44fb6 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -1,12 +1,25 @@ package cli import ( + "context" "fmt" "io" + "strings" + "time" + tea "github.com/charmbracelet/bubbletea" + + "github.com/mickamy/dbtop/internal/driver/postgres" "github.com/mickamy/dbtop/internal/exit" + "github.com/mickamy/dbtop/internal/tui" ) +// connectTimeout bounds the initial connect + version probe. +const connectTimeout = 10 * time.Second + +// defaultInterval is the poll interval until --interval is wired up. +const defaultInterval = time.Second + func Run(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { PrintUsage(stderr) @@ -16,9 +29,38 @@ func Run(args []string, stdout, stderr io.Writer) int { dsn := args[0] - fmt.Fprintf(stderr, "dbtop: live monitor not implemented yet (dsn %q)\n", dsn) + if strings.HasPrefix(dsn, "mysql") { + fmt.Fprintln(stderr, "dbtop: the MySQL driver is not implemented yet") + + return exit.Error + } + + ctx, cancel := context.WithTimeout(context.Background(), connectTimeout) + defer cancel() + + d, err := postgres.Open(ctx, dsn) + if err != nil { + fmt.Fprintf(stderr, "dbtop: %v\n", err) + + return exit.Error + } + defer func() { _ = d.Close() }() + + caps, err := d.Capabilities(ctx) + if err != nil { + fmt.Fprintf(stderr, "dbtop: %v\n", err) + + return exit.Error + } + + program := tea.NewProgram(tui.New(d, caps, defaultInterval), tea.WithAltScreen()) + if _, err := program.Run(); err != nil { + fmt.Fprintf(stderr, "dbtop: %v\n", err) + + return exit.Error + } - return exit.NotImplemented + return exit.OK } func PrintUsage(w io.Writer) { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..45a34e8 --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,38 @@ +package cli_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/mickamy/dbtop/internal/cli" + "github.com/mickamy/dbtop/internal/exit" +) + +func TestRunNoArgsPrintsUsage(t *testing.T) { + t.Parallel() + + var out, errOut bytes.Buffer + + if code := cli.Run(nil, &out, &errOut); code != exit.Usage { + t.Errorf("exit code = %d, want %d", code, exit.Usage) + } + + if !strings.Contains(errOut.String(), "USAGE:") { + t.Errorf("usage not printed:\n%s", errOut.String()) + } +} + +func TestRunMySQLNotImplemented(t *testing.T) { + t.Parallel() + + var out, errOut bytes.Buffer + + if code := cli.Run([]string{"mysql://root:pass@localhost:3306/db"}, &out, &errOut); code != exit.Error { + t.Errorf("exit code = %d, want %d", code, exit.Error) + } + + if !strings.Contains(errOut.String(), "MySQL") { + t.Errorf("expected a MySQL not-implemented message:\n%s", errOut.String()) + } +} diff --git a/internal/tui/export_test.go b/internal/tui/export_test.go new file mode 100644 index 0000000..7ab4028 --- /dev/null +++ b/internal/tui/export_test.go @@ -0,0 +1,11 @@ +package tui + +import tea "github.com/charmbracelet/bubbletea" + +func (m Model) CurrentTab() Tab { return m.tab } + +func (m Model) Paused() bool { return m.paused } + +func (m Model) AwaitingResult() bool { return m.awaitingResult } + +func TickMsg() tea.Msg { return tickMsg{} } diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..8cd1a2b --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,30 @@ +package tui + +import "github.com/charmbracelet/bubbles/key" + +type keyMap struct { + SwitchTab key.Binding + Pause key.Binding + Quit key.Binding +} + +func defaultKeys() keyMap { + return keyMap{ + SwitchTab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("⇥", "switch tab"), + ), + Pause: key.NewBinding( + key.WithKeys(" "), + key.WithHelp("␣", "pause"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), + } +} + +func (k keyMap) footerHints() []key.Binding { + return []key.Binding{k.SwitchTab, k.Pause, k.Quit} +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..6d2ca75 --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,176 @@ +package tui + +import ( + "context" + "time" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + + "github.com/mickamy/dbtop/internal/driver" +) + +// pollTimeout bounds a single poll, so a stuck query can't wedge the loop. +const pollTimeout = 5 * time.Second + +type ( + tickMsg struct{} + activityMsg []driver.Backend + metricsMsg driver.MetricSample + statementsMsg []driver.Statement + errMsg struct{ err error } +) + +type Model struct { + driver driver.Driver + caps driver.Capabilities + interval time.Duration + keys keyMap + + tab Tab + paused bool + + // awaitingResult is true while the poll/tick chain is alive; it stops a + // second chain from starting when unpausing before a pending tick fires. + awaitingResult bool + + backends []driver.Backend + metrics driver.MetricSample + statements []driver.Statement + err error + + width int + height int +} + +func New(d driver.Driver, caps driver.Capabilities, interval time.Duration) Model { + return Model{driver: d, caps: caps, interval: interval, keys: defaultKeys(), tab: TabActivity, awaitingResult: true} +} + +// Init seeds the poll loop with one immediate fetch; each result schedules the +// next tick, so only a single tick chain is ever in flight. +func (m Model) Init() tea.Cmd { + return m.poll() +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + + return m, nil + + case tea.KeyMsg: + return m.handleKey(msg) + + case tickMsg: + if m.paused { + m.awaitingResult = false + + return m, nil + } + + return m, m.poll() + + case activityMsg: + m.backends = msg + m.err = nil + + return m, m.scheduleTick() + + case metricsMsg: + m.metrics = driver.MetricSample(msg) + m.err = nil + + return m, m.scheduleTick() + + case statementsMsg: + m.statements = msg + m.err = nil + + return m, m.scheduleTick() + + case errMsg: + m.err = msg.err + + return m, m.scheduleTick() + } + + return m, nil +} + +func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + + case key.Matches(msg, m.keys.SwitchTab): + m.tab = m.tab.next() + + return m, nil + + case key.Matches(msg, m.keys.Pause): + m.paused = !m.paused + if m.paused { + return m, nil + } + + // A live chain resumes on its own; only reseed once it has stopped. + if m.awaitingResult { + return m, nil + } + + m.awaitingResult = true + + return m, m.poll() + } + + return m, nil +} + +func (m Model) scheduleTick() tea.Cmd { + return tea.Tick(m.interval, func(time.Time) tea.Msg { + return tickMsg{} + }) +} + +func (m Model) poll() tea.Cmd { + tab := m.tab + + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), pollTimeout) + defer cancel() + + switch tab { + case TabActivity: + backends, err := m.driver.Activity(ctx) + if err != nil { + return errMsg{err} + } + + return activityMsg(backends) + + case TabMetrics: + sample, err := m.driver.Metrics(ctx) + if err != nil { + return errMsg{err} + } + + return metricsMsg(sample) + + case TabStatements: + if !m.caps.Statements { + return statementsMsg(nil) + } + + statements, err := m.driver.Statements(ctx) + if err != nil { + return errMsg{err} + } + + return statementsMsg(statements) + } + + return nil + } +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go new file mode 100644 index 0000000..1ce7c32 --- /dev/null +++ b/internal/tui/model_test.go @@ -0,0 +1,278 @@ +package tui_test + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/mickamy/dbtop/internal/driver" + "github.com/mickamy/dbtop/internal/tui" +) + +type fakeDriver struct { + backends []driver.Backend + sample driver.MetricSample + statements []driver.Statement + err error +} + +func (f fakeDriver) Activity(context.Context) ([]driver.Backend, error) { + return f.backends, f.err +} + +func (f fakeDriver) Metrics(context.Context) (driver.MetricSample, error) { + return f.sample, f.err +} + +func (f fakeDriver) Statements(context.Context) ([]driver.Statement, error) { + return f.statements, f.err +} + +func (f fakeDriver) ResetStatements(context.Context) error { return nil } +func (f fakeDriver) Cancel(context.Context, int64) error { return nil } +func (f fakeDriver) Terminate(context.Context, int64) error { return nil } +func (f fakeDriver) Close() error { return nil } + +func (f fakeDriver) Capabilities(context.Context) (driver.Capabilities, error) { + return driver.Capabilities{}, nil +} + +func keyRunes(s string) tea.KeyMsg { + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} +} + +func TestActivityPollRenders(t *testing.T) { + t.Parallel() + + d := fakeDriver{backends: []driver.Backend{ + {PID: 42, User: "app", DB: "myapp", State: driver.StateActive}, + }} + + m := tui.New(d, driver.Capabilities{}, time.Second) + view := mustUpdate(t, m, m.Init()()).View() + + if !strings.Contains(view, "42") || !strings.Contains(view, "app") { + t.Errorf("view missing backend data:\n%s", view) + } +} + +func TestStatementsGuidanceWithoutExtension(t *testing.T) { + t.Parallel() + + // caps.Statements is false: the tab must guide, not poll and error. + m := tui.New(fakeDriver{}, driver.Capabilities{}, time.Second) + model, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab}) // Metrics + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyTab}) // Statements + + view := mustUpdate(t, model, asModel(t, model).Init()()).View() + + if !strings.Contains(view, "CREATE EXTENSION pg_stat_statements") { + t.Errorf("statements tab did not show guidance:\n%s", view) + } +} + +func TestBodyFitsHeight(t *testing.T) { + t.Parallel() + + backends := make([]driver.Backend, 50) + for i := range backends { + backends[i] = driver.Backend{PID: int64(i + 1), User: "app", DB: "db", State: driver.StateActive} + } + + const ( + width = 100 + height = 12 + ) + + var model tea.Model = tui.New(fakeDriver{backends: backends}, driver.Capabilities{}, time.Second) + model, _ = model.Update(tea.WindowSizeMsg{Width: width, Height: height}) + model = mustUpdate(t, model, asModel(t, model).Init()()) + + view := model.View() + + if lines := strings.Count(view, "\n") + 1; lines > height { + t.Errorf("view has %d lines, exceeds height %d:\n%s", lines, height, view) + } + + if !strings.Contains(view, "more") { + t.Errorf("expected a truncation indicator:\n%s", view) + } + + if !strings.Contains(view, "Activity") { + t.Errorf("tab bar dropped off the top:\n%s", view) + } + + // The tab bar must be the very first line, never scrolled off. + if first := strings.SplitN(view, "\n", 2)[0]; !strings.Contains(first, "Activity") { + t.Errorf("first line is not the tab bar: %q", first) + } +} + +// A wide row must be clipped to the width, otherwise it wraps and the wrapped +// lines push the tab bar off the top. +func TestRowsClippedToWidth(t *testing.T) { + t.Parallel() + + long := strings.Repeat("SELECT a, b, c FROM very_long_table_name x JOIN y ", 5) + statements := make([]driver.Statement, 20) + for i := range statements { + statements[i] = driver.Statement{Calls: 100, Query: long} + } + + const ( + width = 60 + height = 14 + ) + + var model tea.Model = tui.New(fakeDriver{statements: statements}, driver.Capabilities{Statements: true}, time.Second) + model, _ = model.Update(tea.WindowSizeMsg{Width: width, Height: height}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyTab}) // Metrics + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyTab}) // Statements + model = mustUpdate(t, model, asModel(t, model).Init()()) + + for line := range strings.SplitSeq(model.View(), "\n") { + if w := lipgloss.Width(line); w > width { + t.Errorf("line width %d exceeds %d: %q", w, width, line) + } + } +} + +func TestUnpauseDoesNotStartSecondPollLoop(t *testing.T) { + t.Parallel() + + // Pausing then unpausing before the pending tick fires must not start a + // second concurrent poll chain. + var model tea.Model = tui.New(fakeDriver{}, driver.Capabilities{}, time.Second) + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeySpace}) // pause + model, cmd := model.Update(tea.KeyMsg{Type: tea.KeySpace}) + + if cmd != nil { + t.Error("unpause while the chain is still alive should not reseed a poll") + } + + if !asModel(t, model).AwaitingResult() { + t.Error("chain should still be considered alive") + } +} + +func TestUnpauseResumesAfterChainStopped(t *testing.T) { + t.Parallel() + + var model tea.Model = tui.New(fakeDriver{}, driver.Capabilities{}, time.Second) + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeySpace}) // pause + model, _ = model.Update(tui.TickMsg()) // tick fires while paused: chain stops + + if asModel(t, model).AwaitingResult() { + t.Fatal("a tick while paused should stop the chain") + } + + model, cmd := model.Update(tea.KeyMsg{Type: tea.KeySpace}) // unpause + if cmd == nil { + t.Error("unpause after the chain stopped should reseed a poll") + } + + if !asModel(t, model).AwaitingResult() { + t.Error("chain should be alive again after reseeding") + } +} + +func TestMetricsTabRenders(t *testing.T) { + t.Parallel() + + d := fakeDriver{sample: driver.MetricSample{ + MaxConnections: 100, + Conns: driver.ConnCounts{Total: 14, Active: 9, Idle: 3, IdleInTx: 2}, + }} + + var model tea.Model = tui.New(d, driver.Capabilities{}, time.Second) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyTab}) // Metrics + model = mustUpdate(t, model, asModel(t, model).Init()()) // poll Metrics + + view := model.View() + if !strings.Contains(view, "14/100") { + t.Errorf("metrics tab missing connection summary:\n%s", view) + } +} + +func TestErrorIsRendered(t *testing.T) { + t.Parallel() + + d := fakeDriver{err: errors.New("boom")} + + m := tui.New(d, driver.Capabilities{}, time.Second) + view := mustUpdate(t, m, m.Init()()).View() + + if !strings.Contains(view, "boom") { + t.Errorf("error not surfaced in view:\n%s", view) + } +} + +func TestQuitKey(t *testing.T) { + t.Parallel() + + m := tui.New(fakeDriver{}, driver.Capabilities{}, time.Second) + + _, cmd := m.Update(keyRunes("q")) + if cmd == nil { + t.Fatal("expected a command for q") + } + + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Errorf("q produced %T, want tea.QuitMsg", cmd()) + } +} + +func TestTabKeyCycles(t *testing.T) { + t.Parallel() + + var model tea.Model = tui.New(fakeDriver{}, driver.Capabilities{}, time.Second) + + for _, want := range []tui.Tab{tui.TabMetrics, tui.TabStatements, tui.TabActivity} { + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyTab}) + if got := asModel(t, model).CurrentTab(); got != want { + t.Errorf("tab = %v, want %v", got, want) + } + } +} + +func TestSpaceTogglesPause(t *testing.T) { + t.Parallel() + + var model tea.Model = tui.New(fakeDriver{}, driver.Capabilities{}, time.Second) + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeySpace}) + if !asModel(t, model).Paused() { + t.Error("space did not pause") + } + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeySpace}) + if asModel(t, model).Paused() { + t.Error("space did not unpause") + } +} + +func mustUpdate(t *testing.T, m tea.Model, msg tea.Msg) tea.Model { + t.Helper() + + updated, _ := m.Update(msg) + + return updated +} + +func asModel(t *testing.T, m tea.Model) tui.Model { + t.Helper() + + got, ok := m.(tui.Model) + if !ok { + t.Fatalf("model is %T, want tui.Model", m) + } + + return got +} diff --git a/internal/tui/tab.go b/internal/tui/tab.go new file mode 100644 index 0000000..ec72a7f --- /dev/null +++ b/internal/tui/tab.go @@ -0,0 +1,31 @@ +package tui + +// Tab is one of the three top-level screens. +type Tab int + +const ( + TabActivity Tab = iota + TabMetrics + TabStatements +) + +func (t Tab) String() string { + switch t { + case TabActivity: + return "Activity" + case TabMetrics: + return "Metrics" + case TabStatements: + return "Statements" + } + + return "" +} + +func (t Tab) next() Tab { + if t == TabStatements { + return TabActivity + } + + return t + 1 +} diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 0000000..027ebfb --- /dev/null +++ b/internal/tui/view.go @@ -0,0 +1,203 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" +) + +var ( + activeTabStyle = lipgloss.NewStyle().Bold(true).Underline(true) + dimStyle = lipgloss.NewStyle().Faint(true) + errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) +) + +// fallbackHeight is used before the first WindowSizeMsg arrives. +const fallbackHeight = 24 + +// View assembles the frame as exact lines — tab bar, body, footer — so the +// total never exceeds the terminal height and the tab bar and footer stay put. +func (m Model) View() string { + header := []string{m.tabBar(), ""} + if m.err != nil { + header = append(header, errStyle.Render("error: "+m.err.Error()), "") + } + + footer := []string{m.footer()} + + height := m.height + if height <= 0 { + height = fallbackHeight + } + + bodyMax := max(height-len(header)-len(footer), 1) + + lines := make([]string, 0, len(header)+bodyMax+len(footer)) + lines = append(lines, header...) + lines = append(lines, m.bodyLines(bodyMax)...) + lines = append(lines, footer...) + + return strings.Join(lines, "\n") +} + +func (m Model) tabBar() string { + tabs := make([]string, 0, 3) + + for _, t := range []Tab{TabActivity, TabMetrics, TabStatements} { + if t == m.tab { + tabs = append(tabs, activeTabStyle.Render(t.String())) + } else { + tabs = append(tabs, dimStyle.Render(t.String())) + } + } + + title := "dbtop" + if m.paused { + title += " " + dimStyle.Render("[paused]") + } + + return title + " " + strings.Join(tabs, " ") +} + +func (m Model) bodyLines(maxLines int) []string { + switch m.tab { + case TabActivity: + return m.activityLines(maxLines) + case TabMetrics: + return m.metricsLines() + case TabStatements: + return m.statementsLines(maxLines) + } + + return nil +} + +func (m Model) activityLines(maxLines int) []string { + if len(m.backends) == 0 { + return []string{dimStyle.Render("no backends")} + } + + lines := []string{fmt.Sprintf("%d backends", len(m.backends))} + show, more := capRows(len(m.backends), maxLines-1) + + for _, be := range m.backends[:show] { + dur := "—" + if d, ok := be.Duration(); ok { + dur = d.Truncate(time.Millisecond).String() + } + + wait := be.Wait() + if wait == "" { + wait = "—" + } + + row := fmt.Sprintf(" %-7d %s@%s %-14s %-10s %s", + be.PID, be.User, be.DB, be.State, dur, wait) + lines = append(lines, m.clip(row)) + } + + return appendMore(lines, more) +} + +func (m Model) metricsLines() []string { + c := m.metrics.Conns + + return []string{ + m.clip(fmt.Sprintf("connections %d/%d active %d idle %d idle-tx %d", + c.Total, m.metrics.MaxConnections, c.Active, c.Idle, c.IdleInTx)), + m.clip(fmt.Sprintf("waiting locks %d replicas %d", + m.metrics.WaitingLocks, len(m.metrics.Replicas))), + } +} + +func (m Model) statementsLines(maxLines int) []string { + if !m.caps.Statements { + return []string{ + dimStyle.Render("pg_stat_statements is not installed."), + dimStyle.Render("run: CREATE EXTENSION pg_stat_statements;"), + } + } + + if len(m.statements) == 0 { + return []string{dimStyle.Render("no statements yet")} + } + + lines := []string{fmt.Sprintf("%d statements", len(m.statements))} + show, more := capRows(len(m.statements), maxLines-1) + + for _, s := range m.statements[:show] { + row := fmt.Sprintf(" calls %-8d total %-12s mean %-10s %s", + s.Calls, + s.Total.Truncate(time.Microsecond), + s.Mean.Truncate(time.Microsecond), + truncate(s.Query, 60)) + lines = append(lines, m.clip(row)) + } + + return appendMore(lines, more) +} + +func (m Model) footer() string { + hints := m.keys.footerHints() + parts := make([]string, 0, len(hints)) + + for _, b := range hints { + h := b.Help() + parts = append(parts, h.Key+" "+h.Desc) + } + + return dimStyle.Render(strings.Join(parts, " · ")) +} + +func appendMore(lines []string, more int) []string { + if more > 0 { + lines = append(lines, dimStyle.Render(fmt.Sprintf(" … %d more", more))) + } + + return lines +} + +// capRows splits total items into how many to show within budget and how many +// remain, reserving one line for the "… N more" note when truncating. +func capRows(total, budget int) (show, more int) { + if budget <= 0 { + return 0, total + } + + if total <= budget { + return total, 0 + } + + return budget - 1, total - (budget - 1) +} + +// clip shortens a plain (unstyled) line to the terminal width so it never wraps. +func (m Model) clip(s string) string { + if m.width <= 0 { + return s + } + + r := []rune(s) + if len(r) <= m.width { + return s + } + + if m.width == 1 { + return "…" + } + + return string(r[:m.width-1]) + "…" +} + +func truncate(s string, n int) string { + s = strings.Join(strings.Fields(s), " ") + + r := []rune(s) + if len(r) <= n { + return s + } + + return string(r[:n-1]) + "…" +}