ncdu-style TUI for browsing PostgreSQL disk usage, shared_buffers occupancy, page contents, WAL, top queries (pg_stat_statements), maintenance/health, live activity, and other diagnostic data. Go 1.26, Bubble Tea, pgx/v5. Single binary, no daemon. Postgres 17, 18 and newer must be supported query wise.
main.go # CLI entry: parse flags → pg.Client → tea.Program
internal/cli/ # flag/env parsing → Config (DSN builder, Target string)
internal/pg/ # pgx wrapper: one *pgxpool.Pool per database, lazy
queries.go # foundation SQL (databases/schemas/tables) as named const strings
types.go # foundation row structs (Database, Schema, Table, Part, …)
queries_{domain}.go# per-domain SQL (queries_diag/pages/statements/wal/…)
types_{domain}.go # per-domain row structs (types_statements/pages/wal/…)
{entity}.go # one file per entity's List*/Fill*/Probe* operations
internal/tui/ # Bubble Tea Model/Update/View
app.go # Model, screen, item, level/tool/sortMode + methods
update.go # top-level Update() dispatcher
update_msgs*.go # *LoadedMsg / extStatus / reindexDone handlers (split per feature)
update_keys*.go # handleKey, handleFilterKey, confirm flows (split per feature)
update_drill*.go # drillIn (level ↓)
update_load.go # loadCurrent (issue load Cmd)
update_sort.go # applySort + validSorts + cycleSort (sortMode methods in sort_modes.go)
cmds*.go # tea.Cmd wrappers around pg.Client (query() helper)
view.go, view_*.go # View() + per-level renderers (view_activity/queries/snapshots/…)
row.go # shared row + paintBar/barSegment primitives
filter.go # fuzzy filter, visibleIndexes, viewportRange
layout.go # layout math, barReserve()
styles.go, keys.go # lipgloss styles, keyMap
internal/humanize/ # Bytes(int64) → "12.34 MB"
internal/prefs/ # per-user UI prefs (JSON at ~/.config/pgdu, atomic write)
internal/sysmem/ # host memory stats (used by the maintenance/system overview)
- SQL goes in
internal/pg/queries.goor a siblingqueries_<domain>.goasconst sql<Name>(queries.gois the foundation; domain SQL lives in the siblings). Functions in the entity files (tables.go,parts.go, …) just callpool.Query/pool.QueryRowand scan into types fromtypes.go/types_<domain>.go. - Identifiers are quoted with
%q.%qorquoteIdent()when interpolated into SQL — never with%s. All identifiers come from catalogs or theTablestruct, never raw user input. - Errors wrap with context:
fmt.Errorf("<op> in %q: %w", db, err). - Client method signature:
func (c *Client) X(ctx, …) (…, error). Every TUI Cmd incmds.goalready wraps the call in a 30 s timeout viaquery()— REINDEX is the only exception (no client-side timeout). - TUI state lives on
screen, not onModel.Modelcarries the stack of screens plus the client and shared widget state (spinner, help, keys). - Sort logic is on
sortMode:.label(desc),.less(a,b),.defaultDesc(),.name(). Add a new sort by extending the enum + those methods +validSorts. - Adding a new entity: drop a new file in
internal/pg/, define its type intypes.go(or atypes_<domain>.go), addsql<Name>inqueries.go(or aqueries_<domain>.go), then wire alevelenum value ininternal/tui/app.go(+ abarReserve()case inlayout.go), a Cmd incmds.go, a handler inupdate_msgs.go, and a drill case inupdate_drill.go. - No comments that just restate the code. Comments explain the why (subtle invariants, why we picked a constant, why an obvious thing isn't).
make build # → ./pgdu
make test # go test ./... (internal/pg has an integration test that
# needs PGDU_TEST_DSN; skipped otherwise)
make lint # golangci-lint --fix + go fix
make deb # Debian package (debian-pkg/, pgdu_<ver>_<arch>.deb)
./pgdu -U user # libpq-style flags: -h host -p port -U user -d dbname --dsn URLpgdu defaults match psql: no -h → Unix socket + peer auth. PGPASSWORD
is read directly; ~/.pgpass is honoured by libpq at connect time.
Top-queries snapshots are written to --snapshot-dir (default a shared
$TMPDIR/pgdu-snapshots, i.e. /tmp/pgdu-snapshots; PGDU_SNAPSHOT_DIR
overrides) as one timestamped gzip-JSON file each (pg.Snapshot). The directory
is intentionally shared and world-writable (created 0o777, no sticky bit, files
0o666) so any user on the host can list/load/delete another user's snapshots.
Per-user UI preferences (currently just C-picker column visibility, per table)
persist to ~/.config/pgdu/prefs.json via internal/prefs (os.UserConfigDir,
PGDU_CONFIG_DIR overrides; dir 0o700, file 0o600, atomic temp+rename). The
schema (prefs.Prefs → Tables[key].Columns) is extensible — add sort/refresh
fields later with no migration. prefs.Load never fails (missing/corrupt →
empty prefs). The TUI seeds the per-table *ColsVisible maps from it in
NewModel and writes back via saveColPrefs in the C-toggle handlers
(update_keys_columns.go); a new table just needs a colPrefs<Name> key + one
saveColPrefs call.
- Tools & levels:
levelToolsis the root menu; from it you pick one of thetoolvalues (toolDisk,toolBuffers,toolPageInspect,toolTools(diagnostics),toolWAL,toolQueries(top queries),toolMaintenance,toolActivity,toolTableStats,toolTriage) and drill through that tool's ownlevel*chain. Both enums live inapp.go. - Two-step confirm is a shared pattern: reindex, snapshot delete (
pendingDeleteSnap), backend cancel/terminate (pendingBackend*), streaming VACUUM (pendingVacuum), and extension reset all use the same flow — Enter arms apending*field, any next key cancels,y/Yexecutes (in the relevant key handler). - Activity (
toolActivity,view_activity.go): livepg_stat_activitybrowser with aCcolumn picker (mirrors top-queries), filter/verbose/auto-refresh cycling, and backend cancel/terminate. Linux derives CPU%/IO from/proc(activity_proc_linux.go). - Shared-memory map (
levelShmem,view_shmem.go): from the buffer-tables list,mopens the whole shared-memory segment (pg_shmem_allocations, not just the buffer pool). Allocations are bucketed byshmemCatOfinto coarse subsystem categories (buffer pool / WAL / txn-SLRU / locks / backends / stats / other / anonymous / free); a grouped category bar sits above the per-allocation list, withfreeas the bar's muted tail. The view is built-in (no extension) but needspg_read_all_stats/superuser; a lesser role surfaces the permission error. The two NULL-name rows are classified inpg.ShmemAllocations(off NULL → anonymous, off set → free). - Streaming VACUUM (on
levelParts): liveNOTICEoutput streamed into a scrollable pane held invacuumState. - Maintenance / system overview (
toolMaintenance,view_maintenance*.go): extension capacity stats (pg_stat_statements/pg_qualstats) with a reset confirm, plus a settings browser (levelSettings). screen.tableis the source of truth atlevelParts/levelColumns.loadCurrent()clearsextPromptandinstallingon entry; any state set before it is loaded asynchronously will be wiped.applySortis called after every load so render order matches sort order.bloatFilledMsgmatches by name, not index, because of this.extPrompt.blocking == truereplaces the list area entirely; non-blocking prompts render as a soft hint line above it.- Top-queries snapshots (
internal/pg/snapshots.go):Sdumps the raw cumulative counters to disk,LopenslevelSnapshots— a timeline range picker whose◀ start/◀ endmarkers reflect the applied window (derived byappliedWindowPaths). Enter pairs the pick with the applied start (the anchor): no anchor → pick→now live (statBaseSnap, "since snapshot, live"); anchor set → time-ordered range,statEndSnaptoo when frozen (no live re-sampling —loadCurrent/onStatementsTickspecial-case it). A pick that lands as a frozen end keeps the browser open (snapshotFrozenLoadedMsg.stay); start picks pop back to the table. Disk-baseline diffs useDiffStatementsClamped; snapshots invalidated by apg_stat_statements_info.stats_resetafter their capture are filtered out of theLbrowser (onSnapshotsListed, using the live reset fromlistSnapshotsCmd) rather than warned about.Ddeletes via the same two-steppendingDeleteSnaparm as reindex. TheLlist also carries three virtual timeline anchors (sentinel paths@now/@session/@resetincmds.go, never backed by a file): "now" (live end, sized fromscreen.statLiveCount), "session start" (restores the preservedstatSessionBaseline/statSessionStartin-memory window — captured once on the first live load, untouched byR), and "since last reset" (cumulative, dated from the livestatLiveReset). The render loop (renderStatementSnapshots) andloadSelectedSnapshot/snapTimespecial-case anchors: they carry no server/db identity (never "other server/db"),@sessioncan only diff against@now, and@sessionnever acts as a pairing anchor (the default window isn't a "pick"). - The top-queries table's columns come from a single registry
(
stmtColumnRegistryinqueries_columns.go): eachstmtColDesccarries an id, kind,defaultOn, anavailable(ctx)gate (planning columns needtrack_planning) and acellbuilder.Copens an htop-style picker (showColumnConfigoverlay) to toggle visibility; choices persist viainternal/prefs.buildStatementItemsprojects the registry to the visible subset sodiagColsand each row's[]pg.DiagCellstay parallel by construction (no index constants). The sort column is tracked by stable id (Model.stmtSortColID);syncStmtSortremaps it to the projecteddiagSortColafter every rebuild, falling back tototal_msif it was hidden. New opt-in metrics are one registry entry — every counter is already onQueryStat.