Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
19e5574
feat: define the driver interface and domain types
mickamy May 27, 2026
0838c39
feat(postgres): add connection setup with a PG 14+ version guard
mickamy May 27, 2026
8d62dc7
feat(postgres): implement the Activity snapshot
mickamy May 27, 2026
8612093
feat(postgres): implement Cancel and Terminate
mickamy May 27, 2026
9683256
feat(postgres): implement Capabilities probing
mickamy May 27, 2026
45b24cc
feat(postgres): implement Statements and ResetStatements
mickamy May 27, 2026
8bc5253
feat(postgres): implement Metrics and assert the Driver interface
mickamy May 27, 2026
179a8bd
fix(postgres): sort activity by the displayed duration
mickamy May 27, 2026
3990670
perf(postgres): aggregate activity connection counts in one scan
mickamy May 27, 2026
e8b2708
fix(postgres): exclude dbtop's own connections from metrics counts
mickamy May 27, 2026
ff6e7b6
test(postgres): add integration tests behind a build tag
mickamy May 27, 2026
08a3015
fix(driver): return no duration for idle backends
mickamy May 27, 2026
b1673f5
refactor(driver): rename BackgroundWorker to SystemBackend
mickamy May 27, 2026
d0d1f09
fix(postgres): sort idle backends last in activity
mickamy May 27, 2026
cb8add8
fix(postgres): cast pg_blocking_pids to bigint[] for portable scanning
mickamy May 27, 2026
c8089dd
ci: bump codecov-action to v5
mickamy May 27, 2026
a5b5c25
fix(postgres): scope statements to the current database
mickamy May 27, 2026
6cbdab8
fix(postgres): exclude dbtop's own connections from activity
mickamy May 27, 2026
75f27d5
fix(postgres): grant monitor and kill to superusers
mickamy May 27, 2026
816bec6
fix(postgres): make the dbtop connection filter NULL-safe
mickamy May 27, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ jobs:

- name: Upload coverage to Codecov
if: always()
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v5
with:
files: ./cover.out
token: ${{ secrets.CODECOV_TOKEN }}
Expand Down
10 changes: 10 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
module github.com/mickamy/dbtop

go 1.26.3

require github.com/jackc/pgx/v5 v5.9.2

require (
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
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)
26 changes: 26 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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/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=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
81 changes: 81 additions & 0 deletions internal/driver/backend.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package driver

import "time"

// State is a backend's connection state, normalized across drivers.
type State uint8

const (
StateUnknown State = iota
StateActive
StateIdle
StateIdleInTx
StateIdleInTxAborted
)

func (s State) String() string {
switch s {
case StateUnknown:
return "unknown"
case StateActive:
return "active"
case StateIdle:
return "idle"
case StateIdleInTx:
return "idle-tx"
case StateIdleInTxAborted:
return "idle-tx-aborted"
}

return "unknown"
}

// Backend is one server-side connection in an Activity snapshot.
type Backend struct {
PID int64
User string
DB string
State State

// Empty when the backend is not waiting.
WaitType string
WaitEvent string

Query string

// PIDs blocking this backend; the ▲/⊘ markers are derived across rows.
BlockedBy []int64

// now() - query_start and now() - xact_start; nil when the timestamp is NULL.
QueryAge *time.Duration
XactAge *time.Duration

// System-internal (non-client) backend, hidden by default.
SystemBackend bool
}

// Duration returns the value shown in the DURATION column. Idle and unknown
// backends have none: their query_start is stale from the last query.
func (b Backend) Duration() (time.Duration, bool) {
switch b.State {
case StateActive:
if b.QueryAge != nil {
return *b.QueryAge, true
}
case StateIdleInTx, StateIdleInTxAborted:
if b.XactAge != nil {
return *b.XactAge, true
}
case StateUnknown, StateIdle:
}

return 0, false
}

func (b Backend) Wait() string {
if b.WaitType == "" {
return ""
}

return b.WaitType + ":" + b.WaitEvent
}
103 changes: 103 additions & 0 deletions internal/driver/backend_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package driver_test

import (
"testing"
"time"

"github.com/mickamy/dbtop/internal/driver"
)

func TestStateString(t *testing.T) {
t.Parallel()

tests := []struct {
state driver.State
want string
}{
{driver.StateActive, "active"},
{driver.StateIdle, "idle"},
{driver.StateIdleInTx, "idle-tx"},
{driver.StateIdleInTxAborted, "idle-tx-aborted"},
{driver.StateUnknown, "unknown"},
}

for _, tt := range tests {
if got := tt.state.String(); got != tt.want {
t.Errorf("State(%d).String() = %q, want %q", tt.state, got, tt.want)
}
}
}

func TestBackendDuration(t *testing.T) {
t.Parallel()

tests := []struct {
name string
backend driver.Backend
want time.Duration
wantOK bool
}{
{
name: "active uses query age",
backend: driver.Backend{State: driver.StateActive, QueryAge: new(3 * time.Second), XactAge: new(time.Minute)},
want: 3 * time.Second,
wantOK: true,
},
{
name: "idle-tx uses xact age",
backend: driver.Backend{State: driver.StateIdleInTx, QueryAge: new(time.Second), XactAge: new(90 * time.Second)},
want: 90 * time.Second,
wantOK: true,
},
{
name: "idle-tx-aborted uses xact age",
backend: driver.Backend{State: driver.StateIdleInTxAborted, XactAge: new(30 * time.Second)},
want: 30 * time.Second,
wantOK: true,
},
{
name: "active without query age is undefined",
backend: driver.Backend{State: driver.StateActive},
wantOK: false,
},
{
name: "idle ignores stale query age",
backend: driver.Backend{State: driver.StateIdle, QueryAge: new(2 * time.Hour)},
wantOK: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

got, ok := tt.backend.Duration()
if ok != tt.wantOK || got != tt.want {
t.Errorf("Duration() = (%v, %v), want (%v, %v)", got, ok, tt.want, tt.wantOK)
}
})
}
}

func TestBackendWait(t *testing.T) {
t.Parallel()

tests := []struct {
name string
backend driver.Backend
want string
}{
{"lock wait", driver.Backend{WaitType: "Lock", WaitEvent: "tuple"}, "Lock:tuple"},
{"not waiting", driver.Backend{}, ""},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

if got := tt.backend.Wait(); got != tt.want {
t.Errorf("Wait() = %q, want %q", got, tt.want)
}
})
}
}
37 changes: 37 additions & 0 deletions internal/driver/driver.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package driver

import "context"

// Driver abstracts a database backend behind the screens the TUI renders. The
// TUI is driver-independent: it only consumes []Backend, MetricSample, and
// []Statement.
type Driver interface {
Activity(ctx context.Context) ([]Backend, error)
Metrics(ctx context.Context) (MetricSample, error)
Statements(ctx context.Context) ([]Statement, error)
ResetStatements(ctx context.Context) error

// Cancel gracefully stops the running query (pg_cancel_backend / KILL QUERY).
Cancel(ctx context.Context, pid int64) error

// Terminate forcibly closes the connection (pg_terminate_backend / KILL CONNECTION).
Terminate(ctx context.Context, pid int64) error

Capabilities(ctx context.Context) (Capabilities, error)
Close() error
}

// Capabilities reports the privileges and features available to the connected
// user, used to degrade gracefully and to render the startup banner.
type Capabilities struct {
Superuser bool

// Can read other backends' full query text (pg_monitor / PROCESS).
Monitor bool

// Can cancel/terminate backends (pg_signal_backend / CONNECTION_ADMIN).
Kill bool

// Digest source available (pg_stat_statements / performance_schema digest).
Statements bool
}
43 changes: 43 additions & 0 deletions internal/driver/metric.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package driver

import "time"

// MetricSample is one polling sample of server-wide health counters. Drivers
// return raw cumulative counters and gauges; per-second rates are derived
// downstream from the delta between successive samples.
type MetricSample struct {
At time.Time

MaxConnections int
Conns ConnCounts

// Monotonic counters; rate = delta / dt.
Commits int64
Rollbacks int64
BlocksHit int64
BlocksRead int64
TuplesInserted int64
TuplesUpdated int64
TuplesDeleted int64
TuplesReturned int64
TuplesFetched int64
TempFiles int64
TempBytes int64

// Point-in-time value; use as-is, not as a rate.
WaitingLocks int

Replicas []ReplicaLag
}

type ConnCounts struct {
Total int
Active int
Idle int
IdleInTx int
}

type ReplicaLag struct {
Client string
Lag time.Duration
}
Loading
Loading