From cdc3c93938abd0b1276f6ee97ced209be14d28a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20D=C3=B6tsch?= Date: Fri, 3 Jul 2026 11:04:00 +0000 Subject: [PATCH 1/6] tui/pg: dedupe render helpers, share SQL, add coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quality pass over the whole codebase (no behaviour change). Simplification / reuse: - Unify the info-overlay scaffolding into shared row.go helpers: padInfo (was 2 private copies + 14 inline loops), infoHeader (1 helper + 15 copies), and swatch for the "▇" legend glyph. - Collapse the three per-function bar-cell closures in row.go into one bytesToCells helper; add screen.resetCursor() for the paired cursor/offset resets. - Move the generic fmt* formatters from view_queries.go to format.go where their other consumers live. - Quote SQL identifiers via the existing qualifiedIdent() in bloat/reindex/vacuum instead of Go %q. - Build sqlBufferStats / sqlBufferStatByOID from one template (was ~50 duplicated lines); generated SQL is byte-identical to the old consts. Tests: shmemCatOf ordering, appliedWindowPaths/snapTime snapshot anchors, the statements column registry (uniqueness, track_planning gating, toggles, cells∥columns parallelism), containsFold vs a strings.Contains oracle, lessBy*Extractor unknown-below-known, viewportRange boundaries, barWidth clamps, a barReserve smoke test, and QueryStat derived-metric edges. --- internal/pg/bloat.go | 4 +- internal/pg/queries_buffers.go | 94 +++++---------- internal/pg/reindex.go | 2 +- internal/pg/statements_test.go | 34 ++++++ internal/pg/vacuum.go | 2 +- internal/tui/filter.go | 8 ++ internal/tui/filter_test.go | 36 +++++- internal/tui/format.go | 67 ++++++++++- internal/tui/layout_test.go | 43 +++++++ internal/tui/queries_columns_test.go | 125 ++++++++++++++++++++ internal/tui/row.go | 67 ++++++----- internal/tui/sort_test.go | 43 +++++++ internal/tui/update_drill_snapshots_test.go | 80 +++++++++++++ internal/tui/update_keys.go | 9 +- internal/tui/update_keys_filter.go | 9 +- internal/tui/view_activity.go | 4 +- internal/tui/view_buffers.go | 38 ++---- internal/tui/view_diag.go | 7 +- internal/tui/view_heap_pages.go | 22 +--- internal/tui/view_maintenance.go | 5 +- internal/tui/view_overlays.go | 14 +-- internal/tui/view_pages.go | 51 ++------ internal/tui/view_queries.go | 68 +---------- internal/tui/view_queries_detail.go | 7 +- internal/tui/view_shmem.go | 14 +-- internal/tui/view_shmem_test.go | 79 +++++++++++++ internal/tui/view_snapshots.go | 12 +- internal/tui/view_tablestats.go | 5 +- internal/tui/view_wal.go | 35 ++---- 29 files changed, 639 insertions(+), 345 deletions(-) create mode 100644 internal/tui/layout_test.go create mode 100644 internal/tui/queries_columns_test.go create mode 100644 internal/tui/update_drill_snapshots_test.go create mode 100644 internal/tui/view_shmem_test.go diff --git a/internal/pg/bloat.go b/internal/pg/bloat.go index f5c3fc4..645b480 100644 --- a/internal/pg/bloat.go +++ b/internal/pg/bloat.go @@ -54,7 +54,7 @@ func (c *Client) FillBloat(ctx context.Context, t Table, parts []Part) error { if err != nil { return err } - qualified := fmt.Sprintf("%q.%q", t.Schema, t.Name) + qualified := qualifiedIdent(t.Schema, t.Name) for i := range parts { p := &parts[i] @@ -83,7 +83,7 @@ func (c *Client) FillBloat(ctx context.Context, t Table, parts []Part) error { } } case PartIndex: - indexRef := fmt.Sprintf("%q.%q", t.Schema, p.Name) + indexRef := qualifiedIdent(t.Schema, p.Name) if mode == BloatExact && p.AccessMethod == "btree" { if err := pool.QueryRow(ctx, sqlBloatIndex, indexRef).Scan(&p.WastedBytes); err == nil { p.HasBloat = true diff --git a/internal/pg/queries_buffers.go b/internal/pg/queries_buffers.go index 034b16b..4c2cbff 100644 --- a/internal/pg/queries_buffers.go +++ b/internal/pg/queries_buffers.go @@ -1,5 +1,7 @@ package pg +import "fmt" + // --- shared-buffers view --- // sqlExtensionProbe returns two booleans: whether the named extension is @@ -85,7 +87,14 @@ ORDER BY g.usagecount // pg_buffercache.reldatabase = 0 is the shared catalog buffer pool — included // so system relations a user owns aren't double-counted oddly, though for // user schemas the join via relfilenode usually filters those out. -const sqlBufferStats = ` +// +// sqlBufferStatsTmpl is the shared body of the two variants below, which +// differ only in how rows are picked: by schema (needs the extra pg_namespace +// join in the filenodes arms, %[1]s, and an nspname predicate) or by a single +// relation OID. %[2]s is the row predicate, %[3]s the trailing ORDER BY +// (pointless for the single-row variant). The fragments are the fixed strings +// in the var block below — nothing user-supplied is ever spliced in. +const sqlBufferStatsTmpl = ` WITH bc AS ( SELECT relfilenode, COUNT(*) AS bufs, @@ -97,20 +106,17 @@ WITH bc AS ( ), filenodes AS ( SELECT c.oid AS tab_oid, pg_relation_filenode(c.oid) AS fn - FROM pg_class c - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = $1 AND c.relkind IN ('r','m','p') + FROM pg_class c%[1]s + WHERE %[2]s AND c.relkind IN ('r','m','p') UNION ALL SELECT c.oid, pg_relation_filenode(c.reltoastrelid) - FROM pg_class c - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = $1 AND c.relkind IN ('r','m','p') AND c.reltoastrelid <> 0 + FROM pg_class c%[1]s + WHERE %[2]s AND c.relkind IN ('r','m','p') AND c.reltoastrelid <> 0 UNION ALL SELECT c.oid, pg_relation_filenode(i.indexrelid) - FROM pg_class c - JOIN pg_namespace n ON n.oid = c.relnamespace + FROM pg_class c%[1]s JOIN pg_index i ON i.indrelid = c.oid - WHERE n.nspname = $1 AND c.relkind IN ('r','m','p') + WHERE %[2]s AND c.relkind IN ('r','m','p') ), buffered AS ( SELECT f.tab_oid, @@ -136,65 +142,21 @@ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace LEFT JOIN buffered b ON b.tab_oid = c.oid LEFT JOIN pg_statio_user_tables s ON s.relid = c.oid -WHERE n.nspname = $1 AND c.relkind IN ('r','m','p') -ORDER BY buffered_bytes DESC, c.relname +WHERE %[2]s AND c.relkind IN ('r','m','p')%[3]s ` -// sqlBufferStatByOID is sqlBufferStats scoped to a single relation by OID -// instead of a whole schema — the natural shape for the describe-table view, -// which has the OID but wants only that one table's cache footprint. The -// filenodes CTEs and final SELECT filter on c.oid = $1; the rest matches -// sqlBufferStats so the scanned column list is identical. -const sqlBufferStatByOID = ` -WITH bc AS ( - SELECT relfilenode, - COUNT(*) AS bufs, - COUNT(*) FILTER (WHERE isdirty) AS dirty_bufs, - SUM(usagecount)::bigint AS usage_sum - FROM pg_buffercache - WHERE reldatabase IN (0, (SELECT oid FROM pg_database WHERE datname = current_database())) - GROUP BY relfilenode -), -filenodes AS ( - SELECT c.oid AS tab_oid, pg_relation_filenode(c.oid) AS fn - FROM pg_class c - WHERE c.oid = $1 AND c.relkind IN ('r','m','p') - UNION ALL - SELECT c.oid, pg_relation_filenode(c.reltoastrelid) - FROM pg_class c - WHERE c.oid = $1 AND c.relkind IN ('r','m','p') AND c.reltoastrelid <> 0 - UNION ALL - SELECT c.oid, pg_relation_filenode(i.indexrelid) - FROM pg_class c - JOIN pg_index i ON i.indrelid = c.oid - WHERE c.oid = $1 AND c.relkind IN ('r','m','p') -), -buffered AS ( - SELECT f.tab_oid, - COALESCE(SUM(bc.bufs), 0)::bigint AS bufs, - COALESCE(SUM(bc.dirty_bufs), 0)::bigint AS dirty_bufs, - COALESCE(SUM(bc.usage_sum), 0)::bigint AS usage_sum - FROM filenodes f - LEFT JOIN bc ON bc.relfilenode = f.fn - GROUP BY f.tab_oid +var ( + // sqlBufferStats lists every table in one schema ($1 = nspname). + sqlBufferStats = fmt.Sprintf(sqlBufferStatsTmpl, + "\n JOIN pg_namespace n ON n.oid = c.relnamespace", + "n.nspname = $1", + "\nORDER BY buffered_bytes DESC, c.relname") + // sqlBufferStatByOID is the same query scoped to a single relation + // ($1 = oid) — the natural shape for the describe-table view, which has + // the OID but wants only that one table's cache footprint. The scanned + // column list is identical to sqlBufferStats. + sqlBufferStatByOID = fmt.Sprintf(sqlBufferStatsTmpl, "", "c.oid = $1", "") ) -SELECT c.oid, - n.nspname, - c.relname, - COALESCE(b.bufs, 0) * current_setting('block_size')::int AS buffered_bytes, - pg_total_relation_size(c.oid) AS total_bytes, - COALESCE(s.heap_blks_hit, 0) + COALESCE(s.idx_blks_hit, 0) AS hits, - COALESCE(s.heap_blks_read, 0) + COALESCE(s.idx_blks_read, 0) AS reads, - COALESCE(b.dirty_bufs, 0) * current_setting('block_size')::int AS dirty_bytes, - CASE WHEN COALESCE(b.bufs, 0) > 0 - THEN b.usage_sum::float8 / b.bufs - ELSE 0 END AS usage_avg -FROM pg_class c -JOIN pg_namespace n ON n.oid = c.relnamespace -LEFT JOIN buffered b ON b.tab_oid = c.oid -LEFT JOIN pg_statio_user_tables s ON s.relid = c.oid -WHERE c.oid = $1 AND c.relkind IN ('r','m','p') -` // sqlShmemAllocations dumps the whole Postgres shared-memory segment from // pg_shmem_allocations: every named region (the buffer pool, lock tables, SLRU diff --git a/internal/pg/reindex.go b/internal/pg/reindex.go index ad04120..8648058 100644 --- a/internal/pg/reindex.go +++ b/internal/pg/reindex.go @@ -13,7 +13,7 @@ func (c *Client) ReindexIndex(ctx context.Context, t Table, indexName string) er if err != nil { return err } - stmt := fmt.Sprintf("REINDEX INDEX CONCURRENTLY %q.%q", t.Schema, indexName) + stmt := "REINDEX INDEX CONCURRENTLY " + qualifiedIdent(t.Schema, indexName) if _, err := pool.Exec(ctx, stmt); err != nil { return fmt.Errorf("reindex %q.%q: %w", t.Schema, indexName, err) } diff --git a/internal/pg/statements_test.go b/internal/pg/statements_test.go index f44a04e..6023104 100644 --- a/internal/pg/statements_test.go +++ b/internal/pg/statements_test.go @@ -329,6 +329,40 @@ func TestQueryStatHitRatioAndDerived(t *testing.T) { } } +func TestQueryStatDerivedEdgeCases(t *testing.T) { + // BlocksPerRow: undefined without result rows, defined otherwise. + if _, ok := (QueryStat{SharedBlksHit: 10}).BlocksPerRow(); ok { + t.Error("BlocksPerRow should be undefined with zero rows") + } + if _, ok := (QueryStat{Rows: -1, SharedBlksHit: 10}).BlocksPerRow(); ok { + t.Error("BlocksPerRow should be undefined with negative rows") + } + if bpr, ok := (QueryStat{Rows: 4, SharedBlksHit: 6, SharedBlksRead: 2}).BlocksPerRow(); !ok || bpr != 2 { + t.Errorf("BlocksPerRow = %v ok=%v, want 2 true", bpr, ok) + } + + // IOTime sums all six timing counters; zero when none are set. + if got := (QueryStat{}).IOTime(); got != 0 { + t.Errorf("IOTime zero = %v, want 0", got) + } + full := QueryStat{ + SharedBlkReadTime: 1, SharedBlkWriteTime: 2, + LocalBlkReadTime: 4, LocalBlkWriteTime: 8, + TempBlkReadTime: 16, TempBlkWriteTime: 32, + } + if got := full.IOTime(); got != 63 { + t.Errorf("IOTime = %v, want 63", got) + } + + // HitRatio boundaries: all-hit and all-miss are both defined ratios. + if hr, ok := (QueryStat{SharedBlksHit: 5}).HitRatio(); !ok || hr != 100 { + t.Errorf("HitRatio all-hit = %v ok=%v, want 100 true", hr, ok) + } + if hr, ok := (QueryStat{SharedBlksRead: 5}).HitRatio(); !ok || hr != 0 { + t.Errorf("HitRatio all-miss = %v ok=%v, want 0 true", hr, ok) + } +} + func TestBuildSampleCall(t *testing.T) { cases := []struct { name string diff --git a/internal/pg/vacuum.go b/internal/pg/vacuum.go index 14d9873..29dc42b 100644 --- a/internal/pg/vacuum.go +++ b/internal/pg/vacuum.go @@ -31,7 +31,7 @@ func (c *Client) VacuumTable(ctx context.Context, t Table, onLine func(string)) } defer func() { _ = conn.Close(context.Background()) }() _, _ = conn.Exec(ctx, "SET pg_stat_statements.track = 'none'") - stmt := fmt.Sprintf("VACUUM (VERBOSE, ANALYZE, SKIP_LOCKED) %q.%q", t.Schema, t.Name) + stmt := "VACUUM (VERBOSE, ANALYZE, SKIP_LOCKED) " + qualifiedIdent(t.Schema, t.Name) if _, err := conn.Exec(ctx, stmt); err != nil { return fmt.Errorf("vacuum %q.%q: %w", t.Schema, t.Name, err) } diff --git a/internal/tui/filter.go b/internal/tui/filter.go index f3655cf..e253364 100644 --- a/internal/tui/filter.go +++ b/internal/tui/filter.go @@ -158,6 +158,14 @@ func (s *screen) clampCursor() { } } +// resetCursor jumps the selection back to the top of the list. cursor and +// offset move together so the viewport can't be left scrolled past the +// selection. +func (s *screen) resetCursor() { + s.cursor = 0 + s.offset = 0 +} + // viewportRange adjusts offset so cursor stays inside [offset, offset+height) // and returns the new offset plus the half-open end. Callers use the offset // to scroll the list and the end (clamped to the underlying length elsewhere) diff --git a/internal/tui/filter_test.go b/internal/tui/filter_test.go index 1ed5836..314a6eb 100644 --- a/internal/tui/filter_test.go +++ b/internal/tui/filter_test.go @@ -1,6 +1,9 @@ package tui -import "testing" +import ( + "strings" + "testing" +) func TestFuzzyMatch(t *testing.T) { cases := []struct { @@ -42,6 +45,33 @@ func TestSubstringMatch(t *testing.T) { } } +// TestContainsFold checks the allocation-free fast path against the +// lowercased strings.Contains it replaces, including the non-ASCII needle +// that must take the unicode-correct fallback. +func TestContainsFold(t *testing.T) { + cases := []struct{ s, substr string }{ + {"SELECT * FROM Users", "users"}, + {"select * from users", "USERS"}, + {"select * from users", "orders"}, + {"", ""}, + {"abc", ""}, + {"", "a"}, + {"ab", "abc"}, // needle longer than haystack + {"alpha", "a"}, + {"alpha", "A"}, + {"alph", "ph"}, // match at the very end + {"NAÏVE query", "naïve"}, // non-ASCII needle → fallback path + {"multibyte żółć in the haystack", "haystack"}, // ASCII needle over UTF-8 haystack + {"żółć", "x"}, + } + for _, c := range cases { + want := strings.Contains(strings.ToLower(c.s), strings.ToLower(c.substr)) + if got := containsFold(c.s, c.substr); got != want { + t.Errorf("containsFold(%q, %q) = %v, want %v", c.s, c.substr, got, want) + } + } +} + func TestMatchFilterPerLevel(t *testing.T) { // b…a…t…t…l…e is a subsequence of this query but not a substring. const q = "select b1_0.id, b1_0.created_at from player_state where a > $1" @@ -68,6 +98,10 @@ func TestViewportRange(t *testing.T) { {"cursor above window scrolls up", 1, 5, 10, 20, 1, 11}, {"end clamped to length", 0, 0, 10, 4, 0, 4}, {"negative offset clamped", 0, -3, 10, 20, 0, 10}, + {"cursor on last item", 19, 0, 10, 20, 10, 20}, + {"cursor exactly at viewport tail", 9, 0, 10, 20, 0, 10}, + {"list shorter than viewport", 2, 0, 10, 3, 0, 3}, + {"height one pins offset to cursor", 7, 3, 1, 20, 7, 8}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { diff --git a/internal/tui/format.go b/internal/tui/format.go index 6f254f0..e7173ef 100644 --- a/internal/tui/format.go +++ b/internal/tui/format.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "strconv" + "strings" "time" ) @@ -46,8 +47,8 @@ func positionLabel(s *screen) string { } // bloatScanLabel returns a short status indicator for the bloat fetch on -// the parts level. FillBloat is a single round trip that covers every -// part, so the states are "scanning…" (in flight) or "ready" (done) — +// the parts level. FillBloat is a single Cmd that covers every part, so +// the states are "scanning…" (in flight) or "ready" (done) — // any partial scanned count comes from individual rows whose bloat could // not be measured (e.g. unsupported index access methods). func bloatScanLabel(s *screen) string { @@ -162,3 +163,65 @@ func maxInt(a, b int) int { } return b } + +// fmtFloat renders a number with up to 1 decimals, trailing zeros stripped. +func fmtFloat(f float64) string { + s := strconv.FormatFloat(f, 'f', 1, 64) + if strings.ContainsRune(s, '.') { + s = strings.TrimRight(strings.TrimRight(s, "0"), ".") + } + return s +} + +// fmt1 renders a number with exactly one decimal place (60 → "60.0", 98.51 → +// "98.5"). The top-queries numeric columns use it so every value shows a single +// fractional digit rather than a ragged mix of 0/1/2 places. +func fmt1(f float64) string { + return strconv.FormatFloat(f, 'f', 1, 64) +} + +// fmtMs formats a millisecond duration compactly: sub-millisecond and small +// values keep ms; large values switch to seconds so the column stays narrow. +func fmtMs(ms float64) string { + if ms >= 100000 { + return fmt1(ms/1000) + "s" + } + return fmt1(ms) +} + +// fmtAge formats an elapsed time (in ms) as an age with an explicit, scale- +// appropriate unit so values never read ambiguously: "850ms", "31.1s", "11.2m", +// "3.1h", "2.4d". Unlike fmtMs the unit is always present, which is what lets the +// reader tell 105ms from 105s at a glance (paired with durationStyle colouring). +func fmtAge(ms float64) string { + switch { + case ms < 1000: + return fmt.Sprintf("%.0fms", ms) + case ms < 60*1000: + return fmt1(ms/1000) + "s" + case ms < 60*60*1000: + return fmt1(ms/(60*1000)) + "m" + case ms < 24*60*60*1000: + return fmt1(ms/(60*60*1000)) + "h" + default: + return fmt1(ms/(24*60*60*1000)) + "d" + } +} + +// fmtDuration renders a window span with explicit units — "45s", "13m 12s", +// "2h 05m", "3d 4h" — so it never reads as a wall-clock time. The old H:MM:SS +// form made "13:12" ambiguous with a start timestamp, which is the whole reason +// it sits next to "since 05:56:46". +func fmtDuration(d time.Duration) string { + d = d.Round(time.Second) + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int(d/time.Second)) + case d < time.Hour: + return fmt.Sprintf("%dm %02ds", int(d/time.Minute), int(d%time.Minute/time.Second)) + case d < 24*time.Hour: + return fmt.Sprintf("%dh %02dm", int(d/time.Hour), int(d%time.Hour/time.Minute)) + default: + return fmt.Sprintf("%dd %dh", int(d/(24*time.Hour)), int(d%(24*time.Hour)/time.Hour)) + } +} diff --git a/internal/tui/layout_test.go b/internal/tui/layout_test.go new file mode 100644 index 0000000..8046517 --- /dev/null +++ b/internal/tui/layout_test.go @@ -0,0 +1,43 @@ +package tui + +import "testing" + +// TestBarWidth pins the terminal-width clamp: levelDescribe reserves nothing, +// so the bar gets the full width, bounded by [barWidthMin, barWidthMax]. +func TestBarWidth(t *testing.T) { + s := &screen{level: levelDescribe} + cases := []struct{ width, want int }{ + {10, barWidthMin}, // too narrow: fall back to the minimum + {barWidthMin, barWidthMin}, + {50, 50}, // in range: track the terminal + {barWidthMax, barWidthMax}, + {500, barWidthMax}, // very wide: cap so columns keep their share + } + for _, c := range cases { + m := &Model{width: c.width} + if got := m.barWidth(s); got != c.want { + t.Errorf("barWidth(width=%d) = %d, want %d", c.width, got, c.want) + } + } +} + +// TestBarReserveSane is a smoke check over every level: the reserve must be +// non-negative and leave room for a bar on a normal-width terminal. It exists +// to catch a levelless typo in the barReserve arithmetic, not to pin exact +// sums (those live next to their renderers). +func TestBarReserveSane(t *testing.T) { + ams := []string{"btree", "gist", "brin", "gin"} + // levelTableStats is the last enum value; extend here if a level is added after it. + for l := levelTools; l <= levelTableStats; l++ { + for _, tl := range []tool{toolDisk, toolPageInspect} { + for _, am := range ams { + s := &screen{level: l, tool: tl} + s.index.AccessMethod = am + r := barReserve(s) + if r < 0 || r > 150 { + t.Errorf("barReserve(level=%d tool=%d am=%s) = %d, want 0..150", l, tl, am, r) + } + } + } + } +} diff --git a/internal/tui/queries_columns_test.go b/internal/tui/queries_columns_test.go new file mode 100644 index 0000000..d19fbab --- /dev/null +++ b/internal/tui/queries_columns_test.go @@ -0,0 +1,125 @@ +package tui + +import ( + "testing" + + "pgdu/internal/pg" +) + +func TestStmtColumnRegistry(t *testing.T) { + seen := map[stmtColID]bool{} + for _, d := range stmtColumnRegistry() { + if seen[d.id] { + t.Errorf("duplicate column id %q", d.id) + } + seen[d.id] = true + if d.name == "" || d.desc == "" { + t.Errorf("column %q: name and desc must be set", d.id) + } + if d.cell == nil { + t.Errorf("column %q: nil cell builder", d.id) + } + if d.mandatory && !d.defaultOn { + t.Errorf("column %q: mandatory columns must also be default-on", d.id) + } + } + if !seen[colQuery] { + t.Error("registry must contain the mandatory query column") + } +} + +func colIDSet(descs []stmtColDesc) map[stmtColID]bool { + out := make(map[stmtColID]bool, len(descs)) + for _, d := range descs { + out[d.id] = true + } + return out +} + +// TestVisibleStmtColsPlanningGate pins the track_planning availability gate: +// with planning off the plan columns disappear entirely (not just hide), with +// it on only the default-on one appears. +func TestVisibleStmtColsPlanningGate(t *testing.T) { + m := &Model{} + + off := colIDSet(m.visibleStmtCols(stmtCtx{trackPlanning: false})) + for _, id := range []stmtColID{colPlanMs, colMeanPlanMs, colPlans} { + if off[id] { + t.Errorf("track_planning off: column %q must be unavailable", id) + } + } + + on := colIDSet(m.visibleStmtCols(stmtCtx{trackPlanning: true})) + if !on[colMeanPlanMs] { + t.Error("track_planning on: default-on mean_plan_ms should appear") + } + if on[colPlanMs] || on[colPlans] { + t.Error("track_planning on: opt-in plan_ms/plans stay hidden by default") + } +} + +func TestVisibleStmtColsUserToggles(t *testing.T) { + m := &Model{stmtColsVisible: map[stmtColID]bool{ + colHit: false, // hide a default-on column + colDirtied: true, // enable an opt-in column + colQuery: false, // mandatory — the toggle must be ignored + }} + ids := colIDSet(m.visibleStmtCols(stmtCtx{})) + if ids[colHit] { + t.Error("hidden default-on column is still visible") + } + if !ids[colDirtied] { + t.Error("user-enabled opt-in column is missing") + } + if !ids[colQuery] { + t.Error("mandatory query column must survive an off toggle") + } + if !ids[colTotalMs] { + t.Error("untouched default-on column is missing") + } +} + +// TestCellsForStaysParallel pins the invariant the registry design exists for: +// cells, diag columns and descriptors are projected from the same slice, so +// they stay index-parallel for any visibility/availability combination. +func TestCellsForStaysParallel(t *testing.T) { + m := &Model{} + q := pg.QueryStat{Query: "select 1", Calls: 3, Rows: 6, TotalExecTime: 12, + SharedBlksHit: 9, SharedBlksRead: 3} + for _, tp := range []bool{false, true} { + ctx := stmtCtx{windowMs: 100, trackPlanning: tp} + descs := m.visibleStmtCols(ctx) + if len(descs) == 0 { + t.Fatalf("trackPlanning=%v: no visible columns", tp) + } + if cells := cellsFor(descs, q, ctx); len(cells) != len(descs) { + t.Fatalf("trackPlanning=%v: %d cells for %d columns", tp, len(cells), len(descs)) + } + if cols := diagColumnsFrom(descs); len(cols) != len(descs) { + t.Fatalf("trackPlanning=%v: %d diag columns for %d descs", tp, len(cols), len(descs)) + } + } + + descs := m.visibleStmtCols(stmtCtx{}) + cells := cellsFor(descs, q, stmtCtx{}) + qi := indexOfStmtCol(descs, colQuery) + if qi < 0 || cells[qi].Display != "select 1" { + t.Errorf("query cell at index %d = %+v, want display %q", qi, cells, "select 1") + } +} + +func TestLabelStmtFooter(t *testing.T) { + m := &Model{} + descs := m.visibleStmtCols(stmtCtx{}) + total := make([]pg.DiagCell, len(descs)) + labelStmtFooter(descs, total) + + if qi := indexOfStmtCol(descs, colQuery); total[qi].Display != "← Sum" { + t.Errorf("footer query cell = %q, want ← Sum", total[qi].Display) + } + for _, id := range []stmtColID{colTable, colType} { + if i := indexOfStmtCol(descs, id); i >= 0 && total[i].Display != "" { + t.Errorf("footer %q cell = %q, want blank", id, total[i].Display) + } + } +} diff --git a/internal/tui/row.go b/internal/tui/row.go index e7562a3..315969f 100644 --- a/internal/tui/row.go +++ b/internal/tui/row.go @@ -234,21 +234,8 @@ func renderSegmentedBar(heap, idx, toast, max int64, width int) string { // styleBloat for semantic parity with the parts view's bloat overlay. func renderHeapPageBar(live, dead int64, width int) string { const blockSize int64 = 8192 - bytesToCells := func(b int64) int { - if b <= 0 { - return 0 - } - c := int(float64(width) * float64(b) / float64(blockSize)) - if c < 0 { - return 0 - } - if c > width { - return width - } - return c - } - l := bytesToCells(live) - d := bytesToCells(dead) + l := bytesToCells(live, blockSize, width) + d := bytesToCells(dead, blockSize, width) if l+d > width { // Rounding can push us one cell over; trim the dead segment last // since live is the dominant visual. @@ -281,26 +268,23 @@ func renderBufferBar(slices []bufferSlice, thisDBRemainder, otherDB, total int64 if total <= 0 { total = 1 } - bytesToCells := func(b int64) int { - return max0(int(float64(width) * float64(b) / float64(total))) - } segs := make([]barSegment, 0, len(slices)+2) used := 0 for i, sl := range slices { - c := bytesToCells(sl.bytes) + c := bytesToCells(sl.bytes, total, width) if used+c > width { c = width - used } segs = append(segs, barSegment{cells: c, style: bufferSliceStyle(i)}) used += c } - rem := bytesToCells(thisDBRemainder) + rem := bytesToCells(thisDBRemainder, total, width) if used+rem > width { rem = width - used } segs = append(segs, barSegment{cells: rem, style: styleBar}) used += rem - other := bytesToCells(otherDB) + other := bytesToCells(otherDB, total, width) if used+other > width { other = width - used } @@ -317,9 +301,6 @@ func renderServerMemBar(sbUsed, sbFree, otherUsed, cache, total int64, width int if total <= 0 { total = 1 } - bytesToCells := func(b int64) int { - return max0(int(float64(width) * float64(b) / float64(total))) - } used := 0 clamp := func(c int) int { if used+c > width { @@ -331,10 +312,10 @@ func renderServerMemBar(sbUsed, sbFree, otherUsed, cache, total int64, width int used += c return c } - a := clamp(bytesToCells(sbUsed)) - b := clamp(bytesToCells(sbFree)) - c := clamp(bytesToCells(otherUsed)) - d := clamp(bytesToCells(cache)) + a := clamp(bytesToCells(sbUsed, total, width)) + b := clamp(bytesToCells(sbFree, total, width)) + c := clamp(bytesToCells(otherUsed, total, width)) + d := clamp(bytesToCells(cache, total, width)) return paintBar(width, barSegment{cells: a, style: styleBar}, barSegment{cells: b, style: styleSBFree}, @@ -350,6 +331,15 @@ func max0(n int) int { return n } +// bytesToCells scales a byte count to bar cells proportionally to total, +// clamped to [0, width]. Callers guarantee total > 0. +func bytesToCells(b, total int64, width int) int { + if b <= 0 { + return 0 + } + return min(max0(int(float64(width)*float64(b)/float64(total))), width) +} + func padRight(s string, n int) string { w := displayWidth(s) if w >= n { @@ -358,6 +348,27 @@ func padRight(s string, n int) string { return s + strings.Repeat(" ", n-w) } +// infoHeader writes the standard `?`-overlay title + dismiss-hint line into b. +func infoHeader(b *strings.Builder, title string) { + mu := styleMuted.Render + b.WriteString("\n") + b.WriteString(" " + styleSelected.Render(title) + mu(" · press ") + + styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") +} + +// padInfo pads an info overlay's builder to exactly `height` lines so the +// help row stays pinned to the bottom of the screen. +func padInfo(b *strings.Builder, height int) string { + rendered := strings.Count(b.String(), "\n") + for i := rendered; i < height; i++ { + b.WriteString("\n") + } + return b.String() +} + +// swatch renders the legend colour block used by the `?` reference overlays. +func swatch(style lipgloss.Style) string { return style.Render("▇") } + // SGR intensity toggles for emboldening the active-sort column label inside a // header line that is rendered as one styleMuted.Render(line). \x1b[22m turns // bold off again *without* resetting the foreground — a nested lipgloss style diff --git a/internal/tui/sort_test.go b/internal/tui/sort_test.go index 866ea89..c557d09 100644 --- a/internal/tui/sort_test.go +++ b/internal/tui/sort_test.go @@ -68,6 +68,49 @@ func TestSortModeLess(t *testing.T) { } } +// TestLessByExtractor pins the generic comparator behind most sort modes, +// in particular the "unknown sorts below known" rule: less(unknown, known) +// is true and less(known, unknown) false, mirroring the itemRows check above. +func TestLessByExtractor(t *testing.T) { + // The test extractor treats negative sizes as "no value". + ext := func(it item) (int64, bool) { return it.size, it.size >= 0 } + small, big, unknown := item{size: 10}, item{size: 20}, item{size: -1} + + if !lessByExtractor(small, big, ext) { + t.Error("10 should be < 20") + } + if lessByExtractor(big, small, ext) { + t.Error("20 should not be < 10") + } + if lessByExtractor(small, small, ext) { + t.Error("equal values are not less") + } + if lessByExtractor(small, unknown, ext) { + t.Error("known should not sort below unknown") + } + if !lessByExtractor(unknown, small, ext) { + t.Error("unknown should sort below known") + } + if lessByExtractor(unknown, unknown, ext) { + t.Error("two unknowns are equal, not less") + } +} + +func TestLessByStringExtractor(t *testing.T) { + ext := func(it item) (string, bool) { return it.name, it.name != "" } + a, b, unknown := item{name: "alpha"}, item{name: "beta"}, item{} + + if !lessByStringExtractor(a, b, ext) || lessByStringExtractor(b, a, ext) { + t.Error("want lexicographic order: alpha < beta only") + } + if lessByStringExtractor(a, unknown, ext) || !lessByStringExtractor(unknown, a, ext) { + t.Error("unknown-below-known rule must match the numeric variant") + } + if lessByStringExtractor(unknown, unknown, ext) { + t.Error("two unknowns are equal, not less") + } +} + func TestItemRows(t *testing.T) { cases := []struct { name string diff --git a/internal/tui/update_drill_snapshots_test.go b/internal/tui/update_drill_snapshots_test.go new file mode 100644 index 0000000..30269fa --- /dev/null +++ b/internal/tui/update_drill_snapshots_test.go @@ -0,0 +1,80 @@ +package tui + +import ( + "testing" + "time" + + "pgdu/internal/pg" +) + +// TestAppliedWindowPaths pins how the statements screen's window state maps +// onto snapshot-browser row paths: the sentinel anchors (@reset/@session/@now), +// real snapshots resolved back to their file path via CapturedAt, and the "" +// start of a fresh re-base that no browser row represents. +func TestAppliedWindowPaths(t *testing.T) { + t1 := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC) + t2 := t1.Add(time.Hour) + metas := []pg.SnapshotMeta{ + {Path: "/snaps/a.json.gz", CapturedAt: t1}, + {Path: "/snaps/b.json.gz", CapturedAt: t2}, + } + snapA := &pg.Snapshot{CapturedAt: t1} + snapB := &pg.Snapshot{CapturedAt: t2} + sessionStart := t1.Add(-time.Hour) + + cases := []struct { + name string + st screen + wantStart string + wantEnd string + }{ + {"cumulative live", screen{statCumulative: true}, snapReset, snapNow}, + {"cumulative with frozen end", screen{statCumulative: true, statEndSnap: snapB}, + snapReset, "/snaps/b.json.gz"}, + {"snapshot base, live end", screen{statBaseSnap: snapA}, "/snaps/a.json.gz", snapNow}, + {"frozen snapshot-to-snapshot diff", screen{statBaseSnap: snapA, statEndSnap: snapB}, + "/snaps/a.json.gz", "/snaps/b.json.gz"}, + {"base snapshot no longer listed", + screen{statBaseSnap: &pg.Snapshot{CapturedAt: t1.Add(time.Minute)}}, "", snapNow}, + {"end snapshot no longer listed", + screen{statBaseSnap: snapA, statEndSnap: &pg.Snapshot{CapturedAt: t2.Add(time.Minute)}}, + "/snaps/a.json.gz", ""}, + {"session window", screen{statSessionStart: sessionStart, statBaselineAt: sessionStart}, + snapSession, snapNow}, + // A fresh R re-base: baseline is neither a snapshot nor the session + // start, so no row can represent it. + {"fresh re-base", screen{statSessionStart: sessionStart, statBaselineAt: sessionStart.Add(time.Minute)}, + "", snapNow}, + } + m := &Model{} + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + s := &screen{statSnapMetas: metas} + start, end := m.appliedWindowPaths(&c.st, s) + if start != c.wantStart || end != c.wantEnd { + t.Errorf("appliedWindowPaths = (%q, %q), want (%q, %q)", + start, end, c.wantStart, c.wantEnd) + } + }) + } +} + +// TestSnapTimeOrdering pins the ordering roles of the sentinel paths: @reset +// is the earliest possible start, @now the latest possible end, and real +// snapshots order by their CapturedAt. +func TestSnapTimeOrdering(t *testing.T) { + t1 := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC) + m := &Model{} + s := &screen{statSnapMetas: []pg.SnapshotMeta{{Path: "/snaps/a.json.gz", CapturedAt: t1}}} + + reset := m.snapTime(s, snapReset) + now := m.snapTime(s, snapNow) + snap := m.snapTime(s, "/snaps/a.json.gz") + + if !reset.Before(snap) || !snap.Before(now) { + t.Errorf("want @reset < snapshot < @now, got %v / %v / %v", reset, snap, now) + } + if got := m.snapTime(s, "/snaps/gone.json.gz"); !got.IsZero() { + t.Errorf("unknown path should map to the zero time, got %v", got) + } +} diff --git a/internal/tui/update_keys.go b/internal/tui/update_keys.go index 0db2866..b6e236f 100644 --- a/internal/tui/update_keys.go +++ b/internal/tui/update_keys.go @@ -248,8 +248,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if s.heapWindowStart >= s.heapPageCount { s.heapWindowStart = max32(s.heapPageCount-s.heapWindowCount, 0) } - s.cursor = 0 - s.offset = 0 + s.resetCursor() return m, m.loadCurrent() } s.cursor = max(min(s.cursor+m.pageStep(), s.visibleLen()-1), 0) @@ -264,8 +263,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } if (s.level == levelHeapPages || s.level == levelIndexPages) && s.heapWindowStart > 0 { s.heapWindowStart = max32(s.heapWindowStart-s.heapWindowCount, 0) - s.cursor = 0 - s.offset = 0 + s.resetCursor() return m, m.loadCurrent() } s.cursor = max(s.cursor-m.pageStep(), 0) @@ -534,8 +532,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } if msg.Type == tea.KeyEsc && s.filter != "" { s.filter = "" - s.cursor = 0 - s.offset = 0 + s.resetCursor() break } if len(m.stack) > 1 { diff --git a/internal/tui/update_keys_filter.go b/internal/tui/update_keys_filter.go index 5686e25..2e41a5c 100644 --- a/internal/tui/update_keys_filter.go +++ b/internal/tui/update_keys_filter.go @@ -14,16 +14,14 @@ func (m *Model) handleFilterKey(s *screen, msg tea.KeyMsg) (tea.Model, tea.Cmd) case tea.KeyEsc: s.filter = "" s.filterFocused = false - s.cursor = 0 - s.offset = 0 + s.resetCursor() case tea.KeyEnter: s.filterFocused = false s.clampCursor() case tea.KeyBackspace, tea.KeyDelete: if r := []rune(s.filter); len(r) > 0 { s.filter = string(r[:len(r)-1]) - s.cursor = 0 - s.offset = 0 + s.resetCursor() } else { s.filterFocused = false } @@ -40,8 +38,7 @@ func (m *Model) handleFilterKey(s *screen, msg tea.KeyMsg) (tea.Model, tea.Cmd) return m, nil } s.filter += string(msg.Runes) - s.cursor = 0 - s.offset = 0 + s.resetCursor() } return m, nil } diff --git a/internal/tui/view_activity.go b/internal/tui/view_activity.go index 3d41c86..9d2dabd 100644 --- a/internal/tui/view_activity.go +++ b/internal/tui/view_activity.go @@ -158,9 +158,7 @@ func (m *Model) renderActivityInfo(height int) string { mu := styleMuted.Render badge := func(s string) string { return styleBadge.Render(s) } var b strings.Builder - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("Activity reference") + mu(" · press ") + - badge("?") + mu(" or ") + badge("esc") + mu(" to dismiss") + "\n\n") + infoHeader(&b, "Activity reference") b.WriteString(" " + styleHeader.Render(" what you're seeing ") + "\n") b.WriteString(" " + mu("Live view of pg_stat_activity. Rows are backends connected to the selected database.") + "\n") diff --git a/internal/tui/view_buffers.go b/internal/tui/view_buffers.go index c7f3ddb..9d0d05b 100644 --- a/internal/tui/view_buffers.go +++ b/internal/tui/view_buffers.go @@ -17,12 +17,10 @@ import ( // pinned to the bottom. Shown when the user toggles `?` on // levelBufferTables. func (m *Model) renderBufferInfo(height int) string { - sw := func(style lipgloss.Style) string { return style.Render("▇") } + sw := swatch var b strings.Builder mu := styleMuted.Render - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("Bar reference") + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + infoHeader(&b, "Bar reference") b.WriteString(" " + styleHeader.Render(" server memory ") + " " + mu("the whole host's RAM (MemTotal) — the superset") + "\n") @@ -51,11 +49,7 @@ func (m *Model) renderBufferInfo(height int) string { b.WriteString(" " + mu("Tables ranked 11+ use the default bar colour. ") + styleBadge.Render("enter") + mu(" opens a per-table breakdown.") + "\n") - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } // renderUsageHeatBar paints the cluster (or any) usagecount histogram as one @@ -227,15 +221,11 @@ func (m *Model) renderBufferDetail(s *screen, height int) string { b.WriteString(line + "\n") } b.WriteString("\n " + mu("0 = cold (evictable) → 5 = hot (frequently reused) · ") + - styleDirty.Render("▇") + mu(" dirty (modified, awaiting flush)") + "\n") + swatch(styleDirty) + mu(" dirty (modified, awaiting flush)") + "\n") } } - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } // renderBufferDetailInfo is the ? overlay for the per-table buffer-detail @@ -244,10 +234,8 @@ func (m *Model) renderBufferDetail(s *screen, height int) string { func (m *Model) renderBufferDetailInfo(height int) string { var b strings.Builder mu := styleMuted.Render - sw := func(style lipgloss.Style) string { return style.Render("▇") } - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("Buffer detail reference") + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + sw := swatch + infoHeader(&b, "Buffer detail reference") b.WriteString(" " + styleHeader.Render(" cache footprint ") + " " + mu("how much of this table lives in shared_buffers") + "\n") @@ -268,11 +256,7 @@ func (m *Model) renderBufferDetailInfo(height int) string { b.WriteString(" " + mu(" checkpointer still owes a write; lots of hot+dirty = write pressure") + "\n") b.WriteString(" " + mu("each row's bar is scaled to the table's busiest band, so band sizes compare.") + "\n") - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } // summaryBarWidth picks the bar width for the two stacked summary bars. @@ -325,7 +309,7 @@ func (m *Model) renderBufferSummary(s *screen) (string, map[uint32]int) { cache := max(sum.ServerMemAvailableBytes-sum.ServerMemFreeBytes, 0) bar := renderServerMemBar(sbUsed, sbFree, otherUsed, cache, sum.ServerMemBytes, barW) muted := styleMuted.Render - sw := func(style lipgloss.Style) string { return style.Render("▇") + " " } + sw := func(style lipgloss.Style) string { return swatch(style) + " " } stats := muted(fmt.Sprintf("shared buffer %s (", humanize.Bytes(sbTotal))) + sw(styleBar) + muted(fmt.Sprintf("used %s / ", humanize.Bytes(sbUsed))) + sw(styleSBFree) + muted(fmt.Sprintf("free %s) · ", humanize.Bytes(sbFree))) + @@ -351,7 +335,7 @@ func (m *Model) renderBufferSummary(s *screen) (string, map[uint32]int) { usedPct := float64(sum.ThisDBBytes+sum.OtherDBBytes) * 100 / float64(sum.TotalBytes) usedStr := percentStyle(usedPct).Render(fmt.Sprintf("%.1f%% used", usedPct)) muted := styleMuted.Render - sw := func(style lipgloss.Style) string { return style.Render("▇") + " " } + sw := func(style lipgloss.Style) string { return swatch(style) + " " } sbStats := usedStr + muted(" · ") + sw(styleBar) + muted(fmt.Sprintf("this db %s · ", humanize.Bytes(sum.ThisDBBytes))) + sw(styleBarAlt) + muted(fmt.Sprintf("other %s · ", humanize.Bytes(sum.OtherDBBytes))) + @@ -392,7 +376,7 @@ func (m *Model) bufferTemperatureLines(sum *pg.BufferCacheSummary, barW int) str cold := sum.UsageCounts[0].Buffers hot := sum.UsageCounts[len(sum.UsageCounts)-1].Buffers muted := styleMuted.Render - sw := func(style lipgloss.Style) string { return style.Render("▇") + " " } + sw := func(style lipgloss.Style) string { return swatch(style) + " " } stats := muted(fmt.Sprintf("avg %.1f/5 · ", avg)) + sw(usageHeatStyle(0)) + muted(fmt.Sprintf("cold %.0f%% · ", float64(cold)*100/float64(totBufs))) + sw(usageHeatStyle(len(usageHeatPalette)-1)) + muted(fmt.Sprintf("hot %.0f%% · ", float64(hot)*100/float64(totBufs))) + diff --git a/internal/tui/view_diag.go b/internal/tui/view_diag.go index 0e4805d..a1a228f 100644 --- a/internal/tui/view_diag.go +++ b/internal/tui/view_diag.go @@ -150,12 +150,7 @@ func (m *Model) renderDescribe(s *screen, height int) string { } } - // Pad to fill the content area so the help row stays pinned. - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } // renderDescribeBufferRows renders the body of the describe-table cache-footprint diff --git a/internal/tui/view_heap_pages.go b/internal/tui/view_heap_pages.go index 3bc8a02..d851039 100644 --- a/internal/tui/view_heap_pages.go +++ b/internal/tui/view_heap_pages.go @@ -16,12 +16,10 @@ import ( // so the help row stays pinned to the bottom. Shown when the user toggles // `?` on levelHeapPages. func (m *Model) renderHeapPagesInfo(height int) string { - sw := func(style lipgloss.Style) string { return style.Render("▇") } + sw := swatch mu := styleMuted.Render var b strings.Builder - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("Page reference") + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + infoHeader(&b, "Page reference") b.WriteString(" " + styleHeader.Render(" page bar ") + " " + mu("each row is one 8 KiB heap page — the bar shows how that page is packed") + "\n") @@ -50,11 +48,7 @@ func (m *Model) renderHeapPagesInfo(height int) string { b.WriteString(" " + mu("PgUp/PgDn slides the load window ("+strconv.Itoa(int(heapWindowDefault))+" pages per step).") + "\n") b.WriteString(" " + mu("Within a window, j/k or arrows move the cursor; Enter drills into one page.") + "\n") - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } // renderHeapTuplesInfo draws a static explainer for the per-tuple drill view: @@ -65,9 +59,7 @@ func (m *Model) renderHeapPagesInfo(height int) string { func (m *Model) renderHeapTuplesInfo(height int) string { mu := styleMuted.Render var b strings.Builder - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("Tuple reference") + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + infoHeader(&b, "Tuple reference") b.WriteString(" " + styleHeader.Render(" columns ") + " " + mu("one row per item-pointer in the page's LP array") + "\n") @@ -115,11 +107,7 @@ func (m *Model) renderHeapTuplesInfo(height int) string { b.WriteString(" " + padRight("lifecycle:", 12) + mu("MVCC story — who inserted it (and if that committed), then deleted/locked/live") + "\n") b.WriteString(" " + padRight("layout:", 12) + mu("header vs payload bytes (split at t_hoff) with a bar, plus the page byte span") + "\n") - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } // heapPageTableLabel reports the qualified relation name for the status line diff --git a/internal/tui/view_maintenance.go b/internal/tui/view_maintenance.go index 9e4ac66..2bcf20a 100644 --- a/internal/tui/view_maintenance.go +++ b/internal/tui/view_maintenance.go @@ -363,10 +363,7 @@ func (m *Model) renderSettingsList(s *screen, height int) string { func (m *Model) renderMaintenanceInfo(height int) string { mu := styleMuted.Render var b strings.Builder - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("system overview reference") + - mu(" · press ") + styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + - mu(" to dismiss") + "\n\n") + infoHeader(&b, "system overview reference") b.WriteString(" " + styleHeader.Render(" extension capacity ") + "\n") b.WriteString(" " + mu("pg_stat_statements and pg_qualstats both pre-allocate a fixed shared-memory array (the .max") + "\n") diff --git a/internal/tui/view_overlays.go b/internal/tui/view_overlays.go index bc557a0..c1fc4b1 100644 --- a/internal/tui/view_overlays.go +++ b/internal/tui/view_overlays.go @@ -83,7 +83,7 @@ func (m *Model) renderInfoOverlay(s *screen, height int) string { // levels whose bars are monochrome (no legend needed). func renderLegend(s *screen) string { swatch := func(style lipgloss.Style, label string) string { - return style.Render("▇") + " " + styleMuted.Render(label) + return swatch(style) + " " + styleMuted.Render(label) } sep := styleMuted.Render(" · ") switch s.level { @@ -255,11 +255,7 @@ func (m *Model) renderExtPrompt(s *screen, height int) string { default: b.WriteString(" " + styleErr.Render(p.name+" is not available on this server — ask the DBA to install it") + "\n") } - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } // renderUpgradePrompt renders the blocking "extension outdated" screen for the @@ -294,9 +290,5 @@ func (m *Model) renderUpgradePrompt(s *screen, height int) string { b.WriteString(" " + styleErr.Render("the server's own "+p.name+" ("+p.available+ ") is older than pgdu needs — upgrade PostgreSQL / the extension package") + "\n") } - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } diff --git a/internal/tui/view_pages.go b/internal/tui/view_pages.go index 2f642f0..504b15a 100644 --- a/internal/tui/view_pages.go +++ b/internal/tui/view_pages.go @@ -17,12 +17,10 @@ import ( // view. Sized to fill `height` lines so the help row stays pinned to the // bottom. Shown when the user toggles `?` on levelIndexPages. func (m *Model) renderIndexPagesInfo(height int) string { - sw := func(style lipgloss.Style) string { return style.Render("▇") } + sw := swatch mu := styleMuted.Render var b strings.Builder - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("B-tree page reference") + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + infoHeader(&b, "B-tree page reference") b.WriteString(" " + styleHeader.Render(" page bar ") + " " + mu("each row is one 8 KiB index page — the bar shows how that page is packed") + "\n") @@ -77,11 +75,7 @@ func (m *Model) renderIndexPagesInfo(height int) string { b.WriteString(" " + mu("Within a window, j/k or arrows move the cursor; Enter drills into one page's items.") + "\n") b.WriteString(" " + mu("Block 0 is the metapage — skipped here; it carries the root pointer, not a tree page.") + "\n") - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } // renderIndexTuplesInfo explains what the user is looking at on a B-tree @@ -93,9 +87,7 @@ func (m *Model) renderIndexPagesInfo(height int) string { func (m *Model) renderIndexTuplesInfo(height int) string { mu := styleMuted.Render var b strings.Builder - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("Index-tuple reference") + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + infoHeader(&b, "Index-tuple reference") b.WriteString(" " + styleHeader.Render(" tuple kinds ") + " " + mu("the ctid column reveals which kind each row is") + "\n") @@ -148,11 +140,7 @@ func (m *Model) renderIndexTuplesInfo(height int) string { b.WriteString(" " + mu("don't drill — there's no single heap row to land on.") + "\n\n") b.WriteString(" " + mu("Reading bt_page_items / bt_metap needs a superuser (or pg_read_server_files).") + "\n") - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } // renderRelationsList draws the page-inspector tool's flat list of heap @@ -1338,23 +1326,6 @@ func renderGinTupleRow(t pg.GinItem, sampleW, tidW, bytesW, countW int, selected // ─── per-AM ? reference overlays ───────────────────────────────────────────── -// infoHeader writes the standard overlay title + dismiss-hint line into b. -// Shared by the gist/brin/gin reference overlays. -func infoHeader(b *strings.Builder, title string) { - mu := styleMuted.Render - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render(title) + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") -} - -func infoPad(b *strings.Builder, height int) string { - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() -} - func (m *Model) renderGistInfo(height int, tuples bool) string { mu := styleMuted.Render var b strings.Builder @@ -1368,7 +1339,7 @@ func (m *Model) renderGistInfo(height int, tuples bool) string { b.WriteString(" " + mu("Enter descends an internal downlink toward the leaves, or opens the heap row a") + "\n") b.WriteString(" " + mu("leaf entry points at. GiST keys have no total order, so there's no key-seek —") + "\n") b.WriteString(" " + mu("use the ") + styleBadge.Render("/") + mu(" filter to search the rendered keys text.") + "\n") - return infoPad(&b, height) + return padInfo(&b, height) } infoHeader(&b, "GiST page reference") b.WriteString(" " + mu("GiST has no metapage — block 0 is the root, so every page is browsable.") + "\n\n") @@ -1378,7 +1349,7 @@ func (m *Model) renderGistInfo(height int, tuples bool) string { b.WriteString(" " + padRight("free", 8) + mu("free space as a percent of the page") + "\n\n") b.WriteString(" " + mu("PgUp/PgDn slides the load window ("+strconv.Itoa(int(heapWindowDefault))+" pages per step); Enter drills a page's items.") + "\n") b.WriteString(" " + mu("Reading gist_page_* needs a superuser (or pg_read_server_files).") + "\n") - return infoPad(&b, height) + return padInfo(&b, height) } func (m *Model) renderBrinInfo(height int, tuples bool) string { @@ -1394,7 +1365,7 @@ func (m *Model) renderBrinInfo(height int, tuples bool) string { b.WriteString(" " + padRight("summary", 12) + mu("the opclass-rendered summary value (e.g. a min…max range)") + "\n\n") b.WriteString(" " + mu("Press ") + styleBadge.Render("s") + mu(" to seek to the range covering a heap block number.") + "\n") b.WriteString(" " + mu("Enter jumps to the heap pages of the summarised block range.") + "\n") - return infoPad(&b, height) + return padInfo(&b, height) } infoHeader(&b, "BRIN page reference") b.WriteString(" " + mu("BRIN pages come in three kinds; the banner above carries the metapage summary.") + "\n\n") @@ -1403,7 +1374,7 @@ func (m *Model) renderBrinInfo(height int, tuples bool) string { b.WriteString(" " + styleBarAlt.Render(padRight("meta", 9)) + mu("metapage (block 0): pages-per-range, version") + "\n\n") b.WriteString(" " + mu("PgUp/PgDn slides the load window; Enter on a regular page lists its summaries.") + "\n") b.WriteString(" " + mu("Reading brin_* needs a superuser (or pg_read_server_files).") + "\n") - return infoPad(&b, height) + return padInfo(&b, height) } func (m *Model) renderGinInfo(height int, tuples bool) string { @@ -1418,7 +1389,7 @@ func (m *Model) renderGinInfo(height int, tuples bool) string { b.WriteString(" " + padRight("tids", 12) + mu("number of heap TIDs packed into the segment") + "\n\n") b.WriteString(" " + styleHeapToastTag.Render("Note") + mu(": pageinspect cannot list GIN entry-tree keys, so only data-leaf") + "\n") b.WriteString(" " + mu("pages are itemizable. These rows are terminal (no per-row drill).") + "\n") - return infoPad(&b, height) + return padInfo(&b, height) } infoHeader(&b, "GIN page reference") b.WriteString(" " + mu("A GIN index is an entry tree (keys) over posting trees/lists (heap tids).") + "\n\n") @@ -1428,5 +1399,5 @@ func (m *Model) renderGinInfo(height int, tuples bool) string { b.WriteString(" " + styleBarAlt.Render(padRight("meta", 10)) + mu("metapage (block 0): entry/data page counts, pending list") + "\n\n") b.WriteString(" " + mu("The banner shows entry/data page counts and pending-list size. PgUp/PgDn slides") + "\n") b.WriteString(" " + mu("the window; Enter on a data-leaf page lists its posting segments.") + "\n") - return infoPad(&b, height) + return padInfo(&b, height) } diff --git a/internal/tui/view_queries.go b/internal/tui/view_queries.go index af36a1d..012d4a9 100644 --- a/internal/tui/view_queries.go +++ b/internal/tui/view_queries.go @@ -2,9 +2,7 @@ package tui import ( "fmt" - "strconv" "strings" - "time" "github.com/charmbracelet/lipgloss" @@ -97,22 +95,6 @@ func flattenQuery(q string) string { return strings.Join(strings.Fields(q), " ") } -// fmtFloat renders a number with up to 1 decimals, trailing zeros stripped. -func fmtFloat(f float64) string { - s := strconv.FormatFloat(f, 'f', 1, 64) - if strings.ContainsRune(s, '.') { - s = strings.TrimRight(strings.TrimRight(s, "0"), ".") - } - return s -} - -// fmt1 renders a number with exactly one decimal place (60 → "60.0", 98.51 → -// "98.5"). The top-queries numeric columns use it so every value shows a single -// fractional digit rather than a ragged mix of 0/1/2 places. -func fmt1(f float64) string { - return strconv.FormatFloat(f, 'f', 1, 64) -} - // planTimeMetric renders the detail-view plan-time line, distinguishing a real // zero from "not collected" (pg_stat_statements.track_planning off). func planTimeMetric(q pg.QueryStat, trackPlanning bool, mu func(...string) string) string { @@ -122,34 +104,6 @@ func planTimeMetric(q pg.QueryStat, trackPlanning bool, mu func(...string) strin return fmtMs(q.TotalPlanTime) + " ms" + mu(fmt.Sprintf(" (%s plans)", formatRows(q.Plans))) } -// fmtMs formats a millisecond duration compactly: sub-millisecond and small -// values keep ms; large values switch to seconds so the column stays narrow. -func fmtMs(ms float64) string { - if ms >= 100000 { - return fmt1(ms/1000) + "s" - } - return fmt1(ms) -} - -// fmtAge formats an elapsed time (in ms) as an age with an explicit, scale- -// appropriate unit so values never read ambiguously: "850ms", "31.1s", "11.2m", -// "3.1h", "2.4d". Unlike fmtMs the unit is always present, which is what lets the -// reader tell 105ms from 105s at a glance (paired with durationStyle colouring). -func fmtAge(ms float64) string { - switch { - case ms < 1000: - return fmt.Sprintf("%.0fms", ms) - case ms < 60*1000: - return fmt1(ms/1000) + "s" - case ms < 60*60*1000: - return fmt1(ms/(60*1000)) + "m" - case ms < 24*60*60*1000: - return fmt1(ms/(60*60*1000)) + "h" - default: - return fmt1(ms/(24*60*60*1000)) + "d" - } -} - // --- window-status header (levelStatements) --- func (m *Model) renderStatementsHeader(s *screen) string { @@ -212,33 +166,13 @@ func (m *Model) refreshSentence() string { return "It re-samples every " + m.statRefresh.String() + " — press t to cycle the cadence (2s → 60s → off)." } -// fmtDuration renders a window span with explicit units — "45s", "13m 12s", -// "2h 05m", "3d 4h" — so it never reads as a wall-clock time. The old H:MM:SS -// form made "13:12" ambiguous with a start timestamp, which is the whole reason -// it sits next to "since 05:56:46". -func fmtDuration(d time.Duration) string { - d = d.Round(time.Second) - switch { - case d < time.Minute: - return fmt.Sprintf("%ds", int(d/time.Second)) - case d < time.Hour: - return fmt.Sprintf("%dm %02ds", int(d/time.Minute), int(d%time.Minute/time.Second)) - case d < 24*time.Hour: - return fmt.Sprintf("%dh %02dm", int(d/time.Hour), int(d%time.Hour/time.Minute)) - default: - return fmt.Sprintf("%dd %dh", int(d/(24*time.Hour)), int(d%(24*time.Hour)/time.Hour)) - } -} - // renderStatementsInfo is the ? overlay for the top-queries tool: it explains // the window model (which is the subtle part — pg_stat_statements has no time // axis) and every column. func (m *Model) renderStatementsInfo(height int) string { mu := styleMuted.Render var b strings.Builder - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("Top queries reference") + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + infoHeader(&b, "Top queries reference") b.WriteString(" " + styleHeader.Render(" the window ") + " " + mu("why numbers start at zero and grow") + "\n") diff --git a/internal/tui/view_queries_detail.go b/internal/tui/view_queries_detail.go index 201e822..b11ee45 100644 --- a/internal/tui/view_queries_detail.go +++ b/internal/tui/view_queries_detail.go @@ -381,12 +381,7 @@ func (m *Model) renderStatementSamples(s *screen, height int) string { styleErr.Render("executes the query for real") + "\n") } - // Pad to fill the content area so the help row stays pinned. - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } // wrapDetail hard-wraps text to the detail panel's usable width (terminal minus diff --git a/internal/tui/view_shmem.go b/internal/tui/view_shmem.go index 4ced7e1..19ef352 100644 --- a/internal/tui/view_shmem.go +++ b/internal/tui/view_shmem.go @@ -184,7 +184,7 @@ func (m *Model) renderShmemSummary(s *screen) string { bar := paintBar(barW, segs...) muted := styleMuted.Render - sw := func(style lipgloss.Style) string { return style.Render("▇") + " " } + sw := func(style lipgloss.Style) string { return swatch(style) + " " } var stats strings.Builder stats.WriteString(muted(fmt.Sprintf("total %s · ", humanize.Bytes(grand)))) for _, c := range shmemCatOrder { @@ -255,10 +255,8 @@ func renderShmemRow(it item, a pg.ShmemAllocation, maxSize, grand int64, barW in func (m *Model) renderShmemInfo(height int) string { var b strings.Builder mu := styleMuted.Render - sw := func(c shmemCat) string { return shmemCatStyle(c).Render("▇") } - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("Shared-memory map") + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + sw := func(c shmemCat) string { return swatch(shmemCatStyle(c)) } + infoHeader(&b, "Shared-memory map") b.WriteString(" " + mu("Every region of the Postgres shared-memory segment (pg_shmem_allocations),") + "\n") b.WriteString(" " + mu("not just the buffer pool. The bar groups allocations by subsystem; the muted") + "\n") @@ -286,9 +284,5 @@ func (m *Model) renderShmemInfo(height int) string { b.WriteString("\n " + mu("Reading pg_shmem_allocations needs pg_read_all_stats / superuser; without it") + "\n") b.WriteString(" " + mu("the view shows a permission error instead of the map.") + "\n") - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } diff --git a/internal/tui/view_shmem_test.go b/internal/tui/view_shmem_test.go new file mode 100644 index 0000000..178aeb4 --- /dev/null +++ b/internal/tui/view_shmem_test.go @@ -0,0 +1,79 @@ +package tui + +import ( + "testing" + + "pgdu/internal/pg" +) + +// TestShmemCatOf pins the category bucketing, in particular the match-order +// subtleties documented on shmemCatOf: "Backend … Buffer" regions must not +// read as the buffer pool, "Buffer Blocks" contains the substring "lock", and +// the lock tables contain "PROC". +func TestShmemCatOf(t *testing.T) { + cases := []struct { + name string + a pg.ShmemAllocation + want shmemCat + }{ + // The two NULL-name rows are classified by flag, never by name. + {"anonymous", pg.ShmemAllocation{Anonymous: true}, catAnon}, + {"free tail", pg.ShmemAllocation{Free: true}, catFree}, + + // Per-backend regions named "Backend … Buffer" must match before the + // buffer-pool test. + {"backend activity buffer", pg.ShmemAllocation{Name: "Backend Activity Buffer"}, catBackends}, + {"backend status array", pg.ShmemAllocation{Name: "Backend Status Array"}, catBackends}, + {"shmInvalBuffer", pg.ShmemAllocation{Name: "shmInvalBuffer"}, catBackends}, + + // "Buffer Blocks" contains "lock" (b·lock·s): buffer must win over locks. + {"buffer blocks", pg.ShmemAllocation{Name: "Buffer Blocks"}, catBuffer}, + {"buffer descriptors", pg.ShmemAllocation{Name: "Buffer Descriptors"}, catBuffer}, + {"checkpointer data", pg.ShmemAllocation{Name: "Checkpointer Data"}, catBuffer}, + {"checkpoint bufferids", pg.ShmemAllocation{Name: "Checkpoint BufferIds"}, catBuffer}, + + // Lock tables contain "PROC": locks must win over the broad "proc" + // backend test. "SERIALIZABLEXACT" also contains "xact": locks first. + {"lock manager", pg.ShmemAllocation{Name: "Lock Manager"}, catLocks}, + {"proclock hash", pg.ShmemAllocation{Name: "PROCLOCK hash"}, catLocks}, + {"predicate lock target hash", pg.ShmemAllocation{Name: "PREDICATELOCKTARGET hash"}, catLocks}, + {"serializable xact hash", pg.ShmemAllocation{Name: "SERIALIZABLEXACT hash"}, catLocks}, + + {"xlog ctl", pg.ShmemAllocation{Name: "XLOG Ctl"}, catWAL}, + {"wal receiver ctl", pg.ShmemAllocation{Name: "Wal Receiver Ctl"}, catWAL}, + + {"async queue control", pg.ShmemAllocation{Name: "Async Queue Control"}, catXact}, + {"known assigned xids", pg.ShmemAllocation{Name: "KnownAssignedXids"}, catXact}, + {"shared multixact state", pg.ShmemAllocation{Name: "Shared MultiXact State"}, catXact}, + + {"proc header", pg.ShmemAllocation{Name: "Proc Header"}, catBackends}, + {"pmsignal state", pg.ShmemAllocation{Name: "PMSignalState"}, catBackends}, + {"background worker data", pg.ShmemAllocation{Name: "Background Worker Data"}, catBackends}, + + {"pg_stat_statements", pg.ShmemAllocation{Name: "pg_stat_statements"}, catStats}, + {"shared memory stats", pg.ShmemAllocation{Name: "Shared Memory Stats"}, catStats}, + + {"unmatched name", pg.ShmemAllocation{Name: "Archiver Data"}, catOther}, + {"empty name", pg.ShmemAllocation{}, catOther}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := shmemCatOf(c.a); got != c.want { + t.Errorf("shmemCatOf(%q) = %v (%s), want %v (%s)", + c.a.Name, got, got.label(), c.want, c.want.label()) + } + }) + } +} + +func TestShmemDisplayName(t *testing.T) { + if got := shmemDisplayName(pg.ShmemAllocation{Anonymous: true}); got != "" { + t.Errorf("anonymous display = %q", got) + } + if got := shmemDisplayName(pg.ShmemAllocation{Free: true}); got != "" { + t.Errorf("free display = %q", got) + } + if got := shmemDisplayName(pg.ShmemAllocation{Name: "XLOG Ctl"}); got != "XLOG Ctl" { + t.Errorf("named display = %q", got) + } +} diff --git a/internal/tui/view_snapshots.go b/internal/tui/view_snapshots.go index f522320..04f93df 100644 --- a/internal/tui/view_snapshots.go +++ b/internal/tui/view_snapshots.go @@ -52,10 +52,7 @@ func (m *Model) renderStatementSnapshots(s *screen, height int) string { if len(s.items) == 0 { b.WriteString(" " + mu("no snapshots yet — press ") + styleBadge.Render("S") + mu(" in the queries view to save one") + "\n") - for i := strings.Count(b.String(), "\n"); i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } listH := max(height-used, 1) @@ -120,12 +117,7 @@ func (m *Model) renderStatementSnapshots(s *screen, height int) string { b.WriteString("\n") } - // Pad to fill the content area so the help row stays pinned. - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } // snapshotAge renders the age column for a browser row. The synthetic anchors diff --git a/internal/tui/view_tablestats.go b/internal/tui/view_tablestats.go index 1ce321f..2f7a3f0 100644 --- a/internal/tui/view_tablestats.go +++ b/internal/tui/view_tablestats.go @@ -62,10 +62,7 @@ func (m *Model) renderTableStatsInfo(height int) string { mu := styleMuted.Render var b strings.Builder - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("table overview reference") + - mu(" · press ") + styleBadge.Render("?") + mu(" or ") + - styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + infoHeader(&b, "table overview reference") b.WriteString(" " + mu("One row per base / partitioned / materialized table in the schema. Write and") + "\n") b.WriteString(" " + mu("scan counters (ins/upd/del, seq/idx, cache) are cumulative since the last stats") + "\n") b.WriteString(" " + mu("reset; sizes and ages are point-in-time. Press ") + styleBadge.Render("C") + diff --git a/internal/tui/view_wal.go b/internal/tui/view_wal.go index 406dc2c..c017b5d 100644 --- a/internal/tui/view_wal.go +++ b/internal/tui/view_wal.go @@ -556,12 +556,10 @@ func renderWALRelRow(it item, st pg.WALRelStat, maxSize int64, barW int, selecte // the record-vs-FPI byte split means, and why FPI matters for tuning. Sized // to fill `height` lines so the help row stays pinned to the bottom. func (m *Model) renderWALInfo(height int) string { - sw := func(style lipgloss.Style) string { return style.Render("▇") } + sw := swatch mu := styleMuted.Render var b strings.Builder - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("WAL inspector reference") + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + infoHeader(&b, "WAL inspector reference") b.WriteString(" " + styleHeader.Render(" what WAL is ") + " " + mu("the write-ahead log — Postgres's durability & replication journal") + "\n") @@ -621,9 +619,7 @@ func (m *Model) renderWALInfo(height int) string { func (m *Model) renderWALRecordsInfo(height int) string { mu := styleMuted.Render var b strings.Builder - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("WAL records reference") + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + infoHeader(&b, "WAL records reference") b.WriteString(" " + styleHeader.Render(" this view ") + " " + mu("every WAL record the selected resource manager wrote in the window, oldest first") + "\n") @@ -639,7 +635,7 @@ func (m *Model) renderWALRecordsInfo(height int) string { b.WriteString(" " + padRight("description", 12) + mu("pg_walinspect's human-readable decode of the record's payload") + "\n\n") b.WriteString(" " + styleHeader.Render(" the bar ") + " " + - styleBar.Render("▇") + mu(" record bytes · ") + styleBarAlt.Render("▇") + + swatch(styleBar) + mu(" record bytes · ") + swatch(styleBarAlt) + mu(" FPI bytes — scaled to the biggest record in this list") + "\n\n") b.WriteString(" " + styleHeader.Render(" summary table ") + " " + @@ -659,9 +655,7 @@ func (m *Model) renderWALRecordsInfo(height int) string { func (m *Model) renderWALBlocksInfo(height int) string { mu := styleMuted.Render var b strings.Builder - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("WAL block references reference") + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + infoHeader(&b, "WAL block references reference") b.WriteString(" " + styleHeader.Render(" this view ") + " " + mu("the page(s) one WAL record modified — its tie-back from the log to physical storage") + "\n") @@ -681,7 +675,7 @@ func (m *Model) renderWALBlocksInfo(height int) string { b.WriteString(" " + padRight("db", 12) + mu("reldatabase OID — 0 for shared catalogs that live outside any one database") + "\n\n") b.WriteString(" " + styleHeader.Render(" the bar & fpi ") + " " + - styleBarAlt.Render("▇") + mu(" full-page-image bytes for this block (empty = the record logged only the change)") + "\n") + swatch(styleBarAlt) + mu(" full-page-image bytes for this block (empty = the record logged only the change)") + "\n") b.WriteString(" " + padRight("data", 12) + mu("block_data_length — bytes of per-block change data (tuple, offsets, …)") + "\n") b.WriteString(" " + padRight("fpi-info", 12) + mu("flags on the page image, e.g. APPLY (replayed) / HAS_HOLE / COMPRESS_*") + "\n\n") @@ -707,12 +701,10 @@ func (m *Model) renderWALBlocksInfo(height int) string { // renderWALRelationsInfo explains the by-relation breakdown: how the window is // re-aggregated per table/index, what the columns mean, and how to drill. func (m *Model) renderWALRelationsInfo(height int) string { - sw := func(style lipgloss.Style) string { return style.Render("▇") } + sw := swatch mu := styleMuted.Render var b strings.Builder - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("WAL by relation reference") + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + infoHeader(&b, "WAL by relation reference") b.WriteString(" " + styleHeader.Render(" this view ") + " " + mu("which table/index generated the WAL in the window — \"what caused the change\"") + "\n") @@ -736,14 +728,3 @@ func (m *Model) renderWALRelationsInfo(height int) string { return padInfo(&b, height) } - -// padInfo pads an info overlay's builder to exactly `height` lines so the -// help row stays pinned to the bottom of the screen. Mirrors the inline -// padding loop the other render*Info helpers use. -func padInfo(b *strings.Builder, height int) string { - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() -} From 6391be2fb844a8bc8cbcaf1110e8e9bbeb3e998d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20D=C3=B6tsch?= Date: Sat, 4 Jul 2026 06:44:15 +0200 Subject: [PATCH 2/6] tui/pg: richer diagnostics, highlighting, and a live lock-tree view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit-driven improvements across every tool/view plus new diagnostics and a new blocking-chain view. Usability/consistency: - Diagnostic.Kinds lets registry diagnostics declare graded column kinds the name heuristic can't infer (hit ratios green→red, dead/stale/consumed % red→ green); annotate the existing entries. - C column picker + Σ footer on levelDiagnosticResult (matches the other three generic tables), remembered per-diagnostic in prefs. - Activity: d describes the selected backend's table; mem/read/s/write/s grade. - Top-queries: opt-in min_ms/max_ms/stddev_ms + derived cv (erratic runtime). - Diagnostics list: colored category badges + f category filter. Highlighting & cross-links: - Parts panel grades dead-tuple % and vacuum/analyze age; buffer detail grades dirty share + wires d; shmem shows buffer-pool share; WAL relations grade FPI write-amplification; activity gains opt-in blocked_by; diag d resolves index names too. - Maintenance dashboard: a/w/r jump into Activity, WAL, replication slots. New diagnostics: fk_missing_index, index_redundant_prefix, idle_in_xact_holders, lock_summary, progress_all, slru_stats, index_io, subscription_stats, stale_statistics. New view: levelLockTree under Activity (b) — pg_blocking_pids forest as an indented tree, auto-refresh, k/x cancel/terminate. Docs: docs/future/ design notes for a progress monitor, health-triage report, and wait-event profiler. Integration tests (PGDU_TEST_DSN) cover all diagnostics plus FK/redundant-index detection and the lock-tree blocking chain. --- docs/future/health-triage.md | 67 ++++++ docs/future/progress-monitor.md | 63 ++++++ docs/future/wait-event-profiler.md | 68 ++++++ internal/pg/activity.go | 2 +- internal/pg/describe.go | 19 ++ internal/pg/diagnostic_defs.go | 101 +++++++++ internal/pg/diagnostics.go | 17 ++ internal/pg/diagnostics_integration_test.go | 222 ++++++++++++++++++ internal/pg/locks.go | 34 +++ internal/pg/locks_scenario_test.go | 90 ++++++++ internal/pg/queries_activity.go | 3 + internal/pg/queries_describe.go | 13 ++ internal/pg/queries_diag.go | 230 +++++++++++++++++++ internal/pg/queries_locks.go | 56 +++++ internal/pg/types_activity.go | 1 + internal/pg/types_locks.go | 23 ++ internal/tui/activity_columns.go | 18 +- internal/tui/app.go | 66 +++++- internal/tui/cmds.go | 14 ++ internal/tui/cmds_activity.go | 16 ++ internal/tui/diag_columns.go | 190 ++++++++++++++++ internal/tui/keys.go | 44 +++- internal/tui/queries_columns.go | 41 ++++ internal/tui/styles.go | 19 ++ internal/tui/update.go | 2 + internal/tui/update_keys.go | 191 ++++++++++++++-- internal/tui/update_keys_columns.go | 64 ++++++ internal/tui/update_load.go | 14 +- internal/tui/update_msgs.go | 67 +++--- internal/tui/update_sort.go | 5 + internal/tui/view.go | 6 +- internal/tui/view_activity.go | 2 + internal/tui/view_buffers.go | 9 +- internal/tui/view_diag.go | 113 +++++++++- internal/tui/view_locktree.go | 238 ++++++++++++++++++++ internal/tui/view_maintenance.go | 5 +- internal/tui/view_parts.go | 39 +++- internal/tui/view_shmem.go | 11 +- internal/tui/view_wal.go | 13 +- 39 files changed, 2097 insertions(+), 99 deletions(-) create mode 100644 docs/future/health-triage.md create mode 100644 docs/future/progress-monitor.md create mode 100644 docs/future/wait-event-profiler.md create mode 100644 internal/pg/diagnostics_integration_test.go create mode 100644 internal/pg/locks.go create mode 100644 internal/pg/locks_scenario_test.go create mode 100644 internal/pg/queries_locks.go create mode 100644 internal/pg/types_locks.go create mode 100644 internal/tui/diag_columns.go create mode 100644 internal/tui/view_locktree.go diff --git a/docs/future/health-triage.md b/docs/future/health-triage.md new file mode 100644 index 0000000..bfa4fc0 --- /dev/null +++ b/docs/future/health-triage.md @@ -0,0 +1,67 @@ +# Future: one-key health triage report + +Status: design only (not implemented). + +## Motivation + +A DBA opening pgdu on an unfamiliar or misbehaving server wants a single "what's +wrong right now?" answer before drilling into any one tool. Today that means +running eight diagnostics by hand. A one-key triage runs a curated battery +concurrently and returns a red/yellow/green checklist, each line drilling into +the diagnostic (or tool) that backs it. + +## The battery + +Reuse existing diagnostics/queries so the thresholds live in one place: + +| Check | Source | Red when … | +|--------------------------|-----------------------------------------------------|-------------------------------------| +| Transaction wraparound | `sqlMaintWraparound` / per-table `FreezeFrac` | age > 80% of autovacuum_freeze_max | +| Blocked backends | `ListLockWaiters` (D1) | any backend waiting > 30s | +| Idle-in-transaction | `idle_in_xact_holders` diagnostic | oldest xact > 5m | +| Replication lag / slots | `replication_slots` diagnostic | inactive slot or retained WAL > cap | +| Cache hit ratio | `database_stats` diagnostic | hit% < 90 | +| SLRU pressure | `slru_stats` diagnostic | any hit% < 90 with heavy reads | +| Sequence exhaustion | `sequences` diagnostic | consumed% > 80 | +| Bloat (top-N) | `bloat_table` / `bloat_index` | any > 50% and large | +| Invalid indexes | new small query on `pg_index.indisvalid = false` | any exist | +| Temp-file / deadlocks | `database_stats` | rising deadlocks / large temp_bytes | + +## UX sketch + +`!` on `levelTools` (or `levelMaintenance`) runs the battery and pushes a +`levelTriage` screen: + +``` +● wraparound oldest datfrozenxid 41% of freeze_max ok +▲ blocked backends 2 backends waiting (longest 48s) warn → ↵ lock tree +✗ idle-in-xact pid 8123 idle in transaction 11m crit → ↵ diagnostic +● cache hit ratio 99.3% ok +✗ invalid indexes 1 index left INVALID by a failed build crit → ↵ describe +``` + +Enter drills into the backing diagnostic/tool for the selected line. Sort by +severity; green lines collapse to a summary count so the eye lands on red first. + +## Wiring outline + +- `internal/pg/triage.go`: a `Triage(ctx)` that fans the checks out concurrently + (each is an existing `RunDiagnostic` / method call) under one budget, returning + `[]TriageResult{Check, Severity, Detail, DiagKey}`. +- `levelTriage` enum; `view_triage.go` renderer (reuse `stateStyle`-like + severity colours and the graded styles from `styles.go`). +- `!` key bound + enabled on `levelTools`; Enter maps `DiagKey` back to a + `diagnosticResultScreen` push (or the lock tree for the blocked-backends line). + +## Effort + +~4–5 days. The value is in curating thresholds, not new plumbing — every check +already has a query. Do it after the graded-kind work (bucket A4) so triage lines +reuse the same colour language. + +## Risks + +- Threshold opinions invite bikeshedding; keep them as named constants in + `triage.go` with a comment justifying each, and treat them as tunable. +- Concurrency under one 30s budget: cap the fan-out and let a slow/failed check + degrade to a "could not evaluate" line rather than failing the whole report. diff --git a/docs/future/progress-monitor.md b/docs/future/progress-monitor.md new file mode 100644 index 0000000..731796d --- /dev/null +++ b/docs/future/progress-monitor.md @@ -0,0 +1,63 @@ +# Future: unified live progress monitor + +Status: design only (not implemented). Precursor shipped: the `progress_all` +diagnostic (`internal/pg/queries_diag.go`, registered in `diagnostic_defs.go`) +already UNIONs every `pg_stat_progress_*` view into one table you can run today +from **Other Tools → Running operations (progress)**. + +## Motivation + +Watching a long migration (`CREATE INDEX CONCURRENTLY`), a manual `VACUUM +FULL`/`CLUSTER`, a big `COPY`, or a base backup is a common "is it nearly done?" +question. The diagnostic result is a point-in-time snapshot — you have to press +refresh. A dedicated live level would auto-refresh and draw a real progress bar +per operation, so you can leave it open and watch a migration crawl to 100%. + +## Data sources + +Reuse `sqlDiagProgressAll` as-is (it already normalizes pid, command, relation, +phase, `done_pct`, `running_for`, username across +`pg_stat_progress_{vacuum,create_index,analyze,cluster,copy,basebackup}`). For a +richer bar, extend it to also return `done`/`total` raw counters so the bar can +render blocks-done/blocks-total instead of only the percentage. + +## UX sketch + +A new `levelProgress` under `toolMaintenance`, opened with `p` from +`levelMaintenance` (mirrors the B5 cross-links). One row per running operation: + +``` +CREATE INDEX public.orders_created_idx building index: 3 of 5 [██████████░░░░] 64% 4m12s +VACUUM public.events scanning heap [███░░░░░░░░░░░] 22% 1m03s +``` + +Auto-refresh on the Activity tool's tick cadence (reuse `activityTick` / +`cycleActivityRefresh`, gate the tick on `levelProgress` like the lock tree +does). Empty state: "no operations in progress". `d` describes the target +relation (reuse `describeTarget`'s by-name path). + +## Wiring outline (per CLAUDE.md "Adding a new entity") + +- `levelProgress` enum in `app.go`; screen fields `progressRows []pg.ProgressRow`. +- `internal/pg/progress.go` + a `ProgressRow` type; a `ListProgress(ctx, db)` + method over the extended `sqlDiagProgressAll`. +- Cmd `loadProgressCmd` in `cmds_maintenance.go` via the `query()` helper; + `progressLoadedMsg` handled in `update_msgs.go`. +- Reuse `paintBar` / `barSegment` (`row.go`) for the per-row progress bar. +- `view_progress.go` renderer; dispatch in `view.go`; `p` key enabled on + `levelMaintenance` in `keys.go`. +- Extend `onActivityTick` (or add a `progressTick`) to re-fire `loadProgressCmd` + while `levelProgress` is on top. + +## Effort + +~1–2 days. All the hard SQL exists; it's a renderer + a live tick loop, both of +which have close templates (lock tree, activity). + +## Risks + +- Low. `pg_stat_progress_*` rows vanish the instant an operation finishes, so the + list will flicker empty at completion — acceptable, but worth a brief "just + finished" grace note if it feels abrupt. +- `basebackup` has no `relid`; the relation column is blank for it (already + handled in the SQL). diff --git a/docs/future/wait-event-profiler.md b/docs/future/wait-event-profiler.md new file mode 100644 index 0000000..4537c50 --- /dev/null +++ b/docs/future/wait-event-profiler.md @@ -0,0 +1,68 @@ +# Future: wait-event sampling profiler ("poor man's ASH") + +Status: design only (not implemented). + +## Motivation + +`pg_stat_activity.wait_event` tells you what each backend is blocked on *right +now*, but a single glance misses the pattern. Oracle's ASH and pg_wait_sampling +answer "where did time actually go?" by sampling wait events over a window. pgdu +already polls `pg_stat_activity` on a timer for the Activity tool — piggybacking +a wait-event histogram on that loop turns those samples into a cheap, no-extension +time profile: "62% LWLock:WALWrite, 20% IO:DataFileRead, 12% running, 6% Lock". + +## Data sources + +No new query in the simple form: reuse each Activity refresh's +`pg_stat_activity` rows. On every tick, bucket each non-idle backend by +`wait_event_type:wait_event` (or "CPU/active" when running with no wait) into a +ring buffer of counts. The histogram is `Σ samples per class ÷ total samples` +over the retained window. + +For higher fidelity later, an optional dedicated query +`SELECT wait_event_type, wait_event, count(*) FROM pg_stat_activity WHERE state +!= 'idle' GROUP BY 1,2` decouples sampling cadence from the table refresh. + +## UX sketch + +`W` on `levelActivity` opens `levelWaitProfile`: a horizontal stacked bar (the +window's wait-class mix) over a ranked list, plus a sparkline-per-class of the +last N buckets so you can see a spike arrive. + +``` +window: last 5m · 300 samples · 1s cadence +[████████████ WALWrite ██████ DataFileRead ███ CPU ██ Lock ░ other] + +LWLock:WALWrite 61% ▁▂▃▅▇▇▅▃ WAL flush contention +IO:DataFileRead 19% ▁▁▂▂▃▂▁▁ heap/index reads from disk +(running, no wait) 12% ▃▃▂▃▄▃▂▃ +Lock:transactionid 6% ▁▁▂▁▁▁▁▁ row-lock waits +``` + +## Wiring outline + +- Ring buffer on the `Model` (not `screen`, since it accumulates across the tool's + lifetime): `waitSamples []waitBucket` with a fixed capacity and a head index. +- Hook the accumulation into `onActivityLoaded` (each tick already delivers the + rows) — no new command needed for the simple form. +- `levelWaitProfile` enum + `view_waitprofile.go` (reuse `paintBar` for the + stacked class bar; a tiny sparkline helper for the per-class trend). +- `W` key enabled on `levelActivity`; the profile keeps updating on the same + Activity tick while open. + +## Effort + +~2–3 days for the piggybacked version; +1 for the dedicated-query / sparkline +polish. + +## Risks + +- **Fidelity is bounded by cadence.** At 2s ticks you sample coarsely; short + spikes between ticks are invisible. Label the window honestly ("300 samples @ + 1s") so nobody reads it as continuous ASH. Encourage a faster cadence (500ms, + already supported) while profiling. +- **Buffer sizing.** A 5-minute window at 500ms is 600 buckets × a handful of + classes — trivial memory, but make the retention explicit and bounded so it + can't grow unbounded on a long-lived session. +- Sampling bias: a backend that waits *between* ticks never appears. Acceptable + for a "poor man's" profiler; note it in the `?` overlay. diff --git a/internal/pg/activity.go b/internal/pg/activity.go index 76ed5e1..f9a9841 100644 --- a/internal/pg/activity.go +++ b/internal/pg/activity.go @@ -33,7 +33,7 @@ func (c *Client) ListActivity(ctx context.Context, db string, mode ActivityFilte &r.BackendType, &r.State, &r.WaitEventType, &r.WaitEvent, &r.BackendXid, &r.BackendXmin, &r.QueryAgeMs, &r.XactAgeMs, &r.StateAgeMs, - &r.QueryID, &r.Query, + &r.QueryID, &r.BlockedBy, &r.Query, ) return r, err }) diff --git a/internal/pg/describe.go b/internal/pg/describe.go index 8e0e0fc..4a0c1a2 100644 --- a/internal/pg/describe.go +++ b/internal/pg/describe.go @@ -32,6 +32,25 @@ func (c *Client) ResolveTable(ctx context.Context, db, name string) (Table, erro return t, nil } +// ResolveIndex resolves an index name (optionally schema-qualified) to its OID +// and qualified display name, so `d` on rows that only carry an index name +// (diagnostic results) can reach DescribeIndex. +func (c *Client) ResolveIndex(ctx context.Context, db, name string) (oid uint32, qualified string, err error) { + pool, err := c.PoolFor(ctx, db) + if err != nil { + return 0, "", err + } + var schema, rel string + err = pool.QueryRow(ctx, sqlResolveIndex, name).Scan(&oid, &schema, &rel) + if errors.Is(err, pgx.ErrNoRows) { + return 0, "", &MissingRelationError{Name: name} + } + if err != nil { + return 0, "", fmt.Errorf("resolve index %q in %q: %w", name, db, err) + } + return oid, schema + "." + rel, nil +} + // MissingRelationError reports that a name couldn't be resolved to a describable // relation (e.g. it's a CTE alias, a view, or simply doesn't exist). type MissingRelationError struct{ Name string } diff --git a/internal/pg/diagnostic_defs.go b/internal/pg/diagnostic_defs.go index 2df2a0a..a088a76 100644 --- a/internal/pg/diagnostic_defs.go +++ b/internal/pg/diagnostic_defs.go @@ -13,6 +13,12 @@ type Diagnostic struct { Bar string // headline column name rendered as a bar, or "" Sort string // default sort column name (descending); "" falls back to Bar, then column 0 ascending PerDB bool // true = query reads only the connected database; the TUI prompts for which database to run against (or all) + + // Kinds overrides the name-heuristic column kind (colKindFromName) per + // column, so a diagnostic can opt into graded rendering the suffix rules + // can't infer — e.g. hit ratios as DiagPercentGraded (higher is better) or + // dead-tuple % as DiagPercentBad (higher is worse). Keys are column names. + Kinds map[string]DiagColumnKind } // Diagnostics is the ordered registry of all built-in diagnostic queries. @@ -29,6 +35,15 @@ var Diagnostics = []Diagnostic{ SQL: sqlDiagBloatIndex, Bar: "bloat_pct", }, + { + Key: "fk_missing_index", + PerDB: true, + Title: "FKs without index", + Category: "index", + Description: "foreign keys whose referencing columns have no supporting index — parent deletes/updates seq-scan the child table", + SQL: sqlDiagFKMissingIndex, + Bar: "table_size_bytes", + }, { Key: "index_brin_candidates", PerDB: true, @@ -49,6 +64,25 @@ var Diagnostics = []Diagnostic{ // SQL: sqlDiagIndexShowAll, // Bar: "number_of_scans", // }, + { + Key: "index_io", + PerDB: true, + Title: "Index I/O", + Category: "index", + Description: "per-index buffer cache hits vs disk reads — hot indexes with poor hit ratios are shared_buffers pressure", + SQL: sqlDiagIndexIO, + Bar: "blks_read", + Kinds: map[string]DiagColumnKind{"hit_pct": DiagPercentGraded}, + }, + { + Key: "index_redundant_prefix", + PerDB: true, + Title: "Redundant indexes (prefix)", + Category: "index", + Description: "btree indexes whose key columns are a leading prefix of a wider index — usually droppable write amplification", + SQL: sqlDiagIndexRedundantPrefix, + Bar: "redundant_size_bytes", + }, { Key: "index_show_definitions", PerDB: true, @@ -107,6 +141,16 @@ var Diagnostics = []Diagnostic{ SQL: sqlDiagBloatTable, Bar: "pct_bloat", }, + { + Key: "stale_statistics", + PerDB: true, + Title: "Stale planner statistics", + Category: "table", + Description: "tables whose row modifications since the last ANALYZE outgrow their live rows — bad-plan risk", + SQL: sqlDiagStaleStatistics, + Bar: "stale_pct", + Kinds: map[string]DiagColumnKind{"stale_pct": DiagPercentBad}, + }, { Key: "table_scan_types", PerDB: true, @@ -115,6 +159,7 @@ var Diagnostics = []Diagnostic{ Description: "tables with >20% sequential reads and >800 kB — potential missing-index candidates", SQL: sqlDiagTableScanTypes, Bar: "index_read_pct", + Kinds: map[string]DiagColumnKind{"index_read_pct": DiagPercentGraded}, }, { Key: "table_show_hitratio", @@ -124,6 +169,7 @@ var Diagnostics = []Diagnostic{ Description: "tables with heap cache hit ratio below 80%, ordered by blocks read from disk", SQL: sqlDiagTableShowHitratio, Bar: "hit_pct", + Kinds: map[string]DiagColumnKind{"hit_pct": DiagPercentGraded}, }, { Key: "table_show_hot_ratio", @@ -134,6 +180,7 @@ var Diagnostics = []Diagnostic{ SQL: sqlDiagTableShowHotRatio, Bar: "hot_pct", Sort: "non_hot_updates", + Kinds: map[string]DiagColumnKind{"hot_pct": DiagPercentGraded}, }, { Key: "table_show_modify_ratio", @@ -170,6 +217,15 @@ var Diagnostics = []Diagnostic{ Description: "currently running autovacuum workers with scan and vacuum progress", SQL: sqlDiagAutovacuumProgress, Bar: "scanned_pct", + Kinds: map[string]DiagColumnKind{"dead_pct": DiagPercentBad}, + }, + { + Key: "progress_all", + Title: "Running operations (progress)", + Category: "vacuum", + Description: "everything with a pg_stat_progress_* view — VACUUM, CREATE INDEX, ANALYZE, CLUSTER, COPY, base backups — with % done", + SQL: sqlDiagProgressAll, + Bar: "done_pct", }, { Key: "vacuum_running", @@ -199,6 +255,27 @@ var Diagnostics = []Diagnostic{ SQL: sqlDiagConnections, Bar: "connections", }, + { + Key: "idle_in_xact_holders", + Title: "Idle-in-transaction lock holders", + Category: "activity", + Description: "open transactions sitting idle, with the locks they still hold — the usual 'why is this stuck / why is bloat growing' answer", + SQL: sqlDiagIdleInXactHolders, + Bar: "xact_age_secs", + Kinds: map[string]DiagColumnKind{ + "xact_age_secs": DiagCostGraded, + "state": DiagBackendState, + }, + }, + { + Key: "lock_summary", + Title: "Lock summary", + Category: "activity", + Description: "pg_locks grouped by lock type and mode with waiter counts — the one-glance contention read", + SQL: sqlDiagLockSummary, + Bar: "locks", + Kinds: map[string]DiagColumnKind{"waiting": DiagCostGraded}, + }, // ── wal ─────────────────────────────────────────────────────────────── { Key: "wal_files", @@ -232,6 +309,7 @@ var Diagnostics = []Diagnostic{ Description: "per-database commits, rollbacks, cache hit ratio, deadlocks and temp-file usage", SQL: sqlDiagDatabaseStats, Bar: "hit_pct", + Kinds: map[string]DiagColumnKind{"hit_pct": DiagPercentGraded}, }, { Key: "foreignkeys_show_all", @@ -267,6 +345,7 @@ var Diagnostics = []Diagnostic{ Description: "how much of each sequence's range is consumed (last_value needs SELECT/USAGE)", SQL: sqlDiagSequences, Bar: "consumed_pct", + Kinds: map[string]DiagColumnKind{"consumed_pct": DiagPercentBad}, }, { Key: "settings_show_pending", @@ -276,4 +355,26 @@ var Diagnostics = []Diagnostic{ SQL: sqlDiagSettingsShowPending, Bar: "", }, + { + Key: "slru_stats", + Title: "SLRU caches", + Category: "server", + Description: "transaction-status / multixact / subtransaction cache traffic — invisible pressure from long transactions and savepoints", + SQL: sqlDiagSLRU, + Bar: "blks_read", + Kinds: map[string]DiagColumnKind{"hit_pct": DiagPercentGraded}, + }, + { + Key: "subscription_stats", + PerDB: true, + Title: "Logical subscriptions", + Category: "server", + Description: "logical-replication subscriptions with worker state, message staleness and apply/sync error counts", + SQL: sqlDiagSubscriptionStats, + Bar: "", + Kinds: map[string]DiagColumnKind{ + "apply_errors": DiagCostGraded, + "sync_errors": DiagCostGraded, + }, + }, } diff --git a/internal/pg/diagnostics.go b/internal/pg/diagnostics.go index 68a210d..1378564 100644 --- a/internal/pg/diagnostics.go +++ b/internal/pg/diagnostics.go @@ -71,6 +71,7 @@ func (c *Client) RunDiagnostic(ctx context.Context, db string, d Diagnostic) (*D return nil, fmt.Errorf("run diagnostic %q: %w", d.Key, err) } + applyKindOverrides(cols, d.Kinds) barCol, sortCol := resolveBarSort(cols, d) return &DiagResult{Columns: cols, Rows: resultRows, BarCol: barCol, SortCol: sortCol}, nil } @@ -183,10 +184,26 @@ func (c *Client) RunDiagnosticAllDBs(ctx context.Context, d Diagnostic) (*DiagRe return &DiagResult{Columns: []DiagColumn{{Name: dbColName, Kind: DiagText}}, BarCol: -1, SortCol: -1}, nil } + applyKindOverrides(cols, d.Kinds) barCol, sortCol := resolveBarSort(cols, d) return &DiagResult{Columns: cols, Rows: rows, BarCol: barCol, SortCol: sortCol}, nil } +// applyKindOverrides replaces inferred column kinds with the diagnostic's +// declared ones (Diagnostic.Kinds). Applied after scanning: Kind only drives +// rendering, so a post-scan overwrite is safe and also wins over the +// text→numeric promotion done while scanning. +func applyKindOverrides(cols []DiagColumn, kinds map[string]DiagColumnKind) { + if len(kinds) == 0 { + return + } + for i, c := range cols { + if k, ok := kinds[c.Name]; ok { + cols[i].Kind = k + } + } +} + // scanDiagRows drains rows into generic column/cell form: column metadata comes // from the server's field descriptions (kind inferred by name, promoted to // numeric on the first numeric value seen), each value goes through diff --git a/internal/pg/diagnostics_integration_test.go b/internal/pg/diagnostics_integration_test.go new file mode 100644 index 0000000..5caa4ee --- /dev/null +++ b/internal/pg/diagnostics_integration_test.go @@ -0,0 +1,222 @@ +package pg + +import ( + "context" + "net/url" + "os" + "strconv" + "testing" + + "pgdu/internal/cli" +) + +// diagTestClient builds a Client from PGDU_TEST_DSN (skipping the test when +// unset) and returns it with the DSN's database name. Mirrors the parsing in +// TestIntegration_FullChain; an empty host falls back to the Unix socket. +func diagTestClient(t *testing.T) (*Client, string) { + t.Helper() + dsn := os.Getenv("PGDU_TEST_DSN") + if dsn == "" { + t.Skip("PGDU_TEST_DSN not set") + } + u, err := url.Parse(dsn) + if err != nil { + t.Fatalf("parse dsn: %v", err) + } + port, _ := strconv.Atoi(u.Port()) + if port == 0 { + port = 5432 + } + user := "" + pw := "" + if u.User != nil { + user = u.User.Username() + pw, _ = u.User.Password() + } + db := u.Path + if len(db) > 0 && db[0] == '/' { + db = db[1:] + } + cfg := cli.Config{Host: u.Hostname(), Port: port, User: user, Password: pw, Database: db, SSLMode: u.Query().Get("sslmode")} + if cfg.SSLMode == "" { + cfg.SSLMode = "disable" + } + c := New(cfg) + t.Cleanup(c.Close) + if err := c.Ping(context.Background()); err != nil { + t.Fatalf("ping: %v", err) + } + return c, db +} + +// Every registered diagnostic must execute cleanly and resolve its declared +// Bar/Sort/Kinds column names against the actual result columns — this is the +// SQL smoke test that keeps registry entries from bit-rotting. +func TestIntegration_AllDiagnostics(t *testing.T) { + c, db := diagTestClient(t) + ctx := context.Background() + + for _, d := range Diagnostics { + t.Run(d.Key, func(t *testing.T) { + res, err := c.RunDiagnostic(ctx, db, d) + if err != nil { + t.Fatalf("RunDiagnostic(%s): %v", d.Key, err) + } + if d.Bar != "" && res.BarCol < 0 { + t.Errorf("bar column %q not in result columns %v", d.Bar, colNames(res.Columns)) + } + if d.Sort != "" && res.SortCol < 0 { + t.Errorf("sort column %q not in result columns %v", d.Sort, colNames(res.Columns)) + } + for name, kind := range d.Kinds { + found := false + for _, col := range res.Columns { + if col.Name == name { + found = true + if col.Kind != kind { + t.Errorf("kind override for %q not applied: got %v want %v", name, col.Kind, kind) + } + } + } + if !found { + t.Errorf("Kinds references column %q not in result columns %v", name, colNames(res.Columns)) + } + } + }) + } +} + +func colNames(cols []DiagColumn) []string { + out := make([]string, len(cols)) + for i, c := range cols { + out[i] = c.Name + } + return out +} + +// diagRowsMatching counts result rows whose cell in column colName equals want. +func diagRowsMatching(res *DiagResult, colName, want string) int { + idx := -1 + for i, c := range res.Columns { + if c.Name == colName { + idx = i + } + } + if idx < 0 { + return 0 + } + n := 0 + for _, row := range res.Rows { + if idx < len(row) && row[idx].Display == want { + n++ + } + } + return n +} + +// The FK-without-index detector must flag an unindexed FK column and stop +// flagging it once a supporting index exists — including one whose leading +// columns are a permutation of a multi-column FK. +func TestIntegration_FKMissingIndex(t *testing.T) { + c, db := diagTestClient(t) + ctx := context.Background() + pool, err := c.PoolFor(ctx, db) + if err != nil { + t.Fatalf("pool: %v", err) + } + + setup := []string{ + `DROP SCHEMA IF EXISTS pgdu_diag_test CASCADE`, + `CREATE SCHEMA pgdu_diag_test`, + `CREATE TABLE pgdu_diag_test.parent (id int PRIMARY KEY)`, + `CREATE TABLE pgdu_diag_test.child ( + id int PRIMARY KEY, + parent_id int REFERENCES pgdu_diag_test.parent(id) + )`, + } + for _, q := range setup { + if _, err := pool.Exec(ctx, q); err != nil { + t.Fatalf("setup %q: %v", q, err) + } + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DROP SCHEMA IF EXISTS pgdu_diag_test CASCADE`) + }) + + diag := diagByKey(t, "fk_missing_index") + res, err := c.RunDiagnostic(ctx, db, diag) + if err != nil { + t.Fatalf("RunDiagnostic: %v", err) + } + if got := diagRowsMatching(res, "table_name", "child"); got != 1 { + t.Fatalf("expected exactly 1 finding for unindexed child FK, got %d (rows: %d)", got, len(res.Rows)) + } + + // A covering index (FK column as the leading key) must clear the finding. + if _, err := pool.Exec(ctx, `CREATE INDEX ON pgdu_diag_test.child (parent_id, id)`); err != nil { + t.Fatalf("create index: %v", err) + } + res, err = c.RunDiagnostic(ctx, db, diag) + if err != nil { + t.Fatalf("RunDiagnostic after index: %v", err) + } + if got := diagRowsMatching(res, "table_name", "child"); got != 0 { + t.Fatalf("indexed FK still flagged (%d rows)", got) + } +} + +// The redundant-prefix detector must flag a single-column index shadowed by a +// wider one with the same leading column, and leave unique / differently-rooted +// indexes alone. +func TestIntegration_IndexRedundantPrefix(t *testing.T) { + c, db := diagTestClient(t) + ctx := context.Background() + pool, err := c.PoolFor(ctx, db) + if err != nil { + t.Fatalf("pool: %v", err) + } + + setup := []string{ + `DROP SCHEMA IF EXISTS pgdu_diag_test2 CASCADE`, + `CREATE SCHEMA pgdu_diag_test2`, + `CREATE TABLE pgdu_diag_test2.t (a int, b int, c int)`, + `CREATE INDEX redundant_a ON pgdu_diag_test2.t (a)`, + `CREATE INDEX wide_ab ON pgdu_diag_test2.t (a, b)`, + // Not redundant: different leading column, and a unique index. + `CREATE INDEX lead_b ON pgdu_diag_test2.t (b)`, + `CREATE UNIQUE INDEX uniq_c ON pgdu_diag_test2.t (c)`, + `CREATE INDEX wide_cb ON pgdu_diag_test2.t (c, b)`, + } + for _, q := range setup { + if _, err := pool.Exec(ctx, q); err != nil { + t.Fatalf("setup %q: %v", q, err) + } + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DROP SCHEMA IF EXISTS pgdu_diag_test2 CASCADE`) + }) + + res, err := c.RunDiagnostic(ctx, db, diagByKey(t, "index_redundant_prefix")) + if err != nil { + t.Fatalf("RunDiagnostic: %v", err) + } + if got := diagRowsMatching(res, "redundant_index", "redundant_a"); got != 1 { + t.Fatalf("expected redundant_a flagged once, got %d", got) + } + for _, name := range []string{"lead_b", "uniq_c", "wide_ab", "wide_cb"} { + if got := diagRowsMatching(res, "redundant_index", name); got != 0 { + t.Fatalf("%s wrongly flagged as redundant (%d rows)", name, got) + } + } +} + +func diagByKey(t *testing.T, key string) Diagnostic { + t.Helper() + for _, d := range Diagnostics { + if d.Key == key { + return d + } + } + t.Fatalf("diagnostic %q not registered", key) + panic("unreachable") +} diff --git a/internal/pg/locks.go b/internal/pg/locks.go new file mode 100644 index 0000000..a4e846f --- /dev/null +++ b/internal/pg/locks.go @@ -0,0 +1,34 @@ +package pg + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" +) + +// ListLockWaiters returns every backend involved in a lock-wait relationship — +// blocked, blocking, or both — with its blockers and the lock it is waiting on. +// The caller assembles the blocking forest from the pid→blockers edges. An +// empty result means no contention. pg_blocking_pids and pg_locks are readable +// by any role, so this needs no special privilege (relation names it can't +// resolve simply come back empty). +func (c *Client) ListLockWaiters(ctx context.Context, db string) ([]LockNode, error) { + pool, err := c.PoolFor(ctx, db) + if err != nil { + return nil, fmt.Errorf("list lock waiters in %q: %w", db, err) + } + return collect(ctx, pool, fmt.Sprintf("list lock waiters in %q", db), sqlLockWaiters, nil, + func(row pgx.CollectableRow) (LockNode, error) { + var n LockNode + err := row.Scan( + &n.PID, &n.Blockers, + &n.Database, &n.Username, &n.AppName, &n.State, + &n.WaitEventType, &n.WaitEvent, + &n.XactAgeMs, + &n.WaitLockType, &n.WaitMode, &n.WaitRelation, + &n.Query, + ) + return n, err + }) +} diff --git a/internal/pg/locks_scenario_test.go b/internal/pg/locks_scenario_test.go new file mode 100644 index 0000000..ec2e417 --- /dev/null +++ b/internal/pg/locks_scenario_test.go @@ -0,0 +1,90 @@ +package pg + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5" +) + +// A real blocking chain must surface as two nodes: the blocker (root, no +// blockers) and the waiter (blocked, with the blocker's PID and the relation). +func TestIntegration_LockWaiters(t *testing.T) { + c, db := diagTestClient(t) + ctx := context.Background() + pool, err := c.PoolFor(ctx, db) + if err != nil { + t.Fatalf("pool: %v", err) + } + _, _ = pool.Exec(ctx, `DROP TABLE IF EXISTS pgdu_locktest`) + if _, err := pool.Exec(ctx, `CREATE TABLE pgdu_locktest (id int PRIMARY KEY, v int)`); err != nil { + t.Fatalf("create: %v", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO pgdu_locktest VALUES (1, 1)`); err != nil { + t.Fatalf("insert: %v", err) + } + t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DROP TABLE IF EXISTS pgdu_locktest`) }) + + dsn := os.Getenv("PGDU_TEST_DSN") + blk, err := pgx.Connect(ctx, dsn) + if err != nil { + t.Fatalf("blocker connect: %v", err) + } + defer func() { _ = blk.Close(ctx) }() + if _, err := blk.Exec(ctx, `BEGIN`); err != nil { + t.Fatalf("begin: %v", err) + } + // A table-level ACCESS EXCLUSIVE lock so the waiter blocks on a *relation* + // lock — that exercises the regclass name resolution in the query. + if _, err := blk.Exec(ctx, `LOCK TABLE pgdu_locktest IN ACCESS EXCLUSIVE MODE`); err != nil { + t.Fatalf("blocker lock: %v", err) + } + + wait, err := pgx.Connect(ctx, dsn) + if err != nil { + t.Fatalf("waiter connect: %v", err) + } + defer func() { _ = wait.Close(ctx) }() + go func() { _, _ = wait.Query(context.Background(), `SELECT * FROM pgdu_locktest`) }() + + // Poll for the wait relationship to establish. + var nodes []LockNode + for range 40 { + time.Sleep(100 * time.Millisecond) + nodes, err = c.ListLockWaiters(ctx, db) + if err != nil { + t.Fatalf("ListLockWaiters: %v", err) + } + waiters := 0 + for _, n := range nodes { + if n.Waiting() { + waiters++ + } + } + if waiters >= 1 && len(nodes) >= 2 { + break + } + } + + if len(nodes) < 2 { + t.Fatalf("expected ≥2 nodes in the chain, got %d: %+v", len(nodes), nodes) + } + var haveRoot, haveWaiter bool + for _, n := range nodes { + if !n.Waiting() && n.PID != 0 { + haveRoot = true + } + if n.Waiting() { + haveWaiter = true + if n.WaitRelation == "" { + t.Errorf("waiter %d missing wait relation: %+v", n.PID, n) + } + } + } + if !haveRoot || !haveWaiter { + t.Fatalf("expected a root and a waiter; haveRoot=%v haveWaiter=%v nodes=%+v", haveRoot, haveWaiter, nodes) + } + _, _ = blk.Exec(ctx, `ROLLBACK`) +} diff --git a/internal/pg/queries_activity.go b/internal/pg/queries_activity.go index caecae5..484b26f 100644 --- a/internal/pg/queries_activity.go +++ b/internal/pg/queries_activity.go @@ -27,6 +27,9 @@ SELECT EXTRACT(epoch FROM now() - a.state_change) * 1000, 0 )::float8 AS state_age_ms, coalesce(a.query_id, 0) AS query_id, + -- PIDs blocking this backend (pg_blocking_pids); empty when nobody blocks it. + -- Rendered as an opt-in column so the lock-wait relationship reads inline. + coalesce(array_to_string(pg_blocking_pids(a.pid), ' '), '') AS blocked_by, coalesce(left(regexp_replace(a.query, '\s+', ' ', 'g'), 300), '') AS query FROM pg_stat_activity a WHERE a.pid <> pg_backend_pid() diff --git a/internal/pg/queries_describe.go b/internal/pg/queries_describe.go index ef1787e..501c495 100644 --- a/internal/pg/queries_describe.go +++ b/internal/pg/queries_describe.go @@ -18,6 +18,19 @@ WHERE c.oid = to_regclass($1) AND c.relkind IN ('r', 'p', 'm', 'f') ` +// sqlResolveIndex resolves an (optionally schema-qualified) index name to its +// OID and qualified name — sqlResolveTable's sibling for relkind 'i'/'I'. +// $1 = name. +const sqlResolveIndex = ` +SELECT c.oid, + n.nspname, + c.relname +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE c.oid = to_regclass($1) + AND c.relkind IN ('i', 'I') +` + // sqlDescribeColumns lists a table's live columns in declaration order with // NOT NULL and the column default expression. $1 = table oid. PG 12+. const sqlDescribeColumns = ` diff --git a/internal/pg/queries_diag.go b/internal/pg/queries_diag.go index 23b0b5c..3c9c2db 100644 --- a/internal/pg/queries_diag.go +++ b/internal/pg/queries_diag.go @@ -766,3 +766,233 @@ SELECT FROM pg_sequences ORDER BY consumed_pct DESC NULLS LAST ` + +// sqlDiagFKMissingIndex finds foreign keys on the referencing side that have no +// supporting index: no valid index whose leading columns contain the FK columns +// (any order — a btree lookup works for any permutation, hence the two-way +// prefix containment via @> on the 0-based smallint[] slice of indkey). Without +// one, every DELETE/UPDATE on the referenced table sequentially scans the +// referencing table per row. +const sqlDiagFKMissingIndex = ` +SELECT + n.nspname AS schema, + t.relname AS table_name, + c.conname AS fk_name, + string_agg(a.attname, ', ' ORDER BY x.n) AS fk_columns, + c.confrelid::regclass::text AS referenced_table, + ps.n_tup_upd + ps.n_tup_del AS referenced_writes, + pg_relation_size(c.conrelid) AS table_size_bytes +FROM pg_constraint c +CROSS JOIN LATERAL unnest(c.conkey) WITH ORDINALITY AS x(attnum, n) +JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = x.attnum +JOIN pg_class t ON t.oid = c.conrelid +JOIN pg_namespace n ON n.oid = t.relnamespace +LEFT JOIN pg_stat_all_tables ps ON ps.relid = c.confrelid +WHERE c.contype = 'f' + AND n.nspname !~ '^pg_' AND n.nspname <> 'information_schema' + AND NOT EXISTS ( + SELECT 1 + FROM pg_index i + WHERE i.indrelid = c.conrelid + AND i.indisvalid + AND (i.indkey::smallint[])[0:cardinality(c.conkey)-1] OPERATOR(pg_catalog.@>) c.conkey + ) +GROUP BY n.nspname, t.relname, c.oid, c.conname, c.confrelid, c.conrelid, ps.n_tup_upd, ps.n_tup_del +ORDER BY pg_relation_size(c.conrelid) DESC +` + +// sqlDiagIndexRedundantPrefix finds btree indexes whose key columns are a strict +// leading prefix of another valid btree index on the same table (same column +// order, opclasses, sort options and partial predicate) — the wider index can +// serve every query the narrower one can, so the narrower one usually just costs +// write amplification and disk. Unique / constraint-backed indexes are excluded +// (they enforce something the wider index doesn't); exact duplicates are covered +// by the separate duplicate-indexes diagnostic. Expression indexes are skipped +// (their indkey entries are 0 and would compare equal across different +// expressions). +const sqlDiagIndexRedundantPrefix = ` +SELECT + n.nspname AS schema, + t.relname AS table_name, + ri.relname AS redundant_index, + ci.relname AS covered_by, + s.idx_scan AS redundant_scans, + pg_relation_size(a.indexrelid) AS redundant_size_bytes +FROM pg_index a +JOIN pg_index b + ON a.indrelid = b.indrelid + AND a.indexrelid <> b.indexrelid + AND b.indisvalid + AND b.indnkeyatts > a.indnkeyatts + AND (b.indkey::smallint[])[0:a.indnkeyatts-1] = (a.indkey::smallint[])[0:a.indnkeyatts-1] + AND (b.indclass::oid[])[0:a.indnkeyatts-1] = (a.indclass::oid[])[0:a.indnkeyatts-1] + AND (b.indoption::smallint[])[0:a.indnkeyatts-1] = (a.indoption::smallint[])[0:a.indnkeyatts-1] +JOIN pg_class ri ON ri.oid = a.indexrelid +JOIN pg_class ci ON ci.oid = b.indexrelid +JOIN pg_class t ON t.oid = a.indrelid +JOIN pg_namespace n ON n.oid = t.relnamespace +JOIN pg_am ra ON ra.oid = ri.relam AND ra.amname = 'btree' +JOIN pg_am ca ON ca.oid = ci.relam AND ca.amname = 'btree' +LEFT JOIN pg_stat_user_indexes s ON s.indexrelid = a.indexrelid +WHERE a.indisvalid + AND NOT a.indisunique + AND a.indexprs IS NULL AND b.indexprs IS NULL + AND coalesce(pg_get_expr(a.indpred, a.indrelid), '') = coalesce(pg_get_expr(b.indpred, b.indrelid), '') + AND NOT EXISTS (SELECT 1 FROM pg_constraint cc WHERE cc.conindid = a.indexrelid) + AND n.nspname !~ '^pg_' AND n.nspname <> 'information_schema' +ORDER BY pg_relation_size(a.indexrelid) DESC +` + +// sqlDiagIndexIO reports per-index buffer I/O from pg_statio_user_indexes: how +// often index blocks came from cache vs disk, next to the scan count and size — +// a hot index with a poor hit ratio is a shared_buffers sizing signal. +const sqlDiagIndexIO = ` +SELECT + io.schemaname AS schema, + io.relname AS table_name, + io.indexrelname AS index_name, + io.idx_blks_read AS blks_read, + io.idx_blks_hit AS blks_hit, + round(100.0 * io.idx_blks_hit / NULLIF(io.idx_blks_hit + io.idx_blks_read, 0), 2) AS hit_pct, + st.idx_scan AS scans, + pg_relation_size(io.indexrelid) AS index_size_bytes +FROM pg_statio_user_indexes io +JOIN pg_stat_user_indexes st USING (indexrelid) +ORDER BY io.idx_blks_read DESC +` + +// sqlDiagStaleStatistics ranks tables by how stale their planner statistics +// are: rows modified since the last ANALYZE relative to the live row count. +// Tables past autovacuum_analyze_scale_factor (10% by default) risk bad plans. +const sqlDiagStaleStatistics = ` +SELECT + schemaname AS schema, + relname AS table_name, + n_live_tup AS live_rows, + n_mod_since_analyze AS modified_rows, + round(100.0 * n_mod_since_analyze / GREATEST(n_live_tup, 1), 1) AS stale_pct, + date_trunc('second', now() - GREATEST(last_analyze, last_autoanalyze)) AS analyzed_ago +FROM pg_stat_user_tables +WHERE n_mod_since_analyze > 0 + OR (last_analyze IS NULL AND last_autoanalyze IS NULL AND n_live_tup > 0) +ORDER BY stale_pct DESC NULLS LAST +` + +// sqlDiagProgressAll unifies every pg_stat_progress_* view into one normalized +// live-operations table: what long-running maintenance/DDL is in flight and how +// far along it is. done/total pick each command's most representative counter +// (blocks for vacuum/index/analyze/cluster, bytes for COPY and base backups). +const sqlDiagProgressAll = ` +WITH prog AS ( + SELECT pid, 'VACUUM' AS command, relid, phase, + heap_blks_scanned::numeric AS done, heap_blks_total::numeric AS total + FROM pg_stat_progress_vacuum + UNION ALL + SELECT pid, 'CREATE INDEX', relid, phase, blocks_done::numeric, blocks_total::numeric + FROM pg_stat_progress_create_index + UNION ALL + SELECT pid, 'ANALYZE', relid, phase, sample_blks_scanned::numeric, sample_blks_total::numeric + FROM pg_stat_progress_analyze + UNION ALL + SELECT pid, 'CLUSTER', relid, phase, heap_blks_scanned::numeric, heap_blks_total::numeric + FROM pg_stat_progress_cluster + UNION ALL + SELECT pid, 'COPY', relid, ''::text, bytes_processed::numeric, bytes_total::numeric + FROM pg_stat_progress_copy + UNION ALL + SELECT pid, 'BASE BACKUP', NULL::oid, phase, backup_streamed::numeric, backup_total::numeric + FROM pg_stat_progress_basebackup +) +SELECT + p.pid, + p.command, + CASE WHEN p.relid IS NOT NULL AND p.relid <> 0 THEN p.relid::regclass::text ELSE '' END AS relation, + p.phase, + round(100.0 * p.done / NULLIF(p.total, 0), 1) AS done_pct, + date_trunc('second', now() - a.xact_start) AS running_for, + a.usename AS username +FROM prog p +LEFT JOIN pg_stat_activity a USING (pid) +ORDER BY done_pct DESC NULLS LAST +` + +// sqlDiagLockSummary aggregates pg_locks by lock type and mode: how many locks +// are out, how many backends hold them, and whether anyone is waiting — the +// one-glance contention read before drilling into per-backend detail. +const sqlDiagLockSummary = ` +SELECT + l.locktype, + l.mode, + count(*) AS locks, + count(*) FILTER (WHERE NOT l.granted) AS waiting, + count(DISTINCT l.pid) AS backends, + min(l.relation::regclass::text) FILTER (WHERE l.locktype = 'relation') AS sample_relation +FROM pg_locks l +GROUP BY l.locktype, l.mode +ORDER BY count(*) DESC +` + +// sqlDiagIdleInXactHolders lists idle-in-transaction backends together with the +// locks their open transaction is still holding — the usual answer to "why is +// this DDL/autovacuum stuck" and "why is bloat growing". xact_age_secs carries +// the numeric sort/bar; locked_relations resolves names only for the current +// database (other databases' relations show as bare OIDs). +const sqlDiagIdleInXactHolders = ` +SELECT + a.pid, + a.usename AS username, + a.datname AS database, + a.state, + round(EXTRACT(epoch FROM now() - a.xact_start))::bigint AS xact_age_secs, + date_trunc('second', now() - a.state_change) AS idle_for, + count(*) FILTER (WHERE l.granted) AS locks_held, + string_agg(DISTINCT l.relation::regclass::text, ', ') + FILTER (WHERE l.granted AND l.locktype = 'relation') AS locked_relations, + a.query AS last_query +FROM pg_stat_activity a +LEFT JOIN pg_locks l ON l.pid = a.pid +WHERE a.state IN ('idle in transaction', 'idle in transaction (aborted)') +GROUP BY a.pid, a.usename, a.datname, a.state, a.xact_start, a.state_change, a.query +ORDER BY a.xact_start +` + +// sqlDiagSLRU reports the SLRU (simple LRU) cache counters — transaction status +// (Xact), multixacts, subtransactions, notify, etc. A poor hit ratio or heavy +// blks_read on MultiXact/Subtrans is otherwise-invisible pressure from long +// transactions, SELECT FOR SHARE, or deep savepoint nesting. +const sqlDiagSLRU = ` +SELECT + name, + blks_hit, + blks_read, + round(100.0 * blks_hit / NULLIF(blks_hit + blks_read, 0), 2) AS hit_pct, + blks_written, + blks_exists, + flushes, + truncates, + stats_reset +FROM pg_stat_slru +ORDER BY blks_read DESC +` + +// sqlDiagSubscriptionStats shows logical-replication subscriptions in the +// current database with their worker state and error counters. Lag toward the +// publisher can't be computed on the subscriber; the message/report ages are the +// staleness signal instead. +const sqlDiagSubscriptionStats = ` +SELECT + su.subname AS subscription, + su.subenabled AS enabled, + st.pid AS worker_pid, + CASE WHEN st.relid IS NOT NULL THEN st.relid::regclass::text ELSE '' END AS syncing_table, + st.received_lsn::text AS received_lsn, + date_trunc('second', now() - st.last_msg_receipt_time) AS last_msg_age, + date_trunc('second', now() - st.latest_end_time) AS report_age, + ss.apply_error_count AS apply_errors, + ss.sync_error_count AS sync_errors +FROM pg_subscription su +LEFT JOIN pg_stat_subscription st ON st.subid = su.oid +LEFT JOIN pg_stat_subscription_stats ss ON ss.subid = su.oid +WHERE su.subdbid = (SELECT oid FROM pg_database WHERE datname = current_database()) +ORDER BY su.subname +` diff --git a/internal/pg/queries_locks.go b/internal/pg/queries_locks.go new file mode 100644 index 0000000..b195bc1 --- /dev/null +++ b/internal/pg/queries_locks.go @@ -0,0 +1,56 @@ +package pg + +// sqlLockWaiters returns every backend that is part of a lock-wait relationship: +// either it is blocked (pg_blocking_pids returns a non-empty set) or it blocks +// someone else. For each such backend it reports identity, state, transaction +// age, the relation/lock it is waiting on (NULL when it isn't waiting), and its +// blockers as an int array — the TUI assembles the forest from the pid→blockers +// edges. A backend appears in the blocking set when its pid shows up in any +// other backend's pg_blocking_pids result. +// +// The waited-on lock is the ungranted row in pg_locks for the blocked backend; +// relation locks resolve to a regclass name, other lock types show their type. +const sqlLockWaiters = ` +WITH blocking AS ( + SELECT pid, pg_blocking_pids(pid) AS blockers + FROM pg_stat_activity + WHERE pid <> pg_backend_pid() +), +involved AS ( + SELECT pid FROM blocking WHERE cardinality(blockers) > 0 + UNION + SELECT DISTINCT unnest(blockers) FROM blocking +), +waited AS ( + -- The lock each blocked backend is waiting to acquire (its ungranted row). + -- DISTINCT ON keeps one representative wait per backend. + SELECT DISTINCT ON (l.pid) + l.pid, + l.locktype, + l.mode, + CASE WHEN l.locktype = 'relation' AND l.relation IS NOT NULL + THEN l.relation::regclass::text END AS wait_relation + FROM pg_locks l + WHERE NOT l.granted + ORDER BY l.pid +) +SELECT + a.pid, + coalesce(b.blockers, '{}') AS blockers, + coalesce(a.datname, '') AS datname, + coalesce(a.usename, '') AS usename, + coalesce(a.application_name, '') AS application_name, + coalesce(a.state, '') AS state, + coalesce(a.wait_event_type, '') AS wait_event_type, + coalesce(a.wait_event, '') AS wait_event, + coalesce(EXTRACT(epoch FROM now() - a.xact_start) * 1000, 0)::float8 AS xact_age_ms, + coalesce(w.locktype, '') AS wait_locktype, + coalesce(w.mode, '') AS wait_mode, + coalesce(w.wait_relation, '') AS wait_relation, + coalesce(left(regexp_replace(a.query, '\s+', ' ', 'g'), 300), '') AS query +FROM involved i +JOIN pg_stat_activity a ON a.pid = i.pid +LEFT JOIN blocking b ON b.pid = i.pid +LEFT JOIN waited w ON w.pid = i.pid +ORDER BY a.pid +` diff --git a/internal/pg/types_activity.go b/internal/pg/types_activity.go index 4037b9a..75952f9 100644 --- a/internal/pg/types_activity.go +++ b/internal/pg/types_activity.go @@ -83,5 +83,6 @@ type ActivityRow struct { XactAgeMs float64 // now() - xact_start in ms; 0 when xact_start is NULL StateAgeMs float64 // now() - state_change in ms; 0 when state_change is NULL QueryID int64 // pg_stat_statements queryid (PG 14+); 0 when unknown + BlockedBy string // space-separated PIDs blocking this backend (pg_blocking_pids); empty when unblocked Query string // truncated normalized query text } diff --git a/internal/pg/types_locks.go b/internal/pg/types_locks.go new file mode 100644 index 0000000..2aff8e2 --- /dev/null +++ b/internal/pg/types_locks.go @@ -0,0 +1,23 @@ +package pg + +// LockNode is one backend in the blocking forest: a row of pg_stat_activity +// restricted to backends that block or are blocked, plus the lock it is waiting +// on and the PIDs blocking it. Blockers drives the tree assembly in the TUI. +type LockNode struct { + PID int32 + Blockers []int32 // PIDs directly blocking this backend (pg_blocking_pids) + Database string + Username string + AppName string + State string // active | idle in transaction | … + WaitEventType string + WaitEvent string + XactAgeMs float64 // now() - xact_start in ms; 0 when not in a transaction + WaitLockType string // locktype of the lock it's waiting to acquire ("" when granted/not waiting) + WaitMode string // requested lock mode + WaitRelation string // relation it's waiting on (regclass name; "" when not a relation lock) + Query string // truncated current/last query +} + +// Waiting reports whether this backend is itself blocked (has any blocker). +func (n LockNode) Waiting() bool { return len(n.Blockers) > 0 } diff --git a/internal/tui/activity_columns.go b/internal/tui/activity_columns.go index c19f10a..c76fba8 100644 --- a/internal/tui/activity_columns.go +++ b/internal/tui/activity_columns.go @@ -29,6 +29,7 @@ const ( actColStateAge actColID = "state_age" actColBackendXid actColID = "xid" actColBackendXmin actColID = "xmin" + actColBlockedBy actColID = "blocked_by" actColTable actColID = "table" actColQuery actColID = "query" // OS-level proc columns (Linux only, local server; show — otherwise). @@ -139,9 +140,20 @@ func actColumnRegistry() []actColDesc { {id: actColBackendXmin, name: "xmin", kind: pg.DiagText, desc: "oldest transaction whose row versions this backend may still need (backend_xmin)", cell: func(r pg.ActivityRow, _ actCtx) pg.DiagCell { return pg.DiagCell{Display: r.BackendXmin} }}, + {id: actColBlockedBy, name: "blocked_by", kind: pg.DiagText, + desc: "PIDs blocking this backend on a lock (pg_blocking_pids); empty when it isn't waiting on anyone", + cell: func(r pg.ActivityRow, _ actCtx) pg.DiagCell { + if r.BlockedBy == "" { + return pg.DiagCell{Display: ""} + } + // Pre-style: this column is only non-empty for a genuinely blocked + // backend, so paint it red to draw the eye. It's a short PID list, + // so ANSI-in-Display survives the no-truncate path for narrow cells. + return pg.DiagCell{Display: styleErr.Render(r.BlockedBy)} + }}, // OS-level columns sourced from /proc — opt-in, show — on non-Linux or // remote connections where the local /proc PIDs don't match. - {id: actColRSS, name: "mem", kind: pg.DiagFloat, + {id: actColRSS, name: "mem", kind: pg.DiagCostGraded, desc: "resident memory (RSS = physical RAM held by the backend process) from /proc//status (Linux, local server only)", cell: func(r pg.ActivityRow, ctx actCtx) pg.DiagCell { d, ok := ctx.proc[r.PID] @@ -159,7 +171,7 @@ func actColumnRegistry() []actColDesc { } return pg.DiagCell{Display: fmt.Sprintf("%.1f%%", d.CPUPct), Num: d.CPUPct, HasNum: true} }}, - {id: actColReadBps, name: "read/s", kind: pg.DiagFloat, + {id: actColReadBps, name: "read/s", kind: pg.DiagCostGraded, desc: "storage read throughput from /proc//io (Linux, same UID as postgres or root)", cell: func(r pg.ActivityRow, ctx actCtx) pg.DiagCell { d, ok := ctx.proc[r.PID] @@ -168,7 +180,7 @@ func actColumnRegistry() []actColDesc { } return pg.DiagCell{Display: humanize.Bytes(int64(d.ReadBps)) + "/s", Num: d.ReadBps, HasNum: true} }}, - {id: actColWriteBps, name: "write/s", kind: pg.DiagFloat, + {id: actColWriteBps, name: "write/s", kind: pg.DiagCostGraded, desc: "storage write throughput from /proc//io (Linux, same UID as postgres or root)", cell: func(r pg.ActivityRow, ctx actCtx) pg.DiagCell { d, ok := ctx.proc[r.PID] diff --git a/internal/tui/app.go b/internal/tui/app.go index 2215de1..690ef36 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -46,6 +46,7 @@ const ( levelMaintenance // server-health dashboard (toolMaintenance) levelSettings // pg_settings browser (child of levelMaintenance) levelActivity // live server activity from pg_stat_activity (toolActivity) + levelLockTree // blocking-chain forest from pg_locks (child of levelActivity) levelTableStats // per-table statistics overview for one schema (toolTableStats) ) @@ -328,6 +329,17 @@ type screen struct { diagSortCol int // active sort column index for the generic table diagAllDBs bool // true when this result runs the query across all databases (leading "database" column) + // diagResult retains the full unprojected result on levelDiagnosticResult so + // the C column picker can re-project the visible subset without re-running + // the query. diagSortName tracks the active sort column by name (diagnostic + // columns have no stable ids) so the sort survives a visibility rebuild. + diagResult *pg.DiagResult + diagSortName string + + // diagCatFilter restricts the levelDiagnostics list to one category + // (f cycles all → index → table → …); "" shows every diagnostic. + diagCatFilter string + // stmtCols is the projected top-queries column descriptors, parallel to // diagCols (same length/order). Non-nil only on levelStatements; it maps the // renderer's column index (diagSortCol) back to a stable column id so the @@ -435,6 +447,13 @@ type screen struct { actVerbose bool actCols []actColDesc + // ── Lock tree (levelLockTree) ───────────────────────────────────────────── + // lockNodes is the last fetched set of blocking-chain backends; lockErr is + // non-nil when the load failed. The items list is the forest flattened in + // DFS order (item.data = lockTreeRow carrying the node and its indent depth). + lockNodes []pg.LockNode + lockErr error + // ── Table overview tool (levelTableStats) ──────────────────────────────── // tblRows is the last fetched per-table stats snapshot for this schema (the // source of truth for drill-in / describe, looked up by OID). tblCols is the @@ -567,6 +586,16 @@ type Model struct { showTblColumnConfig bool tblColCfgCursor int + // Diagnostic-result column configuration (C on levelDiagnosticResult). + // Diagnostic columns are dynamic (server field descriptions), so unlike the + // three static registries above, visibility is kept per diagnostic key and + // by column name. A missing inner map (or missing name) means visible; + // entries are lazily seeded from prefs by diagVis. diagColCfgCursor is the + // C-picker row cursor; showDiagColumnConfig opens it. + diagColsVisible map[string]map[string]bool + showDiagColumnConfig bool + diagColCfgCursor int + // actProcPrev holds the previous /proc sample per PID, used to compute CPU% // and I/O byte-rate deltas between consecutive samples. actProcPrev map[int32]procRaw @@ -713,22 +742,41 @@ func toolItems() []item { } } -// diagnosticItems builds the static list of available diagnostic queries shown -// at levelDiagnostics. Each item carries the Diagnostic value as its .data so -// drillIn can type-assert it and push a result screen. -func diagnosticItems() []item { - items := make([]item, len(pg.Diagnostics)) - for i, d := range pg.Diagnostics { - items[i] = item{ +// diagnosticItems builds the list of available diagnostic queries shown at +// levelDiagnostics, restricted to one category when cat is non-empty (the f +// cycle). Each item carries the Diagnostic value as its .data so drillIn can +// type-assert it and push a result screen; the category renders as a coloured +// badge in renderDiagnosticList. +func diagnosticItems(cat string) []item { + items := make([]item, 0, len(pg.Diagnostics)) + for _, d := range pg.Diagnostics { + if cat != "" && d.Category != cat { + continue + } + items = append(items, item{ name: d.Title, - detail: "[" + d.Category + "] " + d.Description, + detail: d.Description, hasChildren: true, data: d, - } + }) } return items } +// diagCategories returns the distinct diagnostic categories in registry order — +// the f key cycles through them (prefixed by "" = all). +func diagCategories() []string { + var out []string + seen := map[string]bool{} + for _, d := range pg.Diagnostics { + if !seen[d.Category] { + seen[d.Category] = true + out = append(out, d.Category) + } + } + return out +} + func (m *Model) Init() tea.Cmd { return tea.Batch(m.spinner.Tick, m.loadCurrent()) } diff --git a/internal/tui/cmds.go b/internal/tui/cmds.go index fa195a3..bca5a4c 100644 --- a/internal/tui/cmds.go +++ b/internal/tui/cmds.go @@ -556,6 +556,20 @@ func (m *Model) loadDescribeTableByNameCmd(db, name string) tea.Cmd { }) } +// loadDescribeIndexByNameCmd resolves an index name (from a diagnostic result +// row that carries only the index name) to its OID, then describes it — the +// index analogue of loadDescribeTableByNameCmd. +func (m *Model) loadDescribeIndexByNameCmd(db, name string) tea.Cmd { + return query(func(ctx context.Context) tea.Msg { + oid, qualified, err := m.client.ResolveIndex(ctx, db, name) + if err != nil { + return describeLoadedMsg{err: err} + } + d, err := m.client.DescribeIndex(ctx, db, oid, qualified) + return describeLoadedMsg{oid: oid, desc: d, err: err} + }) +} + // resolveDiskTableCmd resolves a relation name (parsed out of a query in the // top-queries view) to its catalog metadata so the caller can open the // disk-usage (parts) view for it. Only the resolve step runs here; a placeholder diff --git a/internal/tui/cmds_activity.go b/internal/tui/cmds_activity.go index b169608..74c7d78 100644 --- a/internal/tui/cmds_activity.go +++ b/internal/tui/cmds_activity.go @@ -20,6 +20,13 @@ type activityLoadedMsg struct { type activityTickMsg struct{} +// lockTreeLoadedMsg carries a fresh blocking-chain snapshot for levelLockTree. +type lockTreeLoadedMsg struct { + db string + nodes []pg.LockNode + err error +} + // activityHostsMsg delivers newly resolved hostnames from the background DNS // resolver so they can be merged into the activity table without a DB round-trip. type activityHostsMsg struct { @@ -58,6 +65,15 @@ func (m *Model) loadActivityCmd(db string, mode pg.ActivityFilter) tea.Cmd { }) } +// loadLockTreeCmd fetches the current blocking-chain backends for the lock-tree +// view. Shares the Activity tool's 30 s query() budget. +func (m *Model) loadLockTreeCmd(db string) tea.Cmd { + return query(func(ctx context.Context) tea.Msg { + nodes, err := m.client.ListLockWaiters(ctx, db) + return lockTreeLoadedMsg{db: db, nodes: nodes, err: err} + }) +} + // activityTick schedules the next Activity re-sample, or returns nil when // auto-refresh is off (m.activityRefresh == 0). Returning nil stops the // self-rescheduling loop; cycling refresh back on or re-entering the tool diff --git a/internal/tui/diag_columns.go b/internal/tui/diag_columns.go new file mode 100644 index 0000000..b0674b9 --- /dev/null +++ b/internal/tui/diag_columns.go @@ -0,0 +1,190 @@ +package tui + +import ( + "strconv" + "strings" + + "pgdu/internal/pg" +) + +// diagPrefsKey namespaces a diagnostic's column-visibility selection in the +// prefs file so each diagnostic remembers its own picker independently. +func diagPrefsKey(key string) string { + return "diag/" + key +} + +// diagColOn reports whether a diagnostic column is visible under vis. Missing +// entries default to visible, so columns a query grows in a later build (and +// the leading "database" column of an all-DBs run) appear without migration. +func diagColOn(vis map[string]bool, name string) bool { + if vis == nil { + return true + } + v, ok := vis[name] + return !ok || v +} + +// diagVis returns the column-visibility map for a diagnostic key, lazily +// seeding it from the persisted prefs on first use. nil means "all visible". +func (m *Model) diagVis(key string) map[string]bool { + if vis, ok := m.diagColsVisible[key]; ok { + return vis + } + var vis map[string]bool + if m.colPrefs != nil { + if v := m.colPrefs.Columns(diagPrefsKey(key)); len(v) > 0 { + vis = v + } + } + if m.diagColsVisible == nil { + m.diagColsVisible = map[string]map[string]bool{} + } + m.diagColsVisible[key] = vis + return vis +} + +// rebuildDiagItems re-projects the retained full result (s.diagResult) to the +// currently visible column subset: columns, bar/sort indices, items and the Σ +// footer all derive from the same projection so they stay parallel by +// construction. Call it after a fresh load or any column toggle. +func (m *Model) rebuildDiagItems(s *screen) { + res := s.diagResult + if res == nil || s.diag == nil { + return + } + vis := m.diagVis(s.diag.Key) + idxs := make([]int, 0, len(res.Columns)) + for i := range res.Columns { + if diagColOn(vis, res.Columns[i].Name) { + idxs = append(idxs, i) + } + } + if len(idxs) == 0 { + // Never project every column away — the toggle handler prevents this, + // but a stale prefs file could; fall back to the first column. + idxs = append(idxs, 0) + } + + cols := make([]pg.DiagColumn, len(idxs)) + for j, i := range idxs { + cols[j] = res.Columns[i] + } + s.diagCols = cols + + s.diagBarCol = -1 + for j, i := range idxs { + if i == res.BarCol { + s.diagBarCol = j + } + } + + // item.name is the space-joined cell display so the fuzzy filter can match + // any (visible) column value. + s.items = s.items[:0] + for _, row := range res.Rows { + cells := make([]pg.DiagCell, len(idxs)) + parts := make([]string, len(idxs)) + for j, i := range idxs { + if i < len(row) { + cells[j] = row[i] + } + parts[j] = cells[j].Display + } + s.items = append(s.items, item{name: strings.Join(parts, " "), data: cells}) + } + s.diagTotalRow = diagFooterCells(cols, s.items) + + // Resolve the sort column: the remembered name if still visible, else the + // diagnostic's default sort (descending), else column 0 ascending. + sortIdx := -1 + if s.diagSortName != "" { + for j, c := range cols { + if c.Name == s.diagSortName { + sortIdx = j + break + } + } + } + if sortIdx < 0 { + if res.SortCol >= 0 && res.SortCol < len(res.Columns) { + want := res.Columns[res.SortCol].Name + for j, c := range cols { + if c.Name == want { + sortIdx = j + break + } + } + } + if sortIdx >= 0 { + s.sortDesc = true + } else { + sortIdx = 0 + s.sortDesc = false + } + s.diagSortName = cols[sortIdx].Name + } + s.diagSortCol = sortIdx + + s.diagMetricsDirty = true + m.applySort(s) +} + +// diagFooterCells builds the pinned Σ footer for a generic diagnostic result. +// Only additive columns sum meaningfully: counts (DiagInt) and sizes +// (DiagBytes). Percents, grades, floats and text stay blank, as do +// identifier-shaped numeric columns (pid/oid) whose sum is nonsense. The +// row-count label lands in the first text column. Returns nil when no column +// summed — a footer of blanks would just eat a row. +func diagFooterCells(cols []pg.DiagColumn, items []item) []pg.DiagCell { + if len(items) == 0 { + return nil + } + total := make([]pg.DiagCell, len(cols)) + summed := false + for j, c := range cols { + if c.Kind != pg.DiagInt && c.Kind != pg.DiagBytes { + continue + } + lower := strings.ToLower(c.Name) + if lower == "pid" || strings.HasSuffix(lower, "_pid") || strings.HasSuffix(lower, "oid") { + continue + } + var sum float64 + n := 0 + for _, it := range items { + row, ok := it.data.([]pg.DiagCell) + if !ok || j >= len(row) || !row[j].HasNum { + continue + } + sum += row[j].Num + n++ + } + if n == 0 { + continue + } + display := strconv.FormatInt(int64(sum), 10) + if sum != float64(int64(sum)) { + display = diagFormatFloatTUI(sum) + } + total[j] = pg.DiagCell{Display: display, Num: sum, HasNum: true} + summed = true + } + if !summed { + return nil + } + for j, c := range cols { + if c.Kind == pg.DiagText && !total[j].HasNum { + total[j] = pg.DiagCell{Display: "Σ " + strconv.Itoa(len(items)) + " rows"} + break + } + } + return total +} + +// diagFormatFloatTUI renders a summed float with up to 2 decimals, trailing +// zeros stripped (mirrors pg's diagFormatFloat, which isn't exported). +func diagFormatFloatTUI(f float64) string { + s := strconv.FormatFloat(f, 'f', 2, 64) + s = strings.TrimRight(s, "0") + return strings.TrimRight(s, ".") +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go index f7aed82..4667dd7 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -36,6 +36,7 @@ type keyMap struct { ActivityFilter key.Binding // f: cycle backend filter mode CancelBackend key.Binding // k: send pg_cancel_backend (SIGINT) TerminateBackend key.Binding // x: send pg_terminate_backend (SIGTERM) + LockTree key.Binding // b: open the blocking-chain lock tree // WAL-inspector binding. WALByRelation key.Binding // w: open the by-relation breakdown of the window @@ -43,6 +44,14 @@ type keyMap struct { // Shared-buffers-tool binding. ShmemMap key.Binding // m: open the shared-memory map (pg_shmem_allocations) + // System-overview cross-links: jump from the maintenance dashboard into the + // live tools that show the detail behind a summary row. Enabled only on + // levelMaintenance (so they don't clash with r/reverse-sort and w/by-relation + // elsewhere) and dispatched before those cases. + JumpActivity key.Binding // a: open the Activity tool + JumpWAL key.Binding // w: open the WAL inspector + JumpReplication key.Binding // r: open the replication-slots diagnostic + // shmemInFooter adds the m (memory map) hint to the footer's short help on // the buffer-tables level, where it's the only advertisement for the view. shmemInFooter bool @@ -92,9 +101,14 @@ func defaultKeys() keyMap { ActivityFilter: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "cycle filter")), CancelBackend: key.NewBinding(key.WithKeys("k"), key.WithHelp("k", "cancel backend")), TerminateBackend: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "terminate backend")), + LockTree: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "lock tree")), WALByRelation: key.NewBinding(key.WithKeys("w"), key.WithHelp("w", "by relation")), ShmemMap: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "memory map")), + + JumpActivity: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "activity")), + JumpWAL: key.NewBinding(key.WithKeys("w"), key.WithHelp("w", "wal")), + JumpReplication: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "replication")), } } @@ -122,11 +136,11 @@ func (k *keyMap) applyContext(s *screen) { k.Rebaseline.SetEnabled(stmtTable) k.Snapshots.SetEnabled(stmtTable) // C (Columns) is the column-config picker on the top-queries table, the - // activity table and the table overview. The picker is hard to find on those - // last two (no header hint), so surface it in the footer there; the - // top-queries header already advertises it. - k.Columns.SetEnabled(stmtTable || activity || tableStats) - k.columnsInFooter = activity || tableStats + // activity table, the table overview and diagnostic results. The picker is + // hard to find without a header hint, so surface it in the footer everywhere + // but the top-queries table, whose header already advertises it. + k.Columns.SetEnabled(stmtTable || activity || tableStats || diagResult) + k.columnsInFooter = activity || tableStats || diagResult // t (ToggleRefresh) cycles the auto-refresh cadence on top-queries levels and // on the activity level. k.ToggleRefresh.SetEnabled(stmtTable || stmtDetail || activity) @@ -142,9 +156,15 @@ func (k *keyMap) applyContext(s *screen) { // (the prompt renders its own `i` hint); keep it out of the footer otherwise. k.Install.SetEnabled(s.extPrompt != nil && s.extPrompt.installable) - k.ActivityFilter.SetEnabled(activity) - k.CancelBackend.SetEnabled(activity) - k.TerminateBackend.SetEnabled(activity) + // f cycles the backend filter on the activity table and the category filter + // on the diagnostics list. + k.ActivityFilter.SetEnabled(activity || s.level == levelDiagnostics) + // Cancel/terminate act on the selected backend from both the activity table + // and its lock-tree child. + k.CancelBackend.SetEnabled(activity || s.level == levelLockTree) + k.TerminateBackend.SetEnabled(activity || s.level == levelLockTree) + // b opens the lock tree from the activity table. + k.LockTree.SetEnabled(activity) // w opens the by-relation WAL breakdown — only from the rmgr overview, so // the physical key stays free for reuse on every other level. @@ -155,6 +175,13 @@ func (k *keyMap) applyContext(s *screen) { k.ShmemMap.SetEnabled(s.level == levelBufferTables) k.shmemInFooter = s.level == levelBufferTables + // System-overview cross-links only exist on the maintenance dashboard; gating + // them here keeps r/w free for reverse-sort and WAL-by-relation everywhere else. + maint := s.level == levelMaintenance + k.JumpActivity.SetEnabled(maint) + k.JumpWAL.SetEnabled(maint) + k.JumpReplication.SetEnabled(maint) + // s seeks on the index-tuples view: a key value on B-tree, a heap block // number on BRIN. GiST/GIN keys have no total order, so seek is disabled // there (use the / filter). The physical key is otherwise ShowQuery @@ -181,6 +208,7 @@ func (k keyMap) FullHelp() [][]key.Binding { {k.Filter, k.Seek, k.SortPrev, k.SortNext, k.ShowQuery, k.ReverseSort}, {k.Refresh, k.ToggleBloat, k.Install, k.Describe, k.DiskUsage}, {k.Rebaseline, k.ToggleRefresh, k.Params, k.Execute, k.Verbose, k.Export}, + {k.ActivityFilter, k.CancelBackend, k.TerminateBackend, k.LockTree}, {k.SaveSnapshot, k.Snapshots, k.DeleteSnapshot, k.Columns, k.WALByRelation, k.ShmemMap}, {k.Help, k.Quit}, } diff --git a/internal/tui/queries_columns.go b/internal/tui/queries_columns.go index 3e80559..34d5363 100644 --- a/internal/tui/queries_columns.go +++ b/internal/tui/queries_columns.go @@ -16,6 +16,10 @@ const ( colTotalMs stmtColID = "total_ms" colPctTime stmtColID = "time%" colMeanMs stmtColID = "mean_ms" + colMinMs stmtColID = "min_ms" + colMaxMs stmtColID = "max_ms" + colStddevMs stmtColID = "stddev_ms" + colCV stmtColID = "cv" colPlanMs stmtColID = "plan_ms" colMeanPlanMs stmtColID = "mean_plan_ms" colPlans stmtColID = "plans" @@ -86,6 +90,43 @@ func stmtColumnRegistry() []stmtColDesc { {id: colMeanMs, name: "mean_ms", kind: pg.DiagCostGraded, defaultOn: true, desc: "average execution time per call", cell: func(q pg.QueryStat, _ stmtCtx) pg.DiagCell { return diagNum(fmtMs(q.MeanTime()), q.MeanTime()) }}, + // The extrema/stddev are cumulative since the last stats reset — they can't + // be windowed (you can't subtract two extrema), so the diff zeroes them and + // these cells show "—" on windowed rows. They carry real values on the + // cumulative "since last reset" anchor and on queries new since the baseline. + {id: colMinMs, name: "min_ms", kind: pg.DiagDuration, + desc: "fastest single call (cumulative since stats reset — on windowed rows)", + cell: func(q pg.QueryStat, _ stmtCtx) pg.DiagCell { + if q.MinExecTime <= 0 { + return pg.DiagCell{Display: "—"} + } + return diagNum(fmtMs(q.MinExecTime), q.MinExecTime) + }}, + {id: colMaxMs, name: "max_ms", kind: pg.DiagDuration, + desc: "slowest single call (cumulative since stats reset) — spots outlier spikes a mean hides", + cell: func(q pg.QueryStat, _ stmtCtx) pg.DiagCell { + if q.MaxExecTime <= 0 { + return pg.DiagCell{Display: "—"} + } + return diagNum(fmtMs(q.MaxExecTime), q.MaxExecTime) + }}, + {id: colStddevMs, name: "stddev_ms", kind: pg.DiagDuration, + desc: "standard deviation of execution time (cumulative since stats reset)", + cell: func(q pg.QueryStat, _ stmtCtx) pg.DiagCell { + if q.StddevExecTime <= 0 { + return pg.DiagCell{Display: "—"} + } + return diagNum(fmtMs(q.StddevExecTime), q.StddevExecTime) + }}, + {id: colCV, name: "cv", kind: pg.DiagCostGraded, + desc: "coefficient of variation (stddev ÷ mean, cumulative) — high = erratic runtime, plan flips or lock waits", + cell: func(q pg.QueryStat, _ stmtCtx) pg.DiagCell { + if q.StddevExecTime <= 0 || q.MeanExecTime <= 0 { + return pg.DiagCell{Display: "—"} + } + v := q.StddevExecTime / q.MeanExecTime + return diagNum(fmtFloat(v), v) + }}, {id: colPlanMs, name: "plan_ms", kind: pg.DiagFloat, available: planningOnly, desc: "total planning time (needs track_planning)", cell: func(q pg.QueryStat, _ stmtCtx) pg.DiagCell { return diagNum(fmtMs(q.TotalPlanTime), q.TotalPlanTime) }}, diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 8c3457e..69baa2a 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -82,6 +82,25 @@ var ( } ) +// diagCatStyle tints a diagnostics-list category badge, one hue per domain so +// the list scans by colour before reading. Unmapped categories (and the "all" +// filter label) render muted. +func diagCatStyle(cat string) lipgloss.Style { + switch cat { + case "index": + return styleBar + case "table": + return lipgloss.NewStyle().Foreground(colorOK) + case "vacuum": + return lipgloss.NewStyle().Foreground(colorAccent) + case "activity": + return lipgloss.NewStyle().Foreground(colorCostLow) + case "wal": + return lipgloss.NewStyle().Foreground(colorBloat) + } + return styleMuted +} + // bufferSliceStyle returns the palette colour for slice index i, cycling on // overflow. Callers should still cap N to a sensible number so legends stay // readable. diff --git a/internal/tui/update.go b/internal/tui/update.go index 4fba09a..94d82ae 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -141,6 +141,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case activityLoadedMsg: return m, m.onActivityLoaded(msg) + case lockTreeLoadedMsg: + return m, m.onLockTreeLoaded(msg) case activityTickMsg: return m, m.onActivityTick() case activityHostsMsg: diff --git a/internal/tui/update_keys.go b/internal/tui/update_keys.go index b6e236f..4512e6a 100644 --- a/internal/tui/update_keys.go +++ b/internal/tui/update_keys.go @@ -158,6 +158,11 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.showTblColumnConfig && s.level == levelTableStats { return m, m.handleTblColumnConfigKey(s, msg) } + // Diagnostic-result column-config overlay — same modal pattern, but over the + // result's dynamic column set instead of a static registry. + if m.showDiagColumnConfig && s.level == levelDiagnosticResult { + return m, m.handleDiagColumnConfigKey(s, msg) + } // The SQL overlay (s on a diagnostic result) is modal: any key dismisses it // so the underlying table bindings don't fire while it's up. Quit still quits. if m.showDiagQuery { @@ -286,6 +291,22 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if s.diag != nil { m.showDiagQuery = true } + case key.Matches(msg, m.keys.JumpActivity): + // System-overview cross-link: open the live Activity tool for the detail + // behind the dashboard's connection/blocked/long-xact figures. + m.stack = append(m.stack, m.toolEntryScreen(toolActivity)) + return m, m.loadCurrent() + case key.Matches(msg, m.keys.JumpWAL): + m.stack = append(m.stack, m.toolEntryScreen(toolWAL)) + return m, m.loadCurrent() + case key.Matches(msg, m.keys.JumpReplication): + // Open the replication-slots diagnostic against the default database. + for i := range pg.Diagnostics { + if pg.Diagnostics[i].Key == "replication_slots" { + m.stack = append(m.stack, diagnosticResultScreen(&pg.Diagnostics[i], "", false)) + return m, m.loadCurrent() + } + } case key.Matches(msg, m.keys.SortNext): m.cycleSort(s, +1) case key.Matches(msg, m.keys.SortPrev): @@ -295,6 +316,17 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.applySort(s) case key.Matches(msg, m.keys.Refresh): return m, m.loadCurrent() + case key.Matches(msg, m.keys.LockTree): + // Open the blocking-chain tree over the activity table. Matched before + // ToggleBloat since both use "b"; LockTree is enabled only on levelActivity. + if s.level == levelActivity { + next := &screen{ + level: levelLockTree, title: "lock tree", tool: toolActivity, + db: s.db, loading: true, + } + m.stack = append(m.stack, next) + return m, m.loadCurrent() + } case key.Matches(msg, m.keys.ToggleBloat): m.fetchBloat = !m.fetchBloat case key.Matches(msg, m.keys.Install): @@ -336,21 +368,35 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Reload immediately with the new filter. return m, m.loadActivityCmd(s.db, s.actFilter) } - case key.Matches(msg, m.keys.CancelBackend): - if s.level == levelActivity { - // Arm the two-step confirmation for pg_cancel_backend. - if pid := activitySelectedPID(s); pid != 0 { - s.pendingBackendPID = pid - s.pendingBackendAction = "cancel" + if s.level == levelDiagnostics { + // Cycle the category filter (all → index → table → …) client-side. + cats := diagCategories() + next := "" + for i, c := range cats { + if c == s.diagCatFilter && i+1 < len(cats) { + next = cats[i+1] + break + } + } + if s.diagCatFilter == "" && len(cats) > 0 { + next = cats[0] } + s.diagCatFilter = next + s.items = diagnosticItems(s.diagCatFilter) + s.itemsRev++ + s.resetCursor() + } + case key.Matches(msg, m.keys.CancelBackend): + // Arm the two-step confirmation for pg_cancel_backend, from the activity + // table or the lock tree. + if pid := backendActionPID(s); pid != 0 { + s.pendingBackendPID = pid + s.pendingBackendAction = "cancel" } case key.Matches(msg, m.keys.TerminateBackend): - if s.level == levelActivity { - // Arm the two-step confirmation for pg_terminate_backend. - if pid := activitySelectedPID(s); pid != 0 { - s.pendingBackendPID = pid - s.pendingBackendAction = "terminate" - } + if pid := backendActionPID(s); pid != 0 { + s.pendingBackendPID = pid + s.pendingBackendAction = "terminate" } case key.Matches(msg, m.keys.Columns): // Open the htop-style column picker — on the top-queries table or on @@ -373,6 +419,11 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.showTblColumnConfig = true m.tblColCfgCursor = 0 } + if s.level == levelDiagnosticResult && s.diagResult != nil { + m.showInfo = false + m.showDiagColumnConfig = true + m.diagColCfgCursor = 0 + } case key.Matches(msg, m.keys.ToggleRefresh): // Cycle the live window's auto-refresh cadence (activity: 500ms → 1s → 2s → 5s → 10s → off). if s.level == levelStatements || s.level == levelStatementDetail { @@ -471,6 +522,9 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { loading: true, } m.stack = append(m.stack, next) + if t.indexByName { + return m, m.loadDescribeIndexByNameCmd(t.db, t.indexName) + } if t.isIndex { return m, m.loadDescribeIndexCmd(t.db, t.indexOID, t.indexName) } @@ -582,13 +636,14 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // descTarget holds the resolved target for a describe action. type descTarget struct { - isIndex bool - byName bool // when the relation is known only by name (top-queries view) - table pg.Table // when !isIndex && !byName - db string // when isIndex || byName - tableName string // when byName — resolved server-side via ResolveTable - indexOID uint32 // when isIndex - indexName string // when isIndex + isIndex bool + byName bool // when the relation is known only by name (top-queries view) + indexByName bool // when only an index name is known (diagnostic result) — resolved via ResolveIndex + table pg.Table // when !isIndex && !byName + db string // when isIndex || byName || indexByName + tableName string // when byName — resolved server-side via ResolveTable + indexOID uint32 // when isIndex + indexName string // when isIndex or indexByName } // describeTarget resolves what `d` should describe given the top screen. It @@ -655,6 +710,18 @@ func describeTarget(s *screen) (descTarget, bool) { OID: st.OID, TotalBytes: st.TotalBytes, }}, true + case levelBufferDetail: + // The inspected table is carried on the screen; same reconstruction as + // the buffer-tables list row. + if s.bufDetail == nil { + return descTarget{}, false + } + st := s.bufDetail + return descTarget{table: pg.Table{ + DB: st.DB, Schema: st.Schema, Name: st.Name, + OID: st.OID, TotalBytes: st.TotalBytes, + }}, true + case levelTableStats: // Generic-table rows carry the relation OID in statQueryID; resolve it // back to the loaded TableStat and describe by exact OID (no name lookup). @@ -729,6 +796,29 @@ func describeTarget(s *screen) (descTarget, bool) { indexName: s.index.Qualified(), }, true + case levelActivity: + // Describe the main table of the highlighted backend's query, in that + // backend's database (which may differ from the screen's connection). + pid := activitySelectedPID(s) + if pid == 0 { + return descTarget{}, false + } + for i := range s.actRows { + if s.actRows[i].PID != pid { + continue + } + name := pg.MainTable(s.actRows[i].Query) + if name == "" { + return descTarget{}, false + } + db := s.actRows[i].Database + if db == "" { + db = s.db + } + return descTarget{byName: true, db: db, tableName: name}, true + } + return descTarget{}, false + case levelDiagnosticResult: // Generic diagnostic rows carry no pg.Table — resolve the relation by // the name in the row (server-side, like the top-queries view). Only @@ -742,11 +832,16 @@ func describeTarget(s *screen) (descTarget, bool) { if !ok { return descTarget{}, false } - name, ok := diagDescribeName(s.diagCols, cells) - if !ok { - return descTarget{}, false + // Prefer a table column; fall back to an index column (index-only + // diagnostics: unused/duplicate/redundant/index-I/O), resolved via + // ResolveIndex into a DescribeIndex panel. + if name, ok := diagDescribeName(s.diagCols, cells); ok { + return descTarget{byName: true, db: s.db, tableName: name}, true } - return descTarget{byName: true, db: s.db, tableName: name}, true + if name, ok := diagDescribeIndexName(s.diagCols, cells); ok { + return descTarget{indexByName: true, db: s.db, indexName: name}, true + } + return descTarget{}, false } return descTarget{}, false @@ -794,6 +889,43 @@ func diagDescribeName(cols []pg.DiagColumn, cells []pg.DiagCell) (string, bool) return quoteDiagIdent(table), true } +// diagDescribeIndexName extracts an index name from a diagnostic result row (the +// index-only diagnostics: unused / duplicate / redundant-prefix / index I/O). +// Qualified with a sibling schema column when present, the same way +// diagDescribeName handles tables. Returns ("", false) when no index column +// exists, so table describe stays the default. +func diagDescribeIndexName(cols []pg.DiagColumn, cells []pg.DiagCell) (string, bool) { + idxIdx, schemaIdx := -1, -1 + for i, c := range cols { + switch strings.ToLower(c.Name) { + case "index", "index_name", "indexname", "indexrelname", "redundant_index": + if idxIdx == -1 { + idxIdx = i + } + case "schema", "schemaname", "schema_name", "table_schema": + if schemaIdx == -1 { + schemaIdx = i + } + } + } + if idxIdx < 0 || idxIdx >= len(cells) { + return "", false + } + name := strings.TrimSpace(cells[idxIdx].Display) + if name == "" || name == "—" { + return "", false + } + if strings.Contains(name, ".") { + return name, true + } + if schemaIdx >= 0 && schemaIdx < len(cells) { + if schema := strings.TrimSpace(cells[schemaIdx].Display); schema != "" && schema != "—" { + return quoteDiagIdent(schema) + "." + quoteDiagIdent(name), true + } + } + return quoteDiagIdent(name), true +} + // quoteDiagIdent double-quotes a single SQL identifier so it round-trips through // to_regclass unchanged regardless of case or special characters. func quoteDiagIdent(s string) string { @@ -816,6 +948,19 @@ func (m *Model) triggerInstall(s *screen) tea.Cmd { return m.installExtensionCmd(s.extPrompt.db, s.extPrompt.name) } +// backendActionPID resolves the PID that k/x should act on for the current +// screen: the activity table's selected row or the lock tree's selected node. +// Returns 0 on any other level or when nothing is selected. +func backendActionPID(s *screen) int32 { + switch s.level { + case levelActivity: + return activitySelectedPID(s) + case levelLockTree: + return lockTreeSelectedPID(s) + } + return 0 +} + // activitySelectedPID returns the PID of the currently highlighted backend in // the Activity table, or 0 when no row is selected or the data doesn't carry a // PID. item.statQueryID reuses the queryid field; the PID lives in the first diff --git a/internal/tui/update_keys_columns.go b/internal/tui/update_keys_columns.go index e19d5d4..53ec313 100644 --- a/internal/tui/update_keys_columns.go +++ b/internal/tui/update_keys_columns.go @@ -102,6 +102,70 @@ func (m *Model) handleActColumnConfigKey(s *screen, msg tea.KeyMsg) tea.Cmd { return nil } +// handleDiagColumnConfigKey drives the modal column-config overlay for a +// diagnostic result (C on levelDiagnosticResult). Unlike the registry-backed +// pickers it operates on the result's dynamic column set; visibility is kept +// per diagnostic key and by column name (see diagVis). The last visible column +// can't be hidden. +func (m *Model) handleDiagColumnConfigKey(s *screen, msg tea.KeyMsg) tea.Cmd { + res := s.diagResult + if res == nil || s.diag == nil { + m.showDiagColumnConfig = false + return nil + } + cols := res.Columns + switch { + case key.Matches(msg, m.keys.Quit): + return tea.Quit + case key.Matches(msg, m.keys.Columns), msg.Type == tea.KeyEsc: + m.showDiagColumnConfig = false + case key.Matches(msg, m.keys.Up): + if m.diagColCfgCursor > 0 { + m.diagColCfgCursor-- + } + case key.Matches(msg, m.keys.Down): + if m.diagColCfgCursor < len(cols)-1 { + m.diagColCfgCursor++ + } + case key.Matches(msg, m.keys.Top): + m.diagColCfgCursor = 0 + case key.Matches(msg, m.keys.Bottom): + m.diagColCfgCursor = len(cols) - 1 + case key.Matches(msg, m.keys.ResetCols): + m.diagColsVisible[s.diag.Key] = nil + m.rebuildDiagItems(s) + m.saveColPrefs(diagPrefsKey(s.diag.Key), map[string]bool{}) + case key.Matches(msg, m.keys.Refresh), key.Matches(msg, m.keys.Enter): + if m.diagColCfgCursor < 0 || m.diagColCfgCursor >= len(cols) { + break + } + name := cols[m.diagColCfgCursor].Name + vis := m.diagVis(s.diag.Key) + if vis == nil { + vis = make(map[string]bool, len(cols)) + for _, c := range cols { + vis[c.Name] = true + } + } + if diagColOn(vis, name) { + visible := 0 + for _, c := range cols { + if diagColOn(vis, c.Name) { + visible++ + } + } + if visible <= 1 { + break // keep at least one column on screen + } + } + vis[name] = !diagColOn(vis, name) + m.diagColsVisible[s.diag.Key] = vis + m.rebuildDiagItems(s) + m.saveColPrefs(diagPrefsKey(s.diag.Key), vis) + } + return nil +} + // handleTblColumnConfigKey drives the modal column-config overlay for the Table // overview tool (C on levelTableStats). Mirrors handleActColumnConfigKey. func (m *Model) handleTblColumnConfigKey(s *screen, msg tea.KeyMsg) tea.Cmd { diff --git a/internal/tui/update_load.go b/internal/tui/update_load.go index 60328f4..1168088 100644 --- a/internal/tui/update_load.go +++ b/internal/tui/update_load.go @@ -25,7 +25,7 @@ func (m *Model) loadCurrent() tea.Cmd { s.loaded = true return nil case levelDiagnostics: - s.items = diagnosticItems() + s.items = diagnosticItems(s.diagCatFilter) s.itemsRev++ // doesn't go through applySort; invalidate the filter cache s.loading = false s.loaded = true @@ -167,6 +167,8 @@ func (m *Model) loadCurrent() tea.Cmd { // Reset generic-table state so a Refresh shows a clean load. s.diagCols = nil s.diagBarCol = -1 + s.diagResult = nil + s.diagTotalRow = nil if s.diagAllDBs { return m.loadDiagnosticAllDBsCmd(*s.diag) } @@ -221,6 +223,16 @@ func (m *Model) loadCurrent() tea.Cmd { } } return tea.Batch(cmds...) + case levelLockTree: + // Same live-refresh pattern as the activity table, reusing its tick loop. + cmds := []tea.Cmd{m.loadLockTreeCmd(s.db)} + if !m.activityTicking { + if tick := m.activityTick(); tick != nil { + m.activityTicking = true + cmds = append(cmds, tick) + } + } + return tea.Batch(cmds...) case levelTableStats: return m.loadTableOverviewCmd(s.db, s.schema) } diff --git a/internal/tui/update_msgs.go b/internal/tui/update_msgs.go index 7734da4..21331bd 100644 --- a/internal/tui/update_msgs.go +++ b/internal/tui/update_msgs.go @@ -3,7 +3,6 @@ package tui import ( "fmt" "maps" - "strings" "time" tea "github.com/charmbracelet/bubbletea" @@ -386,30 +385,10 @@ func (m *Model) onDiagnosticLoaded(msg diagnosticLoadedMsg) tea.Cmd { if msg.err != nil || msg.result == nil { return nil } - s.diagCols = msg.result.Columns - s.diagBarCol = msg.result.BarCol - // Default sort: the result's SortCol (biggest first) if present, else col 0. - if msg.result.SortCol >= 0 { - s.diagSortCol = msg.result.SortCol - s.sortDesc = true - } else { - s.diagSortCol = 0 - s.sortDesc = false - } - // Convert each result row to an item. item.name is the space-joined cell - // display so the existing fuzzy filter can match any column value. - for _, row := range msg.result.Rows { - parts := make([]string, len(row)) - for i, cell := range row { - parts[i] = cell.Display - } - s.items = append(s.items, item{ - name: strings.Join(parts, " "), - data: row, // []pg.DiagCell - }) - } - s.diagMetricsDirty = true - m.applySort(s) + // Retain the full result and project it through the column-visibility + // selection; rebuildDiagItems also resolves bar/sort indices and the footer. + s.diagResult = msg.result + m.rebuildDiagItems(s) return nil } @@ -582,11 +561,11 @@ func (m *Model) onTableOverviewLoaded(msg tableOverviewLoadedMsg) tea.Cmd { } func (m *Model) onActivityTick() tea.Cmd { - // Keep the tick alive while the user is anywhere in the activity tool (the - // table is the only level). Stop when they navigate fully away so re-entry - // can start a fresh loop. + // Keep the tick alive while the user is in the activity table or its + // lock-tree child. Stop when they navigate fully away so re-entry can start a + // fresh loop. top := m.top() - if top.level != levelActivity { + if top.level != levelActivity && top.level != levelLockTree { m.activityTicking = false return nil } @@ -596,9 +575,29 @@ func (m *Model) onActivityTick() tea.Cmd { m.activityTicking = false return nil } + if top.level == levelLockTree { + return tea.Batch(m.loadLockTreeCmd(top.db), next) + } return tea.Batch(m.loadActivityCmd(top.db, top.actFilter), next) } +func (m *Model) onLockTreeLoaded(msg lockTreeLoadedMsg) tea.Cmd { + s := m.findLevel(levelLockTree) + if s == nil || s.db != msg.db { + return nil + } + s.loading = false + s.loaded = true + if msg.err != nil { + s.lockErr = msg.err + return nil + } + s.lockErr = nil + s.lockNodes = msg.nodes + m.rebuildLockTreeItems(s) + return nil +} + func (m *Model) onActivityHosts(msg activityHostsMsg) tea.Cmd { s := m.findLevel(levelActivity) if s == nil { @@ -615,7 +614,12 @@ func (m *Model) onActivityHosts(msg activityHostsMsg) tea.Cmd { } func (m *Model) onBackendAction(msg backendActionMsg) tea.Cmd { - s := m.findLevel(levelActivity) + // The action may have been fired from the activity table or the lock tree; + // act on whichever is on top so the right list refreshes. + s := m.findLevel(levelLockTree) + if s == nil { + s = m.findLevel(levelActivity) + } if s == nil { return nil } @@ -630,6 +634,9 @@ func (m *Model) onBackendAction(msg backendActionMsg) tea.Cmd { m.notice = fmt.Sprintf("%s sent to backend %d", msg.action, msg.pid) } // Refresh the list so the terminated/cancelled backend disappears or changes state. + if s.level == levelLockTree { + return m.loadLockTreeCmd(s.db) + } return m.loadActivityCmd(s.db, s.actFilter) } diff --git a/internal/tui/update_sort.go b/internal/tui/update_sort.go index 07748dc..be433ea 100644 --- a/internal/tui/update_sort.go +++ b/internal/tui/update_sort.go @@ -294,6 +294,11 @@ func (m *Model) cycleSort(s *screen, dir int) { if s.tblCols != nil && s.diagSortCol < len(s.tblCols) { m.tblSortColID = s.tblCols[s.diagSortCol].id } + // Diagnostic results track the sort column by name (no stable ids), so a + // later column hide/show can re-pin it (see rebuildDiagItems). + if s.diag != nil && s.diagSortCol < len(s.diagCols) { + s.diagSortName = s.diagCols[s.diagSortCol].Name + } m.applySort(s) return } diff --git a/internal/tui/view.go b/internal/tui/view.go index 7433f4d..513bc0f 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -141,6 +141,8 @@ func (m *Model) View() string { b.WriteString(m.renderColumnConfig(s, contentHeight)) case m.showTblColumnConfig && s.level == levelTableStats: b.WriteString(m.renderTblColumnConfig(s, contentHeight)) + case m.showDiagColumnConfig && s.level == levelDiagnosticResult: + b.WriteString(m.renderDiagColumnConfig(s, contentHeight)) case m.showDiagQuery && s.level == levelDiagnosticResult && s.diag != nil: b.WriteString(m.renderDiagQuery(s, contentHeight)) case m.showInfo && m.hasInfoOverlay(s): @@ -164,7 +166,7 @@ func (m *Model) View() string { s.level != levelStatements && s.level != levelStatementDetail && s.level != levelStatementResult && s.level != levelSnapshots && s.level != levelBufferDetail && s.level != levelMaintenance && s.level != levelSettings && - s.level != levelActivity && s.level != levelTableStats: + s.level != levelActivity && s.level != levelLockTree && s.level != levelTableStats: // levelDescribe never populates items — it renders from s.describe. // levelDiagnosticResult and levelStatementResult with 0 items mean the // query returned no rows, which is valid; fall through to the renderer @@ -255,6 +257,8 @@ func (m *Model) View() string { // The activity table is a generic diagnostic-style table — same // renderer as levelStatements and levelDiagnosticResult. b.WriteString(m.renderDiagResult(s, contentHeight)) + case levelLockTree: + b.WriteString(m.renderLockTree(s, contentHeight)) case levelTableStats: // The table overview is a generic diagnostic-style table too. b.WriteString(m.renderDiagResult(s, contentHeight)) diff --git a/internal/tui/view_activity.go b/internal/tui/view_activity.go index 9d2dabd..cb4216c 100644 --- a/internal/tui/view_activity.go +++ b/internal/tui/view_activity.go @@ -183,6 +183,8 @@ func (m *Model) renderActivityInfo(height int) string { b.WriteString(" " + styleHeader.Render(" navigation ") + "\n") b.WriteString(" " + badge("↵") + mu(" drill into top-queries detail for the selected row's query_id (when available)") + "\n") + b.WriteString(" " + badge("b") + mu(" open the blocking-chain lock tree (who is blocking whom)") + "\n") + b.WriteString(" " + badge("d") + mu(" describe the main table of the selected row's query (in that backend's database)") + "\n") b.WriteString(" " + badge("C") + mu(" configure visible columns") + "\n") b.WriteString(" " + badge("←") + mu("/") + badge("→") + mu(" cycle sort column · ") + badge("r") + mu(" reverse sort order") + "\n\n") diff --git a/internal/tui/view_buffers.go b/internal/tui/view_buffers.go index 9d0d05b..dc1bf26 100644 --- a/internal/tui/view_buffers.go +++ b/internal/tui/view_buffers.go @@ -171,12 +171,19 @@ func (m *Model) renderBufferDetail(s *screen, height int) string { pct := hr * 100 hitVal = gradedPercentStyle(pct).Render(fmt.Sprintf("%.1f%%", pct)) } + // Grade the dirty figure by its share of this table's buffered pages: a + // mostly-dirty footprint means checkpoint/bgwriter flush pressure ahead. + dirtyVal := humanize.Bytes(dirtyBytes) + if dirtyBytes > 0 && bufferedBytes > 0 { + pct := float64(dirtyBytes) / float64(bufferedBytes) * 100 + dirtyVal = bloatPercentStyle(int(pct)).Render(fmt.Sprintf("%s (%.1f%% of buffered)", humanize.Bytes(dirtyBytes), pct)) + } rows := [][2]string{ {"buffered", humanize.Bytes(bufferedBytes)}, {"table size", humanize.Bytes(st.TotalBytes)}, {"cached", cachedVal}, {"hit ratio", hitVal}, - {"dirty", humanize.Bytes(dirtyBytes)}, + {"dirty", dirtyVal}, {"avg usage", fmt.Sprintf("%.1f / 5", avgUsage)}, } labelW := 0 diff --git a/internal/tui/view_diag.go b/internal/tui/view_diag.go index a1a228f..fbb0238 100644 --- a/internal/tui/view_diag.go +++ b/internal/tui/view_diag.go @@ -219,8 +219,32 @@ func (m *Model) renderDescribeBufferRows(s *screen) string { } // renderDiagnosticList renders the flat list of available diagnostic queries at -// levelDiagnostics. Layout: cursor | [category] | title | muted description. +// levelDiagnostics. Layout: cursor | category badge | title | muted description, +// under a one-line category-filter hint (f cycles all → index → table → …). func (m *Model) renderDiagnosticList(s *screen, height int) string { + var b strings.Builder + + label := "all" + if s.diagCatFilter != "" { + label = s.diagCatFilter + } + b.WriteString(" " + styleMuted.Render("category: ") + diagCatStyle(label).Render(label) + + styleMuted.Render(" · ") + styleBadge.Render("f") + styleMuted.Render(" cycles") + "\n") + height-- + + catW := 0 + for _, c := range diagCategories() { + if len(c) > catW { + catW = len(c) + } + } + nameW := 0 + for i := range s.items { + if n := displayWidth(s.items[i].name); n > nameW { + nameW = n + } + } + vis := s.visibleIndexes() rowsH := height if rowsH > 0 { @@ -228,21 +252,25 @@ func (m *Model) renderDiagnosticList(s *screen, height int) string { } end := min(s.offset+rowsH, len(vis)) - var b strings.Builder for vi := s.offset; vi < end; vi++ { it := s.items[vis[vi]] selected := vi == s.cursor cursor := " " - name := it.name + name := padRight(it.name, nameW) if selected { cursor = lipgloss.NewStyle().Foreground(colorAccent).Render("▶ ") name = styleSelected.Render(name) } + cat := "" + if d, ok := it.data.(pg.Diagnostic); ok { + cat = d.Category + } + badge := diagCatStyle(cat).Render(padRight(cat, catW)) detail := "" if it.detail != "" { detail = " " + styleMuted.Render(it.detail) } - b.WriteString(cursor + name + detail + "\n") + b.WriteString(cursor + badge + " " + name + detail + "\n") } for i := end - s.offset; i < rowsH; i++ { b.WriteString("\n") @@ -250,6 +278,55 @@ func (m *Model) renderDiagnosticList(s *screen, height int) string { return b.String() } +// renderDiagColumnConfig draws the htop-style column picker for a diagnostic +// result (C on levelDiagnosticResult). Same look-and-feel as the three +// registry-backed pickers, but the rows come from the result's dynamic column +// set and the selection is remembered per diagnostic key. +func (m *Model) renderDiagColumnConfig(s *screen, height int) string { + mu := styleMuted.Render + var b strings.Builder + + b.WriteString("\n") + b.WriteString(" " + styleSelected.Render("configure columns") + mu(" · ") + + styleBadge.Render("space") + mu(" toggles · ") + + styleBadge.Render("↑/↓") + mu(" move · ") + + styleBadge.Render("r") + mu(" reset · ") + + styleBadge.Render("C") + mu(" or ") + styleBadge.Render("esc") + mu(" to close") + "\n") + title := "this diagnostic" + if s.diag != nil { + title = s.diag.Title + } + b.WriteString(" " + mu("choose which columns "+title+" shows (remembered per diagnostic)") + "\n\n") + + res := s.diagResult + if res == nil || s.diag == nil { + return padInfo(&b, height) + } + vis := m.diagVis(s.diag.Key) + nameW := 0 + for _, c := range res.Columns { + if n := len(c.Name); n > nameW { + nameW = n + } + } + for i, c := range res.Columns { + box := "[ ]" + if diagColOn(vis, c.Name) { + box = "[x]" + } + cursor := " " + if i == m.diagColCfgCursor { + cursor = lipgloss.NewStyle().Foreground(colorAccent).Render("▶ ") + } + label := box + " " + padRight(c.Name, nameW) + if i == m.diagColCfgCursor { + label = styleSelected.Render(label) + } + b.WriteString(cursor + label + "\n") + } + return padInfo(&b, height) +} + // diagColWidth is the maximum per-column display width in the result table. // Wide values (long SQL definitions, grants) are truncated with "…" so the // row fits in the terminal. @@ -372,8 +449,12 @@ func (m *Model) renderDiagResult(s *screen, height int) string { barCol := s.diagBarCol // Determine bar column type up front — needed in the colW computation below. - barIsPercent := barCol >= 0 && barCol < nCols && cols[barCol].Kind == pg.DiagPercent - barIsBytes := barCol >= 0 && barCol < nCols && cols[barCol].Kind == pg.DiagBytes + barKind := pg.DiagText + if barCol >= 0 && barCol < nCols { + barKind = cols[barCol].Kind + } + barIsPercent := barKind == pg.DiagPercent || barKind == pg.DiagPercentGraded || barKind == pg.DiagPercentBad + barIsBytes := barKind == pg.DiagBytes // Per-column display widths (capped at diagColWidth), uncapped natural widths, // the bar column's numeric max, and the per-column cost-grade maxima. These @@ -535,7 +616,7 @@ func (m *Model) renderDiagResult(s *screen, height int) string { filled := max(min(int(float64(barW)*cell.Num/scaleMax), barW), 0) style := styleBar if barIsPercent { - style = percentStyle(cell.Num) + style = diagPercentBarStyle(barKind, cell.Num) } barStr = paintBar(barW, barSegment{cells: filled, style: style}) } else { @@ -547,9 +628,9 @@ func (m *Model) renderDiagResult(s *screen, height int) string { numStr = humanize.Bytes(int64(cell.Num)) } // Colour the percentage next to its bar the same way the bar is - // graded (percentStyle), so the digits read at a glance too. + // graded, so the digits read at a glance too. if barIsPercent && cell.HasNum && !selected { - numStr = percentStyle(cell.Num).Render(numStr) + numStr = diagPercentBarStyle(barKind, cell.Num).Render(numStr) } if selected { numStr = styleSelected.Render(numStr) @@ -683,6 +764,20 @@ func (m *Model) renderDiagResult(s *screen, height int) string { return b.String() } +// diagPercentBarStyle picks the colour scale for a percent-typed headline bar: +// plain percents grade by fullness (percentStyle), "higher is better" columns +// green→red (gradedPercentStyle), "higher is worse" columns by the bloat bands. +func diagPercentBarStyle(kind pg.DiagColumnKind, pct float64) lipgloss.Style { + switch kind { + case pg.DiagPercentGraded: + return gradedPercentStyle(pct) + case pg.DiagPercentBad: + return bloatPercentStyle(int(pct)) + default: + return percentStyle(pct) + } +} + // truncateDiagCell clips a cell value to maxW cells, appending "…" when the // value is wider than the cap. func truncateDiagCell(s string, maxW int) string { diff --git a/internal/tui/view_locktree.go b/internal/tui/view_locktree.go new file mode 100644 index 0000000..a761cb0 --- /dev/null +++ b/internal/tui/view_locktree.go @@ -0,0 +1,238 @@ +package tui + +import ( + "fmt" + "slices" + "sort" + "strings" + + "github.com/charmbracelet/lipgloss" + + "pgdu/internal/pg" +) + +// lockTreeRow is one flattened node of the blocking forest: the backend plus its +// indent depth and whether it's a root (blocks others but waits on nobody). +type lockTreeRow struct { + node pg.LockNode + depth int + root bool +} + +// rebuildLockTreeItems assembles the blocking forest from s.lockNodes and +// flattens it into s.items in depth-first order (item.data = lockTreeRow). Roots +// are the backends that block someone but aren't themselves blocked; each node's +// children are the backends it directly blocks. A backend blocked by several +// others appears under each blocker (a lock wait can have multiple holders), so +// a visited set guards against the resulting cycles/duplication runaway while +// still showing every distinct edge once per path. +func (m *Model) rebuildLockTreeItems(s *screen) { + s.items = s.items[:0] + s.itemsRev++ + + byPID := make(map[int32]pg.LockNode, len(s.lockNodes)) + blockedBy := make(map[int32][]int32) // pid → its blockers + blocks := make(map[int32][]int32) // pid → backends it blocks (children) + for _, n := range s.lockNodes { + byPID[n.PID] = n + blockedBy[n.PID] = n.Blockers + for _, b := range n.Blockers { + blocks[b] = append(blocks[b], n.PID) + } + } + + // Roots: nodes that block someone but wait on nobody. A pure blocker may not + // itself be a waiter, so it can be a root even with no blockers of its own. + var roots []int32 + for pid := range byPID { + if len(blockedBy[pid]) == 0 && len(blocks[pid]) > 0 { + roots = append(roots, pid) + } + } + // Deterministic order so the tree doesn't reshuffle every refresh. + slices.Sort(roots) + + var walk func(pid int32, depth int, onPath map[int32]bool) + walk = func(pid int32, depth int, onPath map[int32]bool) { + n, ok := byPID[pid] + if !ok || onPath[pid] { + return + } + onPath[pid] = true + s.items = append(s.items, item{ + name: lockRowFilterText(n), + data: lockTreeRow{node: n, depth: depth, root: depth == 0}, + }) + children := append([]int32(nil), blocks[pid]...) + slices.Sort(children) + for _, c := range children { + walk(c, depth+1, onPath) + } + delete(onPath, pid) + } + for _, r := range roots { + walk(r, 0, map[int32]bool{}) + } + + // Defensive: if the graph is all cycles (no clean root), fall back to listing + // every involved backend flat so nothing vanishes from the view. + if len(s.items) == 0 && len(s.lockNodes) > 0 { + nodes := append([]pg.LockNode(nil), s.lockNodes...) + sort.Slice(nodes, func(i, j int) bool { return nodes[i].PID < nodes[j].PID }) + for _, n := range nodes { + s.items = append(s.items, item{name: lockRowFilterText(n), data: lockTreeRow{node: n}}) + } + } + s.clampCursor() +} + +// lockRowFilterText is the fuzzy-filter target for a lock node: pid, user, +// database and query so / matches any of them. +func lockRowFilterText(n pg.LockNode) string { + return fmt.Sprintf("%d %s %s %s", n.PID, n.Username, n.Database, n.Query) +} + +// lockTreeSelectedPID returns the PID under the cursor, or 0 when the list is +// empty — the target for the k/x cancel/terminate confirm flow. +func lockTreeSelectedPID(s *screen) int32 { + vis := s.visibleIndexes() + if s.cursor < 0 || s.cursor >= len(vis) { + return 0 + } + if r, ok := s.items[vis[s.cursor]].data.(lockTreeRow); ok { + return r.node.PID + } + return 0 +} + +func (m *Model) renderLockTree(s *screen, height int) string { + mu := styleMuted.Render + var b strings.Builder + + if s.lockErr != nil { + b.WriteString(" " + styleErr.Render("error: "+s.lockErr.Error()) + "\n") + return padToHeight(&b, height, 1) + } + + // Header line: count + refresh badge + the k/x action hints. + waiters := 0 + for _, n := range s.lockNodes { + if n.Waiting() { + waiters++ + } + } + refresh := "off" + if m.activityRefresh > 0 { + refresh = m.activityRefresh.String() + } + b.WriteString(" " + styleSelected.Render("blocking chains") + mu(fmt.Sprintf(" · %d backends, %d waiting · ⟳ %s · ", + len(s.lockNodes), waiters, refresh)) + + styleBadge.Render("k") + mu(" cancel · ") + styleBadge.Render("x") + mu(" terminate · ") + + styleBadge.Render("t") + mu(" cadence") + "\n") + used := 1 + + if banner := activityPendingBanner(s); banner != "" { + b.WriteString(banner + "\n") + used++ + } + + if len(s.items) == 0 { + b.WriteString(" " + styleBadge.Render("no lock contention") + mu(" — every backend is running unblocked") + "\n") + return padToHeight(&b, height, used+1) + } + + vis := s.visibleIndexes() + rowsH := max(height-used, 0) + if rowsH > 0 { + s.offset, _ = viewportRange(s.cursor, s.offset, rowsH, len(vis)) + } + end := min(s.offset+rowsH, len(vis)) + for vi := s.offset; vi < end; vi++ { + it := s.items[vis[vi]] + row, _ := it.data.(lockTreeRow) + b.WriteString(renderLockRow(row, vi == s.cursor, m.width) + "\n") + used++ + } + return padToHeight(&b, height, used) +} + +// renderLockRow renders one backend as an indented tree node: pid · user@db · +// state · xact age · the lock it waits on (for non-roots) · query snippet. +func renderLockRow(r lockTreeRow, selected bool, width int) string { + mu := styleMuted.Render + n := r.node + + indent := strings.Repeat(" ", r.depth) + branch := "" + if r.depth > 0 { + branch = mu("└─ ") + } + cursor := " " + if selected { + cursor = lipgloss.NewStyle().Foreground(colorAccent).Render("▶ ") + } + + pidStr := fmt.Sprintf("pid %d", n.PID) + if selected { + pidStr = styleSelected.Render(pidStr) + } else if r.root { + // The root holds the lock everyone waits on — highlight it. + pidStr = styleBarAlt.Render(pidStr) + } + + // State: idle-in-transaction roots are the classic culprit — paint red. + stateStr := n.State + if st, ok := stateStyle(n.State); ok { + stateStr = st.Render(n.State) + } + + var seg []string + seg = append(seg, pidStr) + who := n.Username + if n.Database != "" { + who += "@" + n.Database + } + if who != "" && who != "@" { + seg = append(seg, mu(who)) + } + if stateStr != "" { + seg = append(seg, stateStr) + } + if n.XactAgeMs > 0 { + seg = append(seg, durationStyle(n.XactAgeMs).Render("xact "+fmtAge(n.XactAgeMs))) + } + // What this backend is waiting on (only meaningful for blocked, non-root nodes). + if n.Waiting() && (n.WaitMode != "" || n.WaitRelation != "") { + wait := "waiting" + if n.WaitMode != "" { + wait += " " + n.WaitMode + } + if n.WaitRelation != "" { + wait += " on " + n.WaitRelation + } else if n.WaitLockType != "" { + wait += " on " + n.WaitLockType + } + seg = append(seg, styleErr.Render(wait)) + } + + line := cursor + indent + branch + strings.Join(seg, mu(" · ")) + + // Query snippet on the same line, clipped so the row never wraps. + if n.Query != "" { + q := " " + mu(flattenQuery(n.Query)) + line += q + } + if width > 4 && lipgloss.Width(line) > width { + line = truncateToWidth(line, width) + } + return line +} + +// padToHeight writes blank lines until `used` reaches height, so the help row +// stays pinned to the bottom. Returns the accumulated string. +func padToHeight(b *strings.Builder, height, used int) string { + for i := used; i < height; i++ { + b.WriteString("\n") + } + return b.String() +} diff --git a/internal/tui/view_maintenance.go b/internal/tui/view_maintenance.go index 2bcf20a..20a2d40 100644 --- a/internal/tui/view_maintenance.go +++ b/internal/tui/view_maintenance.go @@ -91,7 +91,10 @@ func (m *Model) renderMaintenance(s *screen, height int) string { if hintLine != "" { full.WriteString(hintLine + "\n") } - full.WriteString(" " + mu("↑↓ select capacity row · ↵ arm reset · space refresh · pgdn/pgup scroll") + "\n") + full.WriteString(" " + mu("↑↓ select capacity row · ↵ settings · ") + + styleBadge.Render("a") + mu(" activity · ") + + styleBadge.Render("w") + mu(" wal · ") + + styleBadge.Render("r") + mu(" replication · space refresh") + "\n") full.WriteString(body.String()) return scrollWindow(full.String(), &s.offset, height) diff --git a/internal/tui/view_parts.go b/internal/tui/view_parts.go index a2b5332..d1be357 100644 --- a/internal/tui/view_parts.go +++ b/internal/tui/view_parts.go @@ -5,6 +5,8 @@ import ( "strings" "time" + "github.com/charmbracelet/lipgloss" + "pgdu/internal/pg" ) @@ -172,6 +174,20 @@ func (m *Model) maintReindexHint(s *screen) string { styleBadge.Render("enter") + mu(" to REINDEX it CONCURRENTLY") } +// maintAgeStr renders a "last " fragment with the age graded by how stale +// it is: under a week stays muted (routine), over a week yellow, over a month +// red — the same instinct a DBA applies reading last_autovacuum by hand. +func maintAgeStr(age time.Duration) string { + s := relativeAge(age) + switch { + case age >= 30*24*time.Hour: + return styleBloat.Render(s) + case age >= 7*24*time.Hour: + return styleBarAlt.Render(s) + } + return styleMuted.Render(s) +} + func (m *Model) maintVacuumLine(st *pg.TableMaintStats) string { mu := styleMuted.Render var parts []string @@ -179,11 +195,11 @@ func (m *Model) maintVacuumLine(st *pg.TableMaintStats) string { // Last vacuum with source tag. switch { case st.LastVacuum != nil && (st.LastAutovacuum == nil || st.LastVacuum.After(*st.LastAutovacuum)): - parts = append(parts, mu("last ")+relativeAge(time.Since(*st.LastVacuum))+mu(" (manual)")) + parts = append(parts, mu("last ")+maintAgeStr(time.Since(*st.LastVacuum))+mu(" (manual)")) case st.LastAutovacuum != nil: - parts = append(parts, mu("last ")+relativeAge(time.Since(*st.LastAutovacuum))+mu(" (auto)")) + parts = append(parts, mu("last ")+maintAgeStr(time.Since(*st.LastAutovacuum))+mu(" (auto)")) default: - parts = append(parts, mu("never vacuumed")) + parts = append(parts, styleBarAlt.Render("never vacuumed")) } // Insert-based trigger progress. @@ -205,11 +221,11 @@ func (m *Model) maintAnalyzeLine(st *pg.TableMaintStats) string { switch { case st.LastAnalyze != nil && (st.LastAutoanalyze == nil || st.LastAnalyze.After(*st.LastAutoanalyze)): - parts = append(parts, mu("last ")+relativeAge(time.Since(*st.LastAnalyze))+mu(" (manual)")) + parts = append(parts, mu("last ")+maintAgeStr(time.Since(*st.LastAnalyze))+mu(" (manual)")) case st.LastAutoanalyze != nil: - parts = append(parts, mu("last ")+relativeAge(time.Since(*st.LastAutoanalyze))+mu(" (auto)")) + parts = append(parts, mu("last ")+maintAgeStr(time.Since(*st.LastAutoanalyze))+mu(" (auto)")) default: - parts = append(parts, mu("never analyzed")) + parts = append(parts, styleBarAlt.Render("never analyzed")) } if st.NModSinceAnalyze > 0 { @@ -233,9 +249,16 @@ func (m *Model) maintTuplesLine(st *pg.TableMaintStats) string { deadPct := int(float64(st.NDead) / float64(total) * 100) parts = append(parts, mu(formatRows(st.NLive)+" live · "+formatRows(st.NDead)+" dead")) if deadPct > 0 { + // Grade dead share by absolute band: past the default autovacuum + // scale factor (20%) is a real problem, double that is red. s := mu(fmt.Sprintf("(%d%%)", deadPct)) - if deadPct >= 10 { - s = styleBarAlt.Render(fmt.Sprintf("(%d%%)", deadPct)) + switch { + case deadPct >= 40: + s = styleBloat.Render(fmt.Sprintf("(%d%% dead)", deadPct)) + case deadPct >= 20: + s = styleBarAlt.Render(fmt.Sprintf("(%d%% dead)", deadPct)) + case deadPct >= 10: + s = lipgloss.NewStyle().Foreground(colorCostLow).Render(fmt.Sprintf("(%d%%)", deadPct)) } parts = append(parts, s) } diff --git a/internal/tui/view_shmem.go b/internal/tui/view_shmem.go index 19ef352..451867d 100644 --- a/internal/tui/view_shmem.go +++ b/internal/tui/view_shmem.go @@ -195,7 +195,16 @@ func (m *Model) renderShmemSummary(s *screen) string { } stats.WriteString(muted("░ free " + humanize.Bytes(totals[catFree]))) - return summaryRow("shmem", bar) + "\n" + summaryStats(stats.String()) + // One-line orientation: how much of the whole segment the buffer pool is — + // the usual first question when reading pg_shmem_allocations. + poolLine := "" + if totals[catBuffer] > 0 { + pct := float64(totals[catBuffer]) * 100 / float64(grand) + poolLine = "\n" + summaryStats(muted(fmt.Sprintf("buffer pool %s = %.1f%% of the segment; the rest is WAL, locks, per-backend and bookkeeping structures", + humanize.Bytes(totals[catBuffer]), pct))) + } + + return summaryRow("shmem", bar) + "\n" + summaryStats(stats.String()) + poolLine } // Column widths for the shared-memory map list; kept beside the header/row diff --git a/internal/tui/view_wal.go b/internal/tui/view_wal.go index c017b5d..6334baf 100644 --- a/internal/tui/view_wal.go +++ b/internal/tui/view_wal.go @@ -35,9 +35,9 @@ const ( // Column widths for the WAL by-relation view (levelWALRelations). const ( walRelCombinedColW = 11 - walRelFPIColW = 11 - walRelRecColW = 9 // record count - walRelBlkColW = 9 // distinct pages touched + walRelFPIColW = 17 // "1023.99 MB (99%)" — fpi bytes plus its graded share + walRelRecColW = 9 // record count + walRelBlkColW = 9 // distinct pages touched ) // renderWALBar paints one row's WAL bytes as record-data | FPI, scaled to the @@ -522,7 +522,14 @@ func renderWALRelRow(it item, st pg.WALRelStat, maxSize int64, barW int, selecte bar := renderWALBar(st.DataBytes, st.FPIBytes, maxSize, barW) fpiStr := styleMuted.Render("—") if st.FPIBytes > 0 { + // Grade the FPI share of this relation's WAL: full-page images are + // checkpoint write amplification, so a high share flags a hot relation + // worth a longer checkpoint interval or a fillfactor tweak. fpiStr = styleBarAlt.Render(humanize.Bytes(st.FPIBytes)) + if combined := st.CombinedSize(); combined > 0 { + pct := int(float64(st.FPIBytes) / float64(combined) * 100) + fpiStr = bloatPercentStyle(pct).Render(fmt.Sprintf("%s (%d%%)", humanize.Bytes(st.FPIBytes), pct)) + } } childMark := " " if it.hasChildren { From 285c477cc9ab8ff3b9270d953d4cac1ac25cb2fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20D=C3=B6tsch?= Date: Sat, 4 Jul 2026 12:03:21 +0200 Subject: [PATCH 3/6] tui: live progress monitor + REINDEX progress bar Add a live pg_stat_progress_* monitor (levelProgress) under the maintenance dashboard, opened with 'p', auto-refreshing on the activity tick with a per-operation progress bar, done/total counters, phase, age and user. The existing progress_all diagnostic keeps its point-in-time output; both now share sqlProgressBase. Reuse the same progress data for the disk-usage tool's inline REINDEX: while REINDEX INDEX CONCURRENTLY runs, poll pg_stat_progress_create_index and render a phase label + percentage bar in the banner instead of a bare spinner. The query picks the blocks counter during the scan phase and the tuples counter during validation so the bar stays meaningful across phases. Also fill in the missing levelLabel cases for levelProgress and levelLockTree (both previously rendered 'level: ?'). --- docs/future/progress-monitor.md | 9 +- internal/pg/progress.go | 27 +++++ internal/pg/progress_scenario_test.go | 83 +++++++++++++++ internal/pg/queries_diag.go | 50 +++++++-- internal/pg/reindex.go | 40 +++++++ internal/pg/reindex_progress_test.go | 37 +++++++ internal/pg/types_progress.go | 26 +++++ internal/tui/app.go | 11 ++ internal/tui/cmds.go | 29 ++++++ internal/tui/cmds_maintenance.go | 13 +++ internal/tui/format.go | 4 + internal/tui/keys.go | 9 +- internal/tui/layout.go | 7 ++ internal/tui/layout_test.go | 4 +- internal/tui/update.go | 6 ++ internal/tui/update_keys.go | 30 +++++- internal/tui/update_load.go | 10 ++ internal/tui/update_msgs.go | 52 +++++++++- internal/tui/view.go | 5 +- internal/tui/view_maintenance.go | 3 +- internal/tui/view_overlays.go | 18 +++- internal/tui/view_progress.go | 143 ++++++++++++++++++++++++++ internal/tui/view_progress_test.go | 77 ++++++++++++++ 23 files changed, 664 insertions(+), 29 deletions(-) create mode 100644 internal/pg/progress.go create mode 100644 internal/pg/progress_scenario_test.go create mode 100644 internal/pg/reindex_progress_test.go create mode 100644 internal/pg/types_progress.go create mode 100644 internal/tui/view_progress.go create mode 100644 internal/tui/view_progress_test.go diff --git a/docs/future/progress-monitor.md b/docs/future/progress-monitor.md index 731796d..2bce8e8 100644 --- a/docs/future/progress-monitor.md +++ b/docs/future/progress-monitor.md @@ -1,9 +1,10 @@ # Future: unified live progress monitor -Status: design only (not implemented). Precursor shipped: the `progress_all` -diagnostic (`internal/pg/queries_diag.go`, registered in `diagnostic_defs.go`) -already UNIONs every `pg_stat_progress_*` view into one table you can run today -from **Other Tools → Running operations (progress)**. +Status: implemented — `levelProgress` (`internal/tui/view_progress.go`), opened +with `p` from the maintenance dashboard, live-refreshing on the activity tick. +The `progress_all` diagnostic (`internal/pg/queries_diag.go`, registered in +`diagnostic_defs.go`) remains as the point-in-time variant under +**Other Tools → Running operations (progress)**; both share `sqlProgressBase`. ## Motivation diff --git a/internal/pg/progress.go b/internal/pg/progress.go new file mode 100644 index 0000000..7536e73 --- /dev/null +++ b/internal/pg/progress.go @@ -0,0 +1,27 @@ +package pg + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" +) + +// ListProgress returns every operation currently reporting into a +// pg_stat_progress_* view. The views are cluster-wide, so db only selects the +// pool to query through; an empty result means nothing is in flight. +func (c *Client) ListProgress(ctx context.Context, db string) ([]ProgressRow, error) { + pool, err := c.PoolFor(ctx, db) + if err != nil { + return nil, fmt.Errorf("list progress in %q: %w", db, err) + } + return collect(ctx, pool, fmt.Sprintf("list progress in %q", db), sqlProgressOps, nil, + func(row pgx.CollectableRow) (ProgressRow, error) { + var r ProgressRow + err := row.Scan( + &r.PID, &r.Command, &r.Relation, &r.Phase, &r.Unit, + &r.Done, &r.Total, &r.RunningMs, &r.Username, + ) + return r, err + }) +} diff --git a/internal/pg/progress_scenario_test.go b/internal/pg/progress_scenario_test.go new file mode 100644 index 0000000..bb0f40e --- /dev/null +++ b/internal/pg/progress_scenario_test.go @@ -0,0 +1,83 @@ +package pg + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5" +) + +// A real CREATE INDEX must surface in ListProgress as a block-counted +// operation. The build can outrun the poll loop on fast machines, so a run +// that never catches it skips rather than fails. +func TestIntegration_Progress(t *testing.T) { + c, db := diagTestClient(t) + ctx := context.Background() + + // Idle smoke check first: the query itself must always succeed. + if _, err := c.ListProgress(ctx, db); err != nil { + t.Fatalf("ListProgress: %v", err) + } + + pool, err := c.PoolFor(ctx, db) + if err != nil { + t.Fatalf("pool: %v", err) + } + _, _ = pool.Exec(ctx, `DROP TABLE IF EXISTS pgdu_progtest`) + if _, err := pool.Exec(ctx, + `CREATE TABLE pgdu_progtest AS SELECT g AS id, md5(g::text) AS v FROM generate_series(1, 2000000) g`); err != nil { + t.Fatalf("create: %v", err) + } + t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DROP TABLE IF EXISTS pgdu_progtest`) }) + + builder, err := pgx.Connect(ctx, os.Getenv("PGDU_TEST_DSN")) + if err != nil { + t.Fatalf("builder connect: %v", err) + } + defer func() { _ = builder.Close(context.Background()) }() + done := make(chan error, 1) + go func() { + _, err := builder.Exec(context.Background(), `CREATE INDEX pgdu_progtest_idx ON pgdu_progtest (v)`) + done <- err + }() + + // Poll for the build to show up in pg_stat_progress_create_index. + var got *ProgressRow +poll: + for range 40 { + select { + case err := <-done: + if err != nil { + t.Fatalf("create index: %v", err) + } + break poll + default: + } + rows, err := c.ListProgress(ctx, db) + if err != nil { + t.Fatalf("ListProgress: %v", err) + } + for i, r := range rows { + if r.Command == "CREATE INDEX" { + got = &rows[i] + break poll + } + } + time.Sleep(50 * time.Millisecond) + } + + if got == nil { + t.Skip("index build finished before a progress sample landed") + } + if got.Unit != "blocks" { + t.Errorf("unit = %q, want \"blocks\"", got.Unit) + } + if got.PID == 0 { + t.Errorf("missing pid: %+v", got) + } + if got.Phase == "" { + t.Errorf("missing phase: %+v", got) + } +} diff --git a/internal/pg/queries_diag.go b/internal/pg/queries_diag.go index 3c9c2db..c7466f1 100644 --- a/internal/pg/queries_diag.go +++ b/internal/pg/queries_diag.go @@ -878,31 +878,38 @@ WHERE n_mod_since_analyze > 0 ORDER BY stale_pct DESC NULLS LAST ` -// sqlDiagProgressAll unifies every pg_stat_progress_* view into one normalized -// live-operations table: what long-running maintenance/DDL is in flight and how -// far along it is. done/total pick each command's most representative counter -// (blocks for vacuum/index/analyze/cluster, bytes for COPY and base backups). -const sqlDiagProgressAll = ` +// sqlProgressBase unifies every pg_stat_progress_* view into one normalized +// live-operations CTE: what long-running maintenance/DDL is in flight and how +// far along it is. done/total pick each command's most representative counter, +// and unit records what they count (blocks for vacuum/index/analyze/cluster, +// bytes for COPY and base backups) so callers can format them. +const sqlProgressBase = ` WITH prog AS ( SELECT pid, 'VACUUM' AS command, relid, phase, - heap_blks_scanned::numeric AS done, heap_blks_total::numeric AS total + heap_blks_scanned::numeric AS done, heap_blks_total::numeric AS total, + 'blocks'::text AS unit FROM pg_stat_progress_vacuum UNION ALL - SELECT pid, 'CREATE INDEX', relid, phase, blocks_done::numeric, blocks_total::numeric + SELECT pid, 'CREATE INDEX', relid, phase, blocks_done::numeric, blocks_total::numeric, 'blocks' FROM pg_stat_progress_create_index UNION ALL - SELECT pid, 'ANALYZE', relid, phase, sample_blks_scanned::numeric, sample_blks_total::numeric + SELECT pid, 'ANALYZE', relid, phase, sample_blks_scanned::numeric, sample_blks_total::numeric, 'blocks' FROM pg_stat_progress_analyze UNION ALL - SELECT pid, 'CLUSTER', relid, phase, heap_blks_scanned::numeric, heap_blks_total::numeric + SELECT pid, 'CLUSTER', relid, phase, heap_blks_scanned::numeric, heap_blks_total::numeric, 'blocks' FROM pg_stat_progress_cluster UNION ALL - SELECT pid, 'COPY', relid, ''::text, bytes_processed::numeric, bytes_total::numeric + SELECT pid, 'COPY', relid, ''::text, bytes_processed::numeric, bytes_total::numeric, 'bytes' FROM pg_stat_progress_copy UNION ALL - SELECT pid, 'BASE BACKUP', NULL::oid, phase, backup_streamed::numeric, backup_total::numeric + SELECT pid, 'BASE BACKUP', NULL::oid, phase, backup_streamed::numeric, backup_total::numeric, 'bytes' FROM pg_stat_progress_basebackup ) +` + +// sqlDiagProgressAll is the point-in-time diagnostic over sqlProgressBase; the +// live progress monitor uses sqlProgressOps for the same rows with raw counters. +const sqlDiagProgressAll = sqlProgressBase + ` SELECT p.pid, p.command, @@ -916,6 +923,27 @@ LEFT JOIN pg_stat_activity a USING (pid) ORDER BY done_pct DESC NULLS LAST ` +// sqlProgressOps feeds the live progress monitor: same operations as the +// diagnostic but with raw done/total counters (so the bar can show +// blocks-done/blocks-total) and running_ms in the activity view's float8 +// epoch-ms convention. The pid tiebreak keeps equal-pct rows from swapping +// places between refresh ticks. +const sqlProgressOps = sqlProgressBase + ` +SELECT + p.pid, + p.command, + CASE WHEN p.relid IS NOT NULL AND p.relid <> 0 THEN p.relid::regclass::text ELSE '' END AS relation, + p.phase, + p.unit, + coalesce(p.done, 0)::bigint AS done, + coalesce(p.total, 0)::bigint AS total, + coalesce(EXTRACT(epoch FROM now() - a.xact_start) * 1000, 0)::float8 AS running_ms, + coalesce(a.usename::text, '') AS username +FROM prog p +LEFT JOIN pg_stat_activity a USING (pid) +ORDER BY p.done / NULLIF(p.total, 0) DESC NULLS LAST, p.pid +` + // sqlDiagLockSummary aggregates pg_locks by lock type and mode: how many locks // are out, how many backends hold them, and whether anyone is waiting — the // one-glance contention read before drilling into per-backend detail. diff --git a/internal/pg/reindex.go b/internal/pg/reindex.go index 8648058..20cc98f 100644 --- a/internal/pg/reindex.go +++ b/internal/pg/reindex.go @@ -2,7 +2,10 @@ package pg import ( "context" + "errors" "fmt" + + "github.com/jackc/pgx/v5" ) // ReindexIndex runs REINDEX INDEX CONCURRENTLY on the named index. The index @@ -19,3 +22,40 @@ func (c *Client) ReindexIndex(ctx context.Context, t Table, indexName string) er } return nil } + +// sqlReindexProgress reads pg_stat_progress_create_index for the (single) +// build in flight on tableOID. REINDEX reports into the same view as CREATE +// INDEX, keyed by the table's relid. The scan phase counts in blocks and the +// validation phase in tuples, so pick whichever counter has a live total — +// that keeps the bar meaningful across the phase change instead of snapping +// back to an empty blocks total mid-build. +const sqlReindexProgress = ` +SELECT + phase, + (CASE WHEN blocks_total > 0 THEN blocks_done ELSE tuples_done END)::bigint AS done, + (CASE WHEN blocks_total > 0 THEN blocks_total ELSE tuples_total END)::bigint AS total +FROM pg_stat_progress_create_index +WHERE relid = $1 +ORDER BY pid +LIMIT 1 +` + +// ReindexProgress returns the live progress of a REINDEX/CREATE INDEX running +// on tableOID, or ok=false when no build is currently reporting (not started +// yet, between phases, or already finished). Best-effort: callers poll it and +// tolerate a missing row. +func (c *Client) ReindexProgress(ctx context.Context, db string, tableOID uint32) (ProgressRow, bool, error) { + pool, err := c.PoolFor(ctx, db) + if err != nil { + return ProgressRow{}, false, fmt.Errorf("reindex progress in %q: %w", db, err) + } + r := ProgressRow{Unit: "blocks"} + err = pool.QueryRow(ctx, sqlReindexProgress, tableOID).Scan(&r.Phase, &r.Done, &r.Total) + if errors.Is(err, pgx.ErrNoRows) { + return ProgressRow{}, false, nil + } + if err != nil { + return ProgressRow{}, false, fmt.Errorf("reindex progress in %q: %w", db, err) + } + return r, true, nil +} diff --git a/internal/pg/reindex_progress_test.go b/internal/pg/reindex_progress_test.go new file mode 100644 index 0000000..9bcae30 --- /dev/null +++ b/internal/pg/reindex_progress_test.go @@ -0,0 +1,37 @@ +package pg + +import ( + "context" + "testing" +) + +// ReindexProgress must run cleanly and report "nothing in flight" for a table +// with no build in progress — the idle path the poller hits between and after +// rebuilds. The live-during-REINDEX path is exercised by hand (view_overlays +// banner); here we pin the query parses and the no-rows contract. +func TestIntegration_ReindexProgressIdle(t *testing.T) { + c, db := diagTestClient(t) + ctx := context.Background() + pool, err := c.PoolFor(ctx, db) + if err != nil { + t.Fatalf("pool: %v", err) + } + _, _ = pool.Exec(ctx, `DROP TABLE IF EXISTS pgdu_reindex_idle`) + if _, err := pool.Exec(ctx, `CREATE TABLE pgdu_reindex_idle (id int PRIMARY KEY)`); err != nil { + t.Fatalf("create: %v", err) + } + t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DROP TABLE IF EXISTS pgdu_reindex_idle`) }) + + var oid uint32 + if err := pool.QueryRow(ctx, `SELECT 'pgdu_reindex_idle'::regclass::oid`).Scan(&oid); err != nil { + t.Fatalf("oid: %v", err) + } + + row, ok, err := c.ReindexProgress(ctx, db, oid) + if err != nil { + t.Fatalf("ReindexProgress: %v", err) + } + if ok { + t.Errorf("expected no build in flight, got %+v", row) + } +} diff --git a/internal/pg/types_progress.go b/internal/pg/types_progress.go new file mode 100644 index 0000000..2dcf908 --- /dev/null +++ b/internal/pg/types_progress.go @@ -0,0 +1,26 @@ +package pg + +// ProgressRow is one running operation from the unified pg_stat_progress_* +// query (sqlProgressOps): a maintenance/DDL command, its target relation, the +// current phase, and raw done/total counters whose Unit says what they count +// ("blocks" or "bytes"). +type ProgressRow struct { + PID int32 + Command string + Relation string // empty when the view has no relid (e.g. base backup) + Phase string + Unit string // "blocks" or "bytes" + Done int64 + Total int64 + RunningMs float64 + Username string +} + +// Pct returns completion as 0..100, or -1 when the total is unknown (some +// views report total 0 until they have an estimate). +func (r ProgressRow) Pct() float64 { + if r.Total <= 0 { + return -1 + } + return 100 * float64(r.Done) / float64(r.Total) +} diff --git a/internal/tui/app.go b/internal/tui/app.go index 690ef36..4336e68 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -48,6 +48,7 @@ const ( levelActivity // live server activity from pg_stat_activity (toolActivity) levelLockTree // blocking-chain forest from pg_locks (child of levelActivity) levelTableStats // per-table statistics overview for one schema (toolTableStats) + levelProgress // live pg_stat_progress_* monitor (child of levelMaintenance) ) // tool identifies which top-level statistic the user is exploring. @@ -233,6 +234,10 @@ type screen struct { pendingReindex string // reindexing is the index currently being rebuilt (empty when idle). reindexing string + // reindexProg is the last-polled live progress of the running REINDEX from + // pg_stat_progress_create_index; nil until the first sample lands (or + // between phases where the view reports no counters). + reindexProg *pg.ProgressRow // reindexErr is the last REINDEX failure, shown until the next attempt. reindexErr error @@ -479,6 +484,12 @@ type screen struct { // settingRows is the full pg_settings list for levelSettings. settingRows []pg.SettingRow + // ── Progress monitor (levelProgress) ────────────────────────────────────── + // progressRows is the last fetched set of running operations from the + // pg_stat_progress_* views; progressErr is non-nil when the load failed. + progressRows []pg.ProgressRow + progressErr error + // ── Table maintenance panel (levelParts) ────────────────────────────────── // tableStats is the maintenance snapshot for the current table, loaded // asynchronously alongside the parts list. diff --git a/internal/tui/cmds.go b/internal/tui/cmds.go index bca5a4c..59e0cd9 100644 --- a/internal/tui/cmds.go +++ b/internal/tui/cmds.go @@ -98,6 +98,16 @@ type reindexDoneMsg struct { indexName string err error } + +// reindexTickMsg drives the progress poll while a REINDEX is in flight. +type reindexTickMsg struct{} + +// reindexProgressMsg carries one poll of pg_stat_progress_create_index for the +// reindexing table. row is nil when nothing is reporting (yet / any more). +type reindexProgressMsg struct { + tableOID uint32 + row *pg.ProgressRow +} type heapPagesLoadedMsg struct { table pg.Table start int32 @@ -598,6 +608,25 @@ func (m *Model) reindexIndexCmd(t pg.Table, indexName string) tea.Cmd { } } +// reindexProgressInterval is how often the REINDEX banner re-polls +// pg_stat_progress_create_index — slow enough not to load the server, fast +// enough that the bar visibly moves on a multi-minute rebuild. +const reindexProgressInterval = 500 * time.Millisecond + +func (m *Model) reindexTick() tea.Cmd { + return tea.Tick(reindexProgressInterval, func(time.Time) tea.Msg { return reindexTickMsg{} }) +} + +func (m *Model) loadReindexProgressCmd(db string, tableOID uint32) tea.Cmd { + return query(func(ctx context.Context) tea.Msg { + r, ok, err := m.client.ReindexProgress(ctx, db, tableOID) + if err != nil || !ok { + return reindexProgressMsg{tableOID: tableOID, row: nil} + } + return reindexProgressMsg{tableOID: tableOID, row: &r} + }) +} + func (m *Model) loadDiagnosticCmd(d pg.Diagnostic, db string) tea.Cmd { return query(func(ctx context.Context) tea.Msg { result, err := m.client.RunDiagnostic(ctx, db, d) diff --git a/internal/tui/cmds_maintenance.go b/internal/tui/cmds_maintenance.go index 3f8457e..d82909c 100644 --- a/internal/tui/cmds_maintenance.go +++ b/internal/tui/cmds_maintenance.go @@ -22,6 +22,12 @@ type settingsLoadedMsg struct { err error } +type progressLoadedMsg struct { + db string + rows []pg.ProgressRow + err error +} + type maintResetDoneMsg struct { which string // "statements", "qualstats", or "tablestats" err error @@ -65,6 +71,13 @@ func (m *Model) loadSettingsCmd(db string) tea.Cmd { }) } +func (m *Model) loadProgressCmd(db string) tea.Cmd { + return query(func(ctx context.Context) tea.Msg { + rows, err := m.client.ListProgress(ctx, db) + return progressLoadedMsg{db: db, rows: rows, err: err} + }) +} + func (m *Model) resetStatementsCmd(db string) tea.Cmd { return query(func(ctx context.Context) tea.Msg { err := m.client.ResetStatements(ctx, db) diff --git a/internal/tui/format.go b/internal/tui/format.go index e7173ef..1d54a08 100644 --- a/internal/tui/format.go +++ b/internal/tui/format.go @@ -132,12 +132,16 @@ func levelLabel(l level) string { return "snapshots" case levelActivity: return "activity" + case levelLockTree: + return "lock-tree" case levelTableStats: return "table overview" case levelMaintenance: return "system overview" case levelSettings: return "settings" + case levelProgress: + return "progress" } return "?" } diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 4667dd7..69d4466 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -51,6 +51,7 @@ type keyMap struct { JumpActivity key.Binding // a: open the Activity tool JumpWAL key.Binding // w: open the WAL inspector JumpReplication key.Binding // r: open the replication-slots diagnostic + Progress key.Binding // p: open the live progress monitor // shmemInFooter adds the m (memory map) hint to the footer's short help on // the buffer-tables level, where it's the only advertisement for the view. @@ -109,6 +110,7 @@ func defaultKeys() keyMap { JumpActivity: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "activity")), JumpWAL: key.NewBinding(key.WithKeys("w"), key.WithHelp("w", "wal")), JumpReplication: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "replication")), + Progress: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "progress")), } } @@ -142,8 +144,8 @@ func (k *keyMap) applyContext(s *screen) { k.Columns.SetEnabled(stmtTable || activity || tableStats || diagResult) k.columnsInFooter = activity || tableStats || diagResult // t (ToggleRefresh) cycles the auto-refresh cadence on top-queries levels and - // on the activity level. - k.ToggleRefresh.SetEnabled(stmtTable || stmtDetail || activity) + // on the live activity/progress levels. + k.ToggleRefresh.SetEnabled(stmtTable || stmtDetail || activity || s.level == levelProgress) k.SaveSnapshot.SetEnabled(stmtTable || stmtDetail) k.DiskUsage.SetEnabled(stmtTable || stmtDetail) k.Params.SetEnabled(stmtDetail) @@ -181,6 +183,9 @@ func (k *keyMap) applyContext(s *screen) { k.JumpActivity.SetEnabled(maint) k.JumpWAL.SetEnabled(maint) k.JumpReplication.SetEnabled(maint) + // p opens the live progress monitor; gated to the dashboard so the physical + // key stays free for Params (captured values) on statement detail. + k.Progress.SetEnabled(maint) // s seeks on the index-tuples view: a key value on B-tree, a heap block // number on BRIN. GiST/GIN keys have no total order, so seek is disabled diff --git a/internal/tui/layout.go b/internal/tui/layout.go index d11162a..0b75a3b 100644 --- a/internal/tui/layout.go +++ b/internal/tui/layout.go @@ -149,6 +149,13 @@ func barReserve(s *screen) int { // cursor + bar(brackets) + fpi + data + name + detail return colCursor + colBrackets + walBlkFPIColW + colGutter + walBlkDataColW + colGutter + colName + colDetail + case levelProgress: + // cursor + bar(brackets) + command + relation + phase + done/total + + // pct + age + user + return colCursor + colBrackets + progColCmd + colGutter + + colName + progColPhase + + progColDoneTotal + colGutter + + progColPct + colGutter + progColAge + progColUser case levelWALRelations: // cursor + bar(brackets) + combined + fpi + records + pages + mark + name return colCursor + colBrackets + walRelCombinedColW + colGutter + diff --git a/internal/tui/layout_test.go b/internal/tui/layout_test.go index 8046517..94bb61b 100644 --- a/internal/tui/layout_test.go +++ b/internal/tui/layout_test.go @@ -27,8 +27,8 @@ func TestBarWidth(t *testing.T) { // sums (those live next to their renderers). func TestBarReserveSane(t *testing.T) { ams := []string{"btree", "gist", "brin", "gin"} - // levelTableStats is the last enum value; extend here if a level is added after it. - for l := levelTools; l <= levelTableStats; l++ { + // levelProgress is the last enum value; extend here if a level is added after it. + for l := levelTools; l <= levelProgress; l++ { for _, tl := range []tool{toolDisk, toolPageInspect} { for _, am := range ams { s := &screen{level: l, tool: tl} diff --git a/internal/tui/update.go b/internal/tui/update.go index 94d82ae..778ec00 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -53,6 +53,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.onExtInstalled(msg) case reindexDoneMsg: return m, m.onReindexDone(msg) + case reindexTickMsg: + return m, m.onReindexTick() + case reindexProgressMsg: + return m, m.onReindexProgress(msg) case heapPagesLoadedMsg: return m, m.onHeapPagesLoaded(msg) case heapTuplesLoadedMsg: @@ -128,6 +132,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.onMaintLoaded(msg) case settingsLoadedMsg: return m, m.onSettingsLoaded(msg) + case progressLoadedMsg: + return m, m.onProgressLoaded(msg) case maintResetDoneMsg: return m, m.onMaintResetDone(msg) case tableStatsLoadedMsg: diff --git a/internal/tui/update_keys.go b/internal/tui/update_keys.go index 4512e6a..72ce9b4 100644 --- a/internal/tui/update_keys.go +++ b/internal/tui/update_keys.go @@ -88,8 +88,11 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { idx := s.pendingReindex s.pendingReindex = "" s.reindexing = idx + s.reindexProg = nil s.reindexErr = nil - return m, m.reindexIndexCmd(s.table, idx) + // Run the (blocking) REINDEX and, alongside it, start polling + // pg_stat_progress_create_index so the banner shows a live bar. + return m, tea.Batch(m.reindexIndexCmd(s.table, idx), m.reindexTick()) } s.pendingReindex = "" return m, nil @@ -307,6 +310,15 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.loadCurrent() } } + case key.Matches(msg, m.keys.Progress): + // Open the live progress monitor over the pg_stat_progress_* views. + // Enabled only on levelMaintenance, so it never shadows Params. + next := &screen{ + level: levelProgress, title: "progress", tool: toolMaintenance, + db: s.db, loading: true, + } + m.stack = append(m.stack, next) + return m, m.loadCurrent() case key.Matches(msg, m.keys.SortNext): m.cycleSort(s, +1) case key.Matches(msg, m.keys.SortPrev): @@ -437,7 +449,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } - if s.level == levelActivity { + if s.level == levelActivity || s.level == levelProgress { m.cycleActivityRefresh() if m.activityRefresh > 0 && !m.activityTicking { if tick := m.activityTick(); tick != nil { @@ -684,6 +696,20 @@ func describeTarget(s *screen) (descTarget, bool) { } return descTarget{byName: true, db: s.db, tableName: name}, true + case levelProgress: + // Describe the operation's target relation by name (basebackup rows have + // none). The name comes from regclass in the operation's own database, so + // resolve it against the screen's db like the other by-name paths. + it, ok := curItem() + if !ok { + return descTarget{}, false + } + r, ok := it.data.(pg.ProgressRow) + if !ok || r.Relation == "" { + return descTarget{}, false + } + return descTarget{byName: true, db: s.db, tableName: r.Relation}, true + case levelTables: it, ok := curItem() if !ok { diff --git a/internal/tui/update_load.go b/internal/tui/update_load.go index 1168088..e4c560b 100644 --- a/internal/tui/update_load.go +++ b/internal/tui/update_load.go @@ -235,6 +235,16 @@ func (m *Model) loadCurrent() tea.Cmd { return tea.Batch(cmds...) case levelTableStats: return m.loadTableOverviewCmd(s.db, s.schema) + case levelProgress: + // Same live-refresh pattern as the activity table, reusing its tick loop. + cmds := []tea.Cmd{m.loadProgressCmd(s.db)} + if !m.activityTicking { + if tick := m.activityTick(); tick != nil { + m.activityTicking = true + cmds = append(cmds, tick) + } + } + return tea.Batch(cmds...) } return nil } diff --git a/internal/tui/update_msgs.go b/internal/tui/update_msgs.go index 21331bd..e868cac 100644 --- a/internal/tui/update_msgs.go +++ b/internal/tui/update_msgs.go @@ -363,7 +363,9 @@ func (m *Model) onReindexDone(msg reindexDoneMsg) tea.Cmd { if s == nil || s.table.OID != msg.tableOID { return nil } + // Clearing reindexing stops the progress-poll tick on its next fire. s.reindexing = "" + s.reindexProg = nil if msg.err != nil { s.reindexErr = msg.err return nil @@ -373,6 +375,26 @@ func (m *Model) onReindexDone(msg reindexDoneMsg) tea.Cmd { return m.loadCurrent() } +// onReindexTick re-polls the progress view while a REINDEX is in flight and +// reschedules itself; it stops (returns nil) once reindexing clears, so no +// stray tick outlives the rebuild. +func (m *Model) onReindexTick() tea.Cmd { + s := m.findLevel(levelParts) + if s == nil || s.reindexing == "" { + return nil + } + return tea.Batch(m.loadReindexProgressCmd(s.db, s.table.OID), m.reindexTick()) +} + +func (m *Model) onReindexProgress(msg reindexProgressMsg) tea.Cmd { + s := m.findLevel(levelParts) + if s == nil || s.table.OID != msg.tableOID || s.reindexing == "" { + return nil + } + s.reindexProg = msg.row + return nil +} + func (m *Model) onDiagnosticLoaded(msg diagnosticLoadedMsg) tea.Cmd { s := m.findLevel(levelDiagnosticResult) if s == nil || s.diag == nil || s.diag.Key != msg.key { @@ -561,11 +583,11 @@ func (m *Model) onTableOverviewLoaded(msg tableOverviewLoadedMsg) tea.Cmd { } func (m *Model) onActivityTick() tea.Cmd { - // Keep the tick alive while the user is in the activity table or its - // lock-tree child. Stop when they navigate fully away so re-entry can start a - // fresh loop. + // Keep the tick alive while the user is in the activity table, its + // lock-tree child, or the progress monitor. Stop when they navigate fully + // away so re-entry can start a fresh loop. top := m.top() - if top.level != levelActivity && top.level != levelLockTree { + if top.level != levelActivity && top.level != levelLockTree && top.level != levelProgress { m.activityTicking = false return nil } @@ -575,8 +597,11 @@ func (m *Model) onActivityTick() tea.Cmd { m.activityTicking = false return nil } - if top.level == levelLockTree { + switch top.level { + case levelLockTree: return tea.Batch(m.loadLockTreeCmd(top.db), next) + case levelProgress: + return tea.Batch(m.loadProgressCmd(top.db), next) } return tea.Batch(m.loadActivityCmd(top.db, top.actFilter), next) } @@ -598,6 +623,23 @@ func (m *Model) onLockTreeLoaded(msg lockTreeLoadedMsg) tea.Cmd { return nil } +func (m *Model) onProgressLoaded(msg progressLoadedMsg) tea.Cmd { + s := m.findLevel(levelProgress) + if s == nil || s.db != msg.db { + return nil + } + s.loading = false + s.loaded = true + if msg.err != nil { + s.progressErr = msg.err + return nil + } + s.progressErr = nil + s.progressRows = msg.rows + m.rebuildProgressItems(s) + return nil +} + func (m *Model) onActivityHosts(msg activityHostsMsg) tea.Cmd { s := m.findLevel(levelActivity) if s == nil { diff --git a/internal/tui/view.go b/internal/tui/view.go index 513bc0f..59bc250 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -166,7 +166,8 @@ func (m *Model) View() string { s.level != levelStatements && s.level != levelStatementDetail && s.level != levelStatementResult && s.level != levelSnapshots && s.level != levelBufferDetail && s.level != levelMaintenance && s.level != levelSettings && - s.level != levelActivity && s.level != levelLockTree && s.level != levelTableStats: + s.level != levelActivity && s.level != levelLockTree && s.level != levelTableStats && + s.level != levelProgress: // levelDescribe never populates items — it renders from s.describe. // levelDiagnosticResult and levelStatementResult with 0 items mean the // query returned no rows, which is valid; fall through to the renderer @@ -259,6 +260,8 @@ func (m *Model) View() string { b.WriteString(m.renderDiagResult(s, contentHeight)) case levelLockTree: b.WriteString(m.renderLockTree(s, contentHeight)) + case levelProgress: + b.WriteString(m.renderProgress(s, contentHeight)) case levelTableStats: // The table overview is a generic diagnostic-style table too. b.WriteString(m.renderDiagResult(s, contentHeight)) diff --git a/internal/tui/view_maintenance.go b/internal/tui/view_maintenance.go index 20a2d40..78e28ea 100644 --- a/internal/tui/view_maintenance.go +++ b/internal/tui/view_maintenance.go @@ -94,7 +94,8 @@ func (m *Model) renderMaintenance(s *screen, height int) string { full.WriteString(" " + mu("↑↓ select capacity row · ↵ settings · ") + styleBadge.Render("a") + mu(" activity · ") + styleBadge.Render("w") + mu(" wal · ") + - styleBadge.Render("r") + mu(" replication · space refresh") + "\n") + styleBadge.Render("r") + mu(" replication · ") + + styleBadge.Render("p") + mu(" progress · space refresh") + "\n") full.WriteString(body.String()) return scrollWindow(full.String(), &s.offset, height) diff --git a/internal/tui/view_overlays.go b/internal/tui/view_overlays.go index c1fc4b1..7aaf825 100644 --- a/internal/tui/view_overlays.go +++ b/internal/tui/view_overlays.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "strings" "github.com/charmbracelet/lipgloss" @@ -193,7 +194,22 @@ func (m *Model) renderReindexBanner(s *screen) string { } switch { case s.reindexing != "": - return " " + styleMuted.Render(m.spinner.View()+" REINDEX INDEX CONCURRENTLY "+s.reindexing+"…") + mu := styleMuted.Render + line := " " + m.spinner.View() + " " + + styleSelected.Render("REINDEX") + mu(" CONCURRENTLY "+s.reindexing) + // Live progress, once pg_stat_progress_create_index starts reporting. + if p := s.reindexProg; p != nil { + if p.Phase != "" { + line += mu(" · " + p.Phase) + } + if pct := p.Pct(); pct >= 0 { + const barW = 32 + filled := min(int(float64(barW)*pct/100), barW) + line += " " + paintBar(barW, barSegment{cells: filled, style: styleBar}) + + mu(fmt.Sprintf(" %.0f%%", pct)) + } + } + return line case s.pendingReindex != "": return " " + styleSelected.Render("confirm: ") + styleMuted.Render("REINDEX INDEX CONCURRENTLY "+s.pendingReindex+" — press ") + diff --git a/internal/tui/view_progress.go b/internal/tui/view_progress.go new file mode 100644 index 0000000..347e215 --- /dev/null +++ b/internal/tui/view_progress.go @@ -0,0 +1,143 @@ +package tui + +import ( + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/lipgloss" + + "pgdu/internal/humanize" + "pgdu/internal/pg" +) + +// Column widths shared by the progress-monitor rows and barReserve, so the +// bar auto-sizes against the same budgets the renderer prints with. +const ( + progColCmd = 13 // "CREATE INDEX" is the widest command + progColPhase = 26 // e.g. "building index: scanning table" (clipped) + progColDoneTotal = 20 // "12345678 / 98765432" or "12.34 MB / 1.20 GB" + progColPct = 6 // "99.9%" + progColAge = 8 // fmtAge output ("31.1s", "2.4d") + progColUser = 12 +) + +// rebuildProgressItems flattens s.progressRows into s.items so the generic +// cursor/filter/viewport machinery applies. Order comes from the SQL +// (pct DESC, pid) — there is no user sort on this level. +func (m *Model) rebuildProgressItems(s *screen) { + s.items = s.items[:0] + s.itemsRev++ + for _, r := range s.progressRows { + s.items = append(s.items, item{ + name: fmt.Sprintf("%d %s %s %s %s", r.PID, r.Command, r.Relation, r.Phase, r.Username), + data: r, + }) + } + s.clampCursor() +} + +func (m *Model) renderProgress(s *screen, height int) string { + mu := styleMuted.Render + var b strings.Builder + + if s.progressErr != nil { + b.WriteString(" " + styleErr.Render("error: "+s.progressErr.Error()) + "\n") + return padToHeight(&b, height, 1) + } + + refresh := "off" + if m.activityRefresh > 0 { + refresh = m.activityRefresh.String() + } + b.WriteString(" " + styleSelected.Render("running operations") + + mu(fmt.Sprintf(" · %d ops · ⟳ %s · ", len(s.progressRows), refresh)) + + styleBadge.Render("d") + mu(" describe · ") + + styleBadge.Render("t") + mu(" cadence") + "\n") + used := 1 + + if len(s.items) == 0 { + b.WriteString(" " + styleBadge.Render("no operations in progress") + + mu(" — rows appear while VACUUM / CREATE INDEX / ANALYZE / CLUSTER / COPY / base backups run") + "\n") + return padToHeight(&b, height, used+1) + } + + barW := m.barWidth(s) + vis := s.visibleIndexes() + rowsH := max(height-used, 0) + if rowsH > 0 { + s.offset, _ = viewportRange(s.cursor, s.offset, rowsH, len(vis)) + } + end := min(s.offset+rowsH, len(vis)) + for vi := s.offset; vi < end; vi++ { + r, _ := s.items[vis[vi]].data.(pg.ProgressRow) + b.WriteString(m.renderProgressRow(r, vi == s.cursor, barW) + "\n") + used++ + } + return padToHeight(&b, height, used) +} + +// renderProgressRow renders one operation: command · relation · phase · +// done/total · progress bar · pct · running time · user. +func (m *Model) renderProgressRow(r pg.ProgressRow, selected bool, barW int) string { + mu := styleMuted.Render + + cursor := " " + if selected { + cursor = lipgloss.NewStyle().Foreground(colorAccent).Render("▶ ") + } + cmd := padRight(r.Command, progColCmd) + if selected { + cmd = styleSelected.Render(cmd) + } + + pct := r.Pct() + var bar, pctStr string + if pct < 0 { + // Total still unknown (e.g. base backup before its size estimate): + // empty bar keeps the layout stable, em-dash marks the unknown. + bar = paintBar(barW) + pctStr = "—" + } else { + filled := min(int(float64(barW)*pct/100), barW) + bar = paintBar(barW, barSegment{cells: filled, style: styleBar}) + pctStr = fmt.Sprintf("%.1f%%", pct) + } + + var age string + if r.RunningMs > 0 { + age = durationStyle(r.RunningMs).Render(padRight(fmtAge(r.RunningMs), progColAge)) + } else { + age = strings.Repeat(" ", progColAge) + } + + line := cursor + + cmd + " " + + padRight(truncateToWidth(r.Relation, colName-1), colName) + + mu(padRight(truncateToWidth(r.Phase, progColPhase-1), progColPhase)) + + padLeft(progressDoneTotal(r), progColDoneTotal) + " " + + bar + " " + + padLeft(pctStr, progColPct) + " " + + age + + mu(truncateToWidth(r.Username, progColUser)) + if m.width > 4 && lipgloss.Width(line) > m.width { + line = truncateToWidth(line, m.width) + } + return line +} + +// progressDoneTotal formats the raw counters in their native unit: byte-based +// operations (COPY, base backup) humanized, block-based ones as plain counts. +// With no total yet, show just what's been done so far. +func progressDoneTotal(r pg.ProgressRow) string { + if r.Unit == "bytes" { + if r.Total <= 0 { + return humanize.Bytes(r.Done) + } + return humanize.Bytes(r.Done) + " / " + humanize.Bytes(r.Total) + } + if r.Total <= 0 { + return strconv.FormatInt(r.Done, 10) + } + return fmt.Sprintf("%d / %d", r.Done, r.Total) +} diff --git a/internal/tui/view_progress_test.go b/internal/tui/view_progress_test.go new file mode 100644 index 0000000..6726b8a --- /dev/null +++ b/internal/tui/view_progress_test.go @@ -0,0 +1,77 @@ +package tui + +import ( + "strings" + "testing" + + "pgdu/internal/pg" +) + +func TestProgressDoneTotal(t *testing.T) { + cases := []struct { + name string + row pg.ProgressRow + want string + }{ + {"blocks", pg.ProgressRow{Unit: "blocks", Done: 300, Total: 1000}, "300 / 1000"}, + {"blocks no total", pg.ProgressRow{Unit: "blocks", Done: 42}, "42"}, + {"bytes", pg.ProgressRow{Unit: "bytes", Done: 1 << 20, Total: 1 << 30}, "1.00 MB / 1.00 GB"}, + {"bytes no total", pg.ProgressRow{Unit: "bytes", Done: 1 << 20}, "1.00 MB"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := progressDoneTotal(c.row); got != c.want { + t.Errorf("progressDoneTotal(%+v) = %q, want %q", c.row, got, c.want) + } + }) + } +} + +func TestProgressPct(t *testing.T) { + if got := (pg.ProgressRow{Done: 25, Total: 100}).Pct(); got != 25 { + t.Errorf("Pct() = %v, want 25", got) + } + if got := (pg.ProgressRow{Done: 25}).Pct(); got != -1 { + t.Errorf("Pct() with zero total = %v, want -1", got) + } +} + +func TestRenderProgress(t *testing.T) { + m := &Model{width: 200} + s := &screen{level: levelProgress, tool: toolMaintenance} + + // Empty state renders its own message instead of the generic "(no items)". + m.rebuildProgressItems(s) + if out := stripANSI(m.renderProgress(s, 5)); !strings.Contains(out, "no operations in progress") { + t.Errorf("empty render missing empty-state message:\n%s", out) + } + + s.progressRows = []pg.ProgressRow{ + {PID: 101, Command: "CREATE INDEX", Relation: "public.orders_idx", Phase: "building index", + Unit: "blocks", Done: 640, Total: 1000, RunningMs: 252_000, Username: "app"}, + {PID: 102, Command: "COPY", Relation: "public.events", + Unit: "bytes", Done: 1 << 30, Total: 2 << 30, RunningMs: 63_000, Username: "etl"}, + {PID: 103, Command: "BASE BACKUP", Phase: "streaming database files", + Unit: "bytes", Done: 1 << 20, Total: 0, RunningMs: 1_000, Username: "repl"}, + } + m.rebuildProgressItems(s) + if len(s.items) != 3 { + t.Fatalf("rebuildProgressItems: %d items, want 3", len(s.items)) + } + // Filter text must match pid, command, relation, phase and user. + if !strings.Contains(s.items[0].name, "101") || !strings.Contains(s.items[0].name, "orders_idx") { + t.Errorf("filter text incomplete: %q", s.items[0].name) + } + + out := stripANSI(m.renderProgress(s, 10)) + for _, want := range []string{ + "3 ops", + "CREATE INDEX", "public.orders_idx", "building index", "640 / 1000", "64.0%", "4.2m", + "COPY", "1.00 GB / 2.00 GB", "50.0%", + "BASE BACKUP", "1.00 MB", "—", // unknown total: bare done + em-dash pct + } { + if !strings.Contains(out, want) { + t.Errorf("render missing %q:\n%s", want, out) + } + } +} From aee27be208aec369e82f4403b1f5c524a29ac24b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20D=C3=B6tsch?= Date: Sat, 4 Jul 2026 19:06:47 +0200 Subject: [PATCH 4/6] tui: health-triage tool, wait-event profiler, triage-as-tool Add a one-key health-triage battery (internal/pg/triage.go): fans out the existing diagnostic checks (wraparound, blocked backends, idle-in-xact, replication slots, cache hit ratio, SLRU pressure, sequence exhaustion, bloat, invalid indexes, temp files/deadlocks) concurrently under one budget and renders a severity-sorted red/yellow/green report (levelTriage, view_triage.go); Enter drills into the diagnostic or tool backing the line that fired. Add a wait-event profiler (levelWaitProfile, 'W' on the activity table): samples pg_stat_activity's wait_event_type/wait_event into a ring buffer while the activity tick runs, and renders a sparkline-backed breakdown by wait class. Surface health triage as its own entry on the root tool picker ("Health triage") instead of a bare '!' keybinding on levelTools, so it's discoverable the same way every other tool is and gets a normal toolEntryScreen/levelTriage load path. --- docs/future/health-triage.md | 9 +- docs/future/wait-event-profiler.md | 2 +- go.mod | 2 +- internal/pg/diagnostic_defs.go | 19 + internal/pg/diagnostics_integration_test.go | 19 + internal/pg/queries_diag.go | 22 + internal/pg/triage.go | 530 ++++++++++++++++++++ internal/pg/triage_test.go | 197 ++++++++ internal/tui/app.go | 19 +- internal/tui/cmds_triage.go | 22 + internal/tui/format.go | 4 + internal/tui/keys.go | 17 +- internal/tui/layout.go | 8 +- internal/tui/sparkline.go | 49 ++ internal/tui/sparkline_test.go | 26 + internal/tui/update.go | 3 + internal/tui/update_drill.go | 31 ++ internal/tui/update_keys.go | 14 +- internal/tui/update_load.go | 2 + internal/tui/update_msgs.go | 6 +- internal/tui/update_msgs_triage.go | 46 ++ internal/tui/view.go | 10 +- internal/tui/view_overlays.go | 4 +- internal/tui/view_triage.go | 107 ++++ internal/tui/view_triage_test.go | 85 ++++ internal/tui/view_waitprofile.go | 223 ++++++++ internal/tui/waitprofile.go | 91 ++++ internal/tui/waitprofile_test.go | 107 ++++ 28 files changed, 1660 insertions(+), 14 deletions(-) create mode 100644 internal/pg/triage.go create mode 100644 internal/pg/triage_test.go create mode 100644 internal/tui/cmds_triage.go create mode 100644 internal/tui/sparkline.go create mode 100644 internal/tui/sparkline_test.go create mode 100644 internal/tui/update_msgs_triage.go create mode 100644 internal/tui/view_triage.go create mode 100644 internal/tui/view_triage_test.go create mode 100644 internal/tui/view_waitprofile.go create mode 100644 internal/tui/waitprofile.go create mode 100644 internal/tui/waitprofile_test.go diff --git a/docs/future/health-triage.md b/docs/future/health-triage.md index bfa4fc0..c4cc15c 100644 --- a/docs/future/health-triage.md +++ b/docs/future/health-triage.md @@ -1,6 +1,6 @@ # Future: one-key health triage report -Status: design only (not implemented). +Status: implemented ("Health triage" tool on the root picker → levelTriage; see internal/pg/triage.go). ## Motivation @@ -29,7 +29,7 @@ Reuse existing diagnostics/queries so the thresholds live in one place: ## UX sketch -`!` on `levelTools` (or `levelMaintenance`) runs the battery and pushes a +Selecting "Health triage" on `levelTools` runs the battery and pushes a `levelTriage` screen: ``` @@ -50,8 +50,9 @@ severity; green lines collapse to a summary count so the eye lands on red first. `[]TriageResult{Check, Severity, Detail, DiagKey}`. - `levelTriage` enum; `view_triage.go` renderer (reuse `stateStyle`-like severity colours and the graded styles from `styles.go`). -- `!` key bound + enabled on `levelTools`; Enter maps `DiagKey` back to a - `diagnosticResultScreen` push (or the lock tree for the blocked-backends line). +- `toolTriage` entry on the root tool picker → `toolEntryScreen` pushes + `levelTriage`; Enter maps `DiagKey` back to a `diagnosticResultScreen` push + (or the lock tree for the blocked-backends line). ## Effort diff --git a/docs/future/wait-event-profiler.md b/docs/future/wait-event-profiler.md index 4537c50..f0eef13 100644 --- a/docs/future/wait-event-profiler.md +++ b/docs/future/wait-event-profiler.md @@ -1,6 +1,6 @@ # Future: wait-event sampling profiler ("poor man's ASH") -Status: design only (not implemented). +Status: implemented (`W` on the Activity view → levelWaitProfile; see internal/tui/waitprofile.go). ## Motivation diff --git a/go.mod b/go.mod index 669094e..3d9ee23 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/jackc/pgx/v5 v5.10.0 github.com/muesli/termenv v0.16.0 github.com/spf13/pflag v1.0.10 + golang.org/x/sync v0.21.0 ) require ( @@ -31,7 +32,6 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect ) diff --git a/internal/pg/diagnostic_defs.go b/internal/pg/diagnostic_defs.go index a088a76..a3b4855 100644 --- a/internal/pg/diagnostic_defs.go +++ b/internal/pg/diagnostic_defs.go @@ -21,6 +21,16 @@ type Diagnostic struct { Kinds map[string]DiagColumnKind } +// DiagnosticByKey looks a diagnostic up in the registry by its stable key. +func DiagnosticByKey(key string) (Diagnostic, bool) { + for _, d := range Diagnostics { + if d.Key == key { + return d, true + } + } + return Diagnostic{}, false +} + // Diagnostics is the ordered registry of all built-in diagnostic queries. // Queries are grouped by category and sorted alphabetically within each group; // the TUI list renders them in this order. @@ -64,6 +74,15 @@ var Diagnostics = []Diagnostic{ // SQL: sqlDiagIndexShowAll, // Bar: "number_of_scans", // }, + { + Key: "index_invalid", + PerDB: true, + Title: "Invalid indexes", + Category: "index", + Description: "indexes left INVALID by a failed CREATE/REINDEX CONCURRENTLY — unusable by plans but still maintained on writes", + SQL: sqlDiagIndexInvalid, + Bar: "index_size_bytes", + }, { Key: "index_io", PerDB: true, diff --git a/internal/pg/diagnostics_integration_test.go b/internal/pg/diagnostics_integration_test.go index 5caa4ee..0dd6a35 100644 --- a/internal/pg/diagnostics_integration_test.go +++ b/internal/pg/diagnostics_integration_test.go @@ -5,6 +5,7 @@ import ( "net/url" "os" "strconv" + "strings" "testing" "pgdu/internal/cli" @@ -220,3 +221,21 @@ func diagByKey(t *testing.T, key string) Diagnostic { t.Fatalf("diagnostic %q not registered", key) panic("unreachable") } + +// TestIntegration_Triage runs the whole health battery against the live test +// cluster. Severities are data-dependent (a small test DB can legitimately +// grade red on e.g. cache hit ratio), so the assertion is only that every +// check evaluated — nothing may degrade to "could not evaluate". +func TestIntegration_Triage(t *testing.T) { + c, _ := diagTestClient(t) + results := c.Triage(context.Background()) + if len(results) != 10 { + t.Fatalf("Triage returned %d results, want 10", len(results)) + } + for _, r := range results { + if strings.HasPrefix(r.Detail, "could not evaluate") { + t.Errorf("%s: %s", r.Check, r.Detail) + } + t.Logf("%-24s sev=%d %s", r.Check, r.Severity, r.Detail) + } +} diff --git a/internal/pg/queries_diag.go b/internal/pg/queries_diag.go index c7466f1..a94e7d1 100644 --- a/internal/pg/queries_diag.go +++ b/internal/pg/queries_diag.go @@ -1024,3 +1024,25 @@ LEFT JOIN pg_stat_subscription_stats ss ON ss.subid = su.oid WHERE su.subdbid = (SELECT oid FROM pg_database WHERE datname = current_database()) ORDER BY su.subname ` + +// sqlDiagIndexInvalid lists indexes flagged NOT indisvalid — the residue of a +// failed CREATE INDEX CONCURRENTLY / REINDEX CONCURRENTLY. Plans never use +// them but every write still maintains them, so they are pure overhead until +// rebuilt or dropped. pg_toast is included on purpose (REINDEX CONCURRENTLY +// can strand TOAST indexes too); catalog and temp schemas are not. +const sqlDiagIndexInvalid = ` +SELECT + n.nspname AS schema, + t.relname AS table_name, + ic.relname AS index_name, + pg_relation_size(i.indexrelid) AS index_size_bytes, + pg_get_indexdef(i.indexrelid) AS definition +FROM pg_index i +JOIN pg_class ic ON ic.oid = i.indexrelid +JOIN pg_class t ON t.oid = i.indrelid +JOIN pg_namespace n ON n.oid = t.relnamespace +WHERE NOT i.indisvalid + AND n.nspname <> 'information_schema' + AND n.nspname !~ '^pg_(catalog|temp_)' +ORDER BY pg_relation_size(i.indexrelid) DESC +` diff --git a/internal/pg/triage.go b/internal/pg/triage.go new file mode 100644 index 0000000..2e40c02 --- /dev/null +++ b/internal/pg/triage.go @@ -0,0 +1,530 @@ +package pg + +import ( + "context" + "errors" + "fmt" + "sort" + "time" + + "golang.org/x/sync/errgroup" + + "pgdu/internal/humanize" +) + +// Severity grades one triage check: green / yellow / red. +type Severity int + +const ( + SevOK Severity = iota + SevWarn + SevCrit +) + +// TriageTarget says which screen Enter should drill into for a triage line. +type TriageTarget int + +const ( + TriageTargetDiagnostic TriageTarget = iota // push the diagnostic named by DiagKey + TriageTargetLockTree // push the live lock tree + TriageTargetMaintenance // push the maintenance/system overview +) + +// TriageResult is one line of the health-triage report. +type TriageResult struct { + Check string + Severity Severity + Detail string + DiagKey string // diagnostic to drill into when Target == TriageTargetDiagnostic + Target TriageTarget +} + +// Triage thresholds. All are deliberately named constants so opinions live in +// one place and can be tuned without hunting through check code. Where a check +// reads cumulative counters (deadlocks, temp_bytes) the thresholds are generous +// because there is no baseline to compute a rate against. +const ( + // wraparound: autovacuum starts aggressive freezing at + // autovacuum_freeze_max_age; being most of the way there means autovacuum + // is not keeping up. + wraparoundCritFrac = 0.80 + wraparoundWarnFrac = 0.50 + + // blocked backends: a lock wait measured in tens of seconds is past + // "normal contention" and worth a look. XactAgeMs is the transaction age, + // not the exact wait duration — an upper bound, good enough for triage. + blockedCritXactMs = 30_000 + + // idle-in-transaction: 5 minutes of holding locks/snapshot while idle + // blocks vacuum and invites bloat. + idleXactCritSecs = 300 + + // cache hit ratio: below ~90% the working set clearly doesn't fit in + // shared_buffers; below warn it's starting to slip. + cacheHitCritPct = 90 + cacheHitWarnPct = 95 + + // SLRU: a poor hit ratio only matters once the cache sees real traffic; + // the read floors keep byte-sized test clusters green. + slruHitCritPct = 90 + slruCritReadsFloor = 10_000 + slruWarnReadsFloor = 1_000 + + // sequences: consumed_pct is fraction of max_value handed out; past 80% + // exhaustion is on the horizon and a type/cycle decision is due. + seqCritPct = 80 + seqWarnPct = 60 + + // replication slots: when the server can't report safe_wal_size, cap + // retained WAL at a fixed budget instead. + slotRetainedCapBytes = 16 << 30 + + // temp files / deadlocks: cumulative since the last stats reset, so only + // large absolute numbers are meaningful. + deadlocksWarn = 1 + deadlocksCrit = 100 + tempBytesWarn = 10 << 30 + tempBytesCrit = 100 << 30 + + // fan-out: enough parallelism to finish fast without stampeding a server + // that is already unwell; each check also gets its own sub-budget so one + // hung catalog query degrades to "could not evaluate" instead of eating + // the whole report's time. + triageFanout = 6 + triageCheckTimeout = 10 * time.Second +) + +// Triage runs the curated health battery concurrently and returns one line per +// check, sorted most-severe first. A failed or slow check degrades to a +// SevWarn "could not evaluate" line; Triage itself never fails. +// +// Per-database checks (sequences, bloat, invalid indexes) run against the +// default database only — sweeping every database would multiply the fan-out +// by the cluster's database count and blow the budget. Their detail lines name +// the database they looked at. +func (c *Client) Triage(ctx context.Context) []TriageResult { + type check struct { + name string + target TriageTarget + diagKey string + run func(ctx context.Context) (Severity, string, error) + } + + db := c.DefaultDB() + checks := []check{ + {"wraparound", TriageTargetMaintenance, "", c.triageWraparound}, + {"blocked backends", TriageTargetLockTree, "", c.triageBlocked}, + {"idle-in-xact", TriageTargetDiagnostic, "idle_in_xact_holders", c.triageIdleInXact}, + {"replication slots", TriageTargetDiagnostic, "replication_slots", c.triageReplicationSlots}, + {"cache hit ratio", TriageTargetDiagnostic, "database_stats", c.triageCacheHit}, + {"SLRU pressure", TriageTargetDiagnostic, "slru_stats", c.triageSLRU}, + {"temp files / deadlocks", TriageTargetDiagnostic, "database_stats", c.triageTempDeadlocks}, + {"sequence exhaustion", TriageTargetDiagnostic, "sequences", func(ctx context.Context) (Severity, string, error) { + return c.triageSequences(ctx, db) + }}, + {"bloat", TriageTargetDiagnostic, "bloat_table", func(ctx context.Context) (Severity, string, error) { + return c.triageBloat(ctx, db) + }}, + {"invalid indexes", TriageTargetDiagnostic, "index_invalid", func(ctx context.Context) (Severity, string, error) { + return c.triageInvalidIndexes(ctx, db) + }}, + } + + results := make([]TriageResult, len(checks)) + g := new(errgroup.Group) + g.SetLimit(triageFanout) + for i, chk := range checks { + g.Go(func() error { + cctx, cancel := context.WithTimeout(ctx, triageCheckTimeout) + defer cancel() + sev, detail, err := chk.run(cctx) + if err != nil { + sev, detail = SevWarn, "could not evaluate: "+err.Error() + } + results[i] = TriageResult{ + Check: chk.name, + Severity: sev, + Detail: detail, + DiagKey: chk.diagKey, + Target: chk.target, + } + return nil + }) + } + _ = g.Wait() + + sort.SliceStable(results, func(a, b int) bool { + return results[a].Severity > results[b].Severity + }) + return results +} + +func (c *Client) triageWraparound(ctx context.Context) (Severity, string, error) { + info, err := c.Maintenance(ctx, "") + if err != nil { + return 0, "", err + } + // Maintenance is best-effort and absorbs sub-query failures; a zero + // FreezeMaxAge means the settings read itself failed, so degrade honestly + // instead of reporting a green 0%. + if info.FreezeMaxAge <= 0 { + return 0, "", errors.New("autovacuum_freeze_max_age unavailable") + } + sev := wraparoundSeverity(info.XidAge, info.FreezeMaxAge) + pct := 100 * float64(info.XidAge) / float64(info.FreezeMaxAge) + return sev, fmt.Sprintf("oldest datfrozenxid at %.0f%% of autovacuum_freeze_max_age", pct), nil +} + +func wraparoundSeverity(xidAge, freezeMaxAge int64) Severity { + if freezeMaxAge <= 0 { + return SevOK + } + frac := float64(xidAge) / float64(freezeMaxAge) + switch { + case frac > wraparoundCritFrac: + return SevCrit + case frac > wraparoundWarnFrac: + return SevWarn + } + return SevOK +} + +func (c *Client) triageBlocked(ctx context.Context) (Severity, string, error) { + nodes, err := c.ListLockWaiters(ctx, "") + if err != nil { + return 0, "", err + } + waiting, longestMs := 0, float64(0) + for _, n := range nodes { + if !n.Waiting() { + continue + } + waiting++ + if n.XactAgeMs > longestMs { + longestMs = n.XactAgeMs + } + } + sev := blockedSeverity(waiting, longestMs) + if waiting == 0 { + return sev, "no lock waits", nil + } + return sev, fmt.Sprintf("%d backend(s) waiting on locks (longest xact %s)", + waiting, triageDuration(longestMs/1000)), nil +} + +func blockedSeverity(waiting int, longestMs float64) Severity { + switch { + case waiting > 0 && longestMs > blockedCritXactMs: + return SevCrit + case waiting > 0: + return SevWarn + } + return SevOK +} + +func (c *Client) triageIdleInXact(ctx context.Context) (Severity, string, error) { + res, err := c.runTriageDiag(ctx, "", "idle_in_xact_holders") + if err != nil { + return 0, "", err + } + ageCol := diagColIdx(res, "xact_age_secs") + oldest := float64(0) + for _, row := range res.Rows { + if v, ok := diagNum(row, ageCol); ok && v > oldest { + oldest = v + } + } + sev := idleInXactSeverity(len(res.Rows), oldest) + if len(res.Rows) == 0 { + return sev, "no idle-in-transaction backends", nil + } + return sev, fmt.Sprintf("%d backend(s) idle in transaction (oldest %s)", + len(res.Rows), triageDuration(oldest)), nil +} + +func idleInXactSeverity(count int, oldestSecs float64) Severity { + switch { + case count > 0 && oldestSecs > idleXactCritSecs: + return SevCrit + case count > 0: + return SevWarn + } + return SevOK +} + +func (c *Client) triageReplicationSlots(ctx context.Context) (Severity, string, error) { + res, err := c.runTriageDiag(ctx, "", "replication_slots") + if err != nil { + return 0, "", err + } + if len(res.Rows) == 0 { + return SevOK, "no replication slots", nil + } + var ( + activeCol = diagColIdx(res, "active") + statusCol = diagColIdx(res, "wal_status") + retainedCol = diagColIdx(res, "retained_wal_bytes") + safeCol = diagColIdx(res, "safe_wal_size") + + inactive, lost int + maxRetained float64 + overCap bool + ) + for _, row := range res.Rows { + if activeCol >= 0 && row[activeCol].Display == "f" { + inactive++ + } + if statusCol >= 0 { + switch row[statusCol].Display { + case "lost", "unreserved": + lost++ + } + } + retained, ok := diagNum(row, retainedCol) + if !ok { + continue + } + if retained > maxRetained { + maxRetained = retained + } + budget := float64(slotRetainedCapBytes) + if safe, ok := diagNum(row, safeCol); ok && safe > 0 { + budget = safe + } + if retained > budget { + overCap = true + } + } + sev := slotSeverity(inactive, lost, overCap) + detail := fmt.Sprintf("%d slot(s), max retained WAL %s", len(res.Rows), humanize.Bytes(int64(maxRetained))) + if inactive > 0 { + detail = fmt.Sprintf("%d of %d slot(s) inactive, max retained WAL %s", + inactive, len(res.Rows), humanize.Bytes(int64(maxRetained))) + } + if lost > 0 { + detail += fmt.Sprintf(", %d lost/unreserved", lost) + } + return sev, detail, nil +} + +func slotSeverity(inactive, lost int, overCap bool) Severity { + switch { + case lost > 0 || overCap: + return SevCrit + case inactive > 0: + return SevWarn + } + return SevOK +} + +func (c *Client) triageCacheHit(ctx context.Context) (Severity, string, error) { + res, err := c.runTriageDiag(ctx, "", "database_stats") + if err != nil { + return 0, "", err + } + hitCol := diagColIdx(res, "hit_pct") + dbCol := diagColIdx(res, "database") + minHit, worstDB, seen := 100.0, "", false + for _, row := range res.Rows { + v, ok := diagNum(row, hitCol) + if !ok { // NULL hit_pct: no block traffic yet, nothing to grade + continue + } + seen = true + if v < minHit { + minHit = v + if dbCol >= 0 { + worstDB = row[dbCol].Display + } + } + } + if !seen { + return SevOK, "no block traffic yet", nil + } + sev := cacheHitSeverity(minHit) + if worstDB != "" { + return sev, fmt.Sprintf("worst %.1f%% (%s)", minHit, worstDB), nil + } + return sev, fmt.Sprintf("worst %.1f%%", minHit), nil +} + +func cacheHitSeverity(minHitPct float64) Severity { + switch { + case minHitPct < cacheHitCritPct: + return SevCrit + case minHitPct < cacheHitWarnPct: + return SevWarn + } + return SevOK +} + +func (c *Client) triageSLRU(ctx context.Context) (Severity, string, error) { + res, err := c.runTriageDiag(ctx, "", "slru_stats") + if err != nil { + return 0, "", err + } + hitCol := diagColIdx(res, "hit_pct") + readCol := diagColIdx(res, "blks_read") + nameCol := diagColIdx(res, "name") + worst, worstName := SevOK, "" + for _, row := range res.Rows { + hit, hok := diagNum(row, hitCol) + reads, rok := diagNum(row, readCol) + if !hok || !rok { + continue + } + if sev := slruSeverity(hit, reads); sev > worst { + worst = sev + if nameCol >= 0 { + worstName = row[nameCol].Display + } + } + } + if worst == SevOK { + return worst, "all SLRU caches healthy", nil + } + return worst, fmt.Sprintf("%s cache under pressure (hit ratio below %d%% with heavy reads)", + worstName, slruHitCritPct), nil +} + +func slruSeverity(hitPct, blksRead float64) Severity { + switch { + case hitPct < slruHitCritPct && blksRead >= slruCritReadsFloor: + return SevCrit + case hitPct < slruHitCritPct && blksRead >= slruWarnReadsFloor: + return SevWarn + } + return SevOK +} + +func (c *Client) triageTempDeadlocks(ctx context.Context) (Severity, string, error) { + res, err := c.runTriageDiag(ctx, "", "database_stats") + if err != nil { + return 0, "", err + } + dlCol := diagColIdx(res, "deadlocks") + tmpCol := diagColIdx(res, "temp_bytes") + var deadlocks, tempBytes float64 + for _, row := range res.Rows { + if v, ok := diagNum(row, dlCol); ok { + deadlocks += v + } + if v, ok := diagNum(row, tmpCol); ok { + tempBytes += v + } + } + sev := tempDeadlockSeverity(deadlocks, tempBytes) + if sev == SevOK { + return sev, "no deadlocks, little temp-file spill", nil + } + return sev, fmt.Sprintf("%d deadlock(s), %s spilled to temp files since stats reset", + int64(deadlocks), humanize.Bytes(int64(tempBytes))), nil +} + +func tempDeadlockSeverity(deadlocks, tempBytes float64) Severity { + switch { + case deadlocks >= deadlocksCrit || tempBytes >= tempBytesCrit: + return SevCrit + case deadlocks >= deadlocksWarn || tempBytes >= tempBytesWarn: + return SevWarn + } + return SevOK +} + +func (c *Client) triageSequences(ctx context.Context, db string) (Severity, string, error) { + res, err := c.runTriageDiag(ctx, db, "sequences") + if err != nil { + return 0, "", err + } + pctCol := diagColIdx(res, "consumed_pct") + maxPct := float64(0) + for _, row := range res.Rows { + if v, ok := diagNum(row, pctCol); ok && v > maxPct { + maxPct = v + } + } + sev := sequenceSeverity(maxPct) + return sev, fmt.Sprintf("most-consumed sequence at %.1f%% of its range (in %s)", maxPct, db), nil +} + +func sequenceSeverity(maxConsumedPct float64) Severity { + switch { + case maxConsumedPct > seqCritPct: + return SevCrit + case maxConsumedPct > seqWarnPct: + return SevWarn + } + return SevOK +} + +// triageBloat leans on the bloat queries' own server-side filters: any row they +// return is already past "50% bloated and large", so row count is the signal. +func (c *Client) triageBloat(ctx context.Context, db string) (Severity, string, error) { + tables, err := c.runTriageDiag(ctx, db, "bloat_table") + if err != nil { + return 0, "", err + } + indexes, err := c.runTriageDiag(ctx, db, "bloat_index") + if err != nil { + return 0, "", err + } + nt, ni := len(tables.Rows), len(indexes.Rows) + if nt == 0 && ni == 0 { + return SevOK, fmt.Sprintf("no heavily bloated tables or indexes (in %s)", db), nil + } + return SevCrit, fmt.Sprintf("%d table(s), %d index(es) heavily bloated (in %s)", nt, ni, db), nil +} + +func (c *Client) triageInvalidIndexes(ctx context.Context, db string) (Severity, string, error) { + res, err := c.runTriageDiag(ctx, db, "index_invalid") + if err != nil { + return 0, "", err + } + if len(res.Rows) == 0 { + return SevOK, fmt.Sprintf("no invalid indexes (in %s)", db), nil + } + return SevCrit, fmt.Sprintf("%d index(es) left INVALID by a failed concurrent build (in %s)", + len(res.Rows), db), nil +} + +// runTriageDiag runs a registry diagnostic by key so the SQL and column +// definitions stay single-sourced in diagnostic_defs.go. +func (c *Client) runTriageDiag(ctx context.Context, db, key string) (*DiagResult, error) { + d, ok := DiagnosticByKey(key) + if !ok { + return nil, fmt.Errorf("unknown diagnostic %q", key) + } + return c.RunDiagnostic(ctx, db, d) +} + +// diagColIdx finds a column by name, -1 when absent. +func diagColIdx(res *DiagResult, name string) int { + for i, col := range res.Columns { + if col.Name == name { + return i + } + } + return -1 +} + +// diagNum reads the numeric value of row[idx], false when the column is +// missing or the cell carries no number (NULL, text). +func diagNum(row []DiagCell, idx int) (float64, bool) { + if idx < 0 || idx >= len(row) || !row[idx].HasNum { + return 0, false + } + return row[idx].Num, true +} + +// triageDuration renders seconds the way the report reads them: "48s", "11m", +// "3h". +func triageDuration(secs float64) string { + d := time.Duration(secs * float64(time.Second)) + switch { + case d >= time.Hour: + return fmt.Sprintf("%.0fh", d.Hours()) + case d >= time.Minute: + return fmt.Sprintf("%.0fm", d.Minutes()) + } + return fmt.Sprintf("%.0fs", d.Seconds()) +} diff --git a/internal/pg/triage_test.go b/internal/pg/triage_test.go new file mode 100644 index 0000000..bedbdb0 --- /dev/null +++ b/internal/pg/triage_test.go @@ -0,0 +1,197 @@ +package pg + +import ( + "context" + "strings" + "testing" + + "pgdu/internal/cli" +) + +func TestWraparoundSeverity(t *testing.T) { + tests := []struct { + name string + age, mx int64 + want Severity + }{ + {"quiet", 10_000_000, 200_000_000, SevOK}, + {"halfway", 110_000_000, 200_000_000, SevWarn}, + {"near freeze", 170_000_000, 200_000_000, SevCrit}, + {"unknown max", 170_000_000, 0, SevOK}, + } + for _, tt := range tests { + if got := wraparoundSeverity(tt.age, tt.mx); got != tt.want { + t.Errorf("%s: wraparoundSeverity(%d, %d) = %v, want %v", tt.name, tt.age, tt.mx, got, tt.want) + } + } +} + +func TestBlockedSeverity(t *testing.T) { + tests := []struct { + name string + waiting int + longestMs float64 + want Severity + }{ + {"none", 0, 0, SevOK}, + {"brief wait", 2, 5_000, SevWarn}, + {"long wait", 1, 48_000, SevCrit}, + } + for _, tt := range tests { + if got := blockedSeverity(tt.waiting, tt.longestMs); got != tt.want { + t.Errorf("%s: blockedSeverity(%d, %v) = %v, want %v", tt.name, tt.waiting, tt.longestMs, got, tt.want) + } + } +} + +func TestIdleInXactSeverity(t *testing.T) { + if got := idleInXactSeverity(0, 0); got != SevOK { + t.Errorf("no holders = %v, want SevOK", got) + } + if got := idleInXactSeverity(1, 30); got != SevWarn { + t.Errorf("young holder = %v, want SevWarn", got) + } + if got := idleInXactSeverity(1, 660); got != SevCrit { + t.Errorf("11m holder = %v, want SevCrit", got) + } +} + +func TestSlotSeverity(t *testing.T) { + if got := slotSeverity(0, 0, false); got != SevOK { + t.Errorf("healthy = %v, want SevOK", got) + } + if got := slotSeverity(1, 0, false); got != SevWarn { + t.Errorf("inactive = %v, want SevWarn", got) + } + if got := slotSeverity(0, 1, false); got != SevCrit { + t.Errorf("lost = %v, want SevCrit", got) + } + if got := slotSeverity(0, 0, true); got != SevCrit { + t.Errorf("over cap = %v, want SevCrit", got) + } +} + +func TestCacheHitSeverity(t *testing.T) { + if got := cacheHitSeverity(99.3); got != SevOK { + t.Errorf("99.3 = %v, want SevOK", got) + } + if got := cacheHitSeverity(93); got != SevWarn { + t.Errorf("93 = %v, want SevWarn", got) + } + if got := cacheHitSeverity(85); got != SevCrit { + t.Errorf("85 = %v, want SevCrit", got) + } +} + +func TestSlruSeverity(t *testing.T) { + if got := slruSeverity(99, 1_000_000); got != SevOK { + t.Errorf("high hit = %v, want SevOK", got) + } + if got := slruSeverity(50, 100); got != SevOK { + t.Errorf("low traffic = %v, want SevOK", got) + } + if got := slruSeverity(80, 5_000); got != SevWarn { + t.Errorf("moderate reads = %v, want SevWarn", got) + } + if got := slruSeverity(80, 50_000); got != SevCrit { + t.Errorf("heavy reads = %v, want SevCrit", got) + } +} + +func TestSequenceSeverity(t *testing.T) { + if got := sequenceSeverity(10); got != SevOK { + t.Errorf("10%% = %v, want SevOK", got) + } + if got := sequenceSeverity(70); got != SevWarn { + t.Errorf("70%% = %v, want SevWarn", got) + } + if got := sequenceSeverity(95); got != SevCrit { + t.Errorf("95%% = %v, want SevCrit", got) + } +} + +func TestTempDeadlockSeverity(t *testing.T) { + if got := tempDeadlockSeverity(0, 1<<20); got != SevOK { + t.Errorf("clean = %v, want SevOK", got) + } + if got := tempDeadlockSeverity(3, 0); got != SevWarn { + t.Errorf("a few deadlocks = %v, want SevWarn", got) + } + if got := tempDeadlockSeverity(0, 200<<30); got != SevCrit { + t.Errorf("200GB temp = %v, want SevCrit", got) + } +} + +func TestTriageDuration(t *testing.T) { + tests := []struct { + secs float64 + want string + }{ + {48, "48s"}, + {660, "11m"}, + {7200, "2h"}, + } + for _, tt := range tests { + if got := triageDuration(tt.secs); got != tt.want { + t.Errorf("triageDuration(%v) = %q, want %q", tt.secs, got, tt.want) + } + } +} + +func TestDiagNumAndColIdx(t *testing.T) { + res := &DiagResult{Columns: []DiagColumn{{Name: "a"}, {Name: "b"}}} + if got := diagColIdx(res, "b"); got != 1 { + t.Errorf("diagColIdx(b) = %d, want 1", got) + } + if got := diagColIdx(res, "missing"); got != -1 { + t.Errorf("diagColIdx(missing) = %d, want -1", got) + } + row := []DiagCell{{Display: "x"}, {Num: 42, HasNum: true}} + if v, ok := diagNum(row, 1); !ok || v != 42 { + t.Errorf("diagNum(1) = %v,%v, want 42,true", v, ok) + } + if _, ok := diagNum(row, 0); ok { + t.Errorf("diagNum on text cell should be false") + } + if _, ok := diagNum(row, -1); ok { + t.Errorf("diagNum(-1) should be false") + } +} + +// Triage must degrade a failing check to a "could not evaluate" line instead +// of failing the report; with a cancelled context every check fails that way. +func TestTriageDegradesOnFailure(t *testing.T) { + c := New(cli.Config{Database: "nope"}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + results := c.Triage(ctx) + if len(results) != 10 { + t.Fatalf("Triage returned %d results, want 10", len(results)) + } + for _, r := range results { + if r.Check == "" { + t.Errorf("result with empty Check name: %+v", r) + } + if r.Severity != SevWarn { + t.Errorf("%s: severity %v, want SevWarn for a failed check", r.Check, r.Severity) + } + if !strings.HasPrefix(r.Detail, "could not evaluate: ") { + t.Errorf("%s: detail %q, want could-not-evaluate", r.Check, r.Detail) + } + } +} + +func TestTriageDiagKeysExist(t *testing.T) { + c := New(cli.Config{Database: "nope"}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + for _, r := range c.Triage(ctx) { + if r.Target != TriageTargetDiagnostic { + continue + } + if _, ok := DiagnosticByKey(r.DiagKey); !ok { + t.Errorf("%s: DiagKey %q not in the Diagnostics registry", r.Check, r.DiagKey) + } + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go index 4336e68..4a28fcc 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -49,6 +49,8 @@ const ( levelLockTree // blocking-chain forest from pg_locks (child of levelActivity) levelTableStats // per-table statistics overview for one schema (toolTableStats) levelProgress // live pg_stat_progress_* monitor (child of levelMaintenance) + levelTriage // one-key health-triage report (toolTriage) + levelWaitProfile // wait-event sampling profile ('W' on levelActivity) ) // tool identifies which top-level statistic the user is exploring. @@ -65,6 +67,7 @@ const ( toolMaintenance // server-health dashboard + settings browser toolActivity // live server activity (pg_stat_activity) toolTableStats // per-table statistics overview (pg_stat_all_tables + sizes) + toolTriage // one-key health-triage report (levelTriage) ) func (t tool) Name() string { @@ -87,6 +90,8 @@ func (t tool) Name() string { return "activity" case toolTableStats: return "tables" + case toolTriage: + return "triage" } return "?" } @@ -484,6 +489,11 @@ type screen struct { // settingRows is the full pg_settings list for levelSettings. settingRows []pg.SettingRow + // ── Health triage (levelTriage) ─────────────────────────────────────────── + // triageResults is the loaded battery result, severity-sorted; items are + // derived from it with green checks collapsed (see triageItems). + triageResults []pg.TriageResult + // ── Progress monitor (levelProgress) ────────────────────────────────────── // progressRows is the last fetched set of running operations from the // pg_stat_progress_* views; progressErr is non-nil when the load failed. @@ -614,6 +624,12 @@ type Model struct { // write/s) from the most recent sample pair. nil = not yet sampled. actProcStats map[int32]procDerived + // waitRing accumulates per-tick wait-event samples from every activity + // refresh (see pushWaitBucket). Model-level so the histogram survives + // screen pushes/pops and already has history when the profile opens; + // lazily allocated on the first activity snapshot. + waitRing *waitRing + // activityTicking is true while a self-rescheduling refresh tick is running // for the Activity tool, so re-entering levelActivity doesn't spawn a second // loop. @@ -683,7 +699,7 @@ func (m *Model) vacuumPaneVisible(s *screen) bool { // by the -- CLI flags) back to the tool enum. The bool is false for an // unknown/empty name so the caller can fall back to the tool picker. func toolByName(name string) (tool, bool) { - for _, t := range []tool{toolDisk, toolBuffers, toolPageInspect, toolTools, toolWAL, toolQueries, toolMaintenance, toolActivity, toolTableStats} { + for _, t := range []tool{toolDisk, toolBuffers, toolPageInspect, toolTools, toolWAL, toolQueries, toolMaintenance, toolActivity, toolTableStats, toolTriage} { if t.Name() == name { return t, true } @@ -746,6 +762,7 @@ func toolItems() []item { {name: "Current Activity", detail: "live server activity (pg_stat_activity): active queries, waits, client IPs; cancel / terminate backends", hasChildren: true, data: toolActivity}, {name: "Table overview", detail: "per-table stats for a schema: size, write/scan activity, cache hit ratios, bloat, vacuum age, storage options — sortable, customizable columns", hasChildren: true, data: toolTableStats}, {name: "System overview", detail: "server health dashboard: connections, transactions, I/O, replication, autovacuum, WAL, PgBouncer", hasChildren: true, data: toolMaintenance}, + {name: "Health triage", detail: "one-key red/yellow/green health report: runs the whole diagnostic battery concurrently; Enter drills into the check that fired", hasChildren: true, data: toolTriage}, {name: "Shared buffers", detail: "browse tables by shared_buffers footprint and cache hit ratio", hasChildren: true, data: toolBuffers}, {name: "Page inspector", detail: "drill into heap pages and tuple line pointers using pageinspect", hasChildren: true, data: toolPageInspect}, {name: "WAL inspector", detail: "drill into recent write-ahead-log: bytes per resource manager, records, block refs (pg_walinspect)", hasChildren: true, data: toolWAL}, diff --git a/internal/tui/cmds_triage.go b/internal/tui/cmds_triage.go new file mode 100644 index 0000000..d7c87fd --- /dev/null +++ b/internal/tui/cmds_triage.go @@ -0,0 +1,22 @@ +package tui + +import ( + "context" + + tea "github.com/charmbracelet/bubbletea" + + "pgdu/internal/pg" +) + +// triageLoadedMsg delivers the health-triage battery's results. There is no +// err field on purpose: Triage degrades each failed check to a "could not +// evaluate" line instead of failing the whole report. +type triageLoadedMsg struct { + results []pg.TriageResult +} + +func (m *Model) loadTriageCmd() tea.Cmd { + return query(func(ctx context.Context) tea.Msg { + return triageLoadedMsg{results: m.client.Triage(ctx)} + }) +} diff --git a/internal/tui/format.go b/internal/tui/format.go index 1d54a08..98a1a02 100644 --- a/internal/tui/format.go +++ b/internal/tui/format.go @@ -142,6 +142,10 @@ func levelLabel(l level) string { return "settings" case levelProgress: return "progress" + case levelTriage: + return "triage" + case levelWaitProfile: + return "wait-profile" } return "?" } diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 69d4466..7463418 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -53,6 +53,12 @@ type keyMap struct { JumpReplication key.Binding // r: open the replication-slots diagnostic Progress key.Binding // p: open the live progress monitor + // Wait-event profiler over the Activity tool's sample stream. + WaitProfile key.Binding // W: open the wait-event profile + + // waitProfileInFooter adds the W hint to the footer on the activity table. + waitProfileInFooter bool + // shmemInFooter adds the m (memory map) hint to the footer's short help on // the buffer-tables level, where it's the only advertisement for the view. shmemInFooter bool @@ -111,6 +117,8 @@ func defaultKeys() keyMap { JumpWAL: key.NewBinding(key.WithKeys("w"), key.WithHelp("w", "wal")), JumpReplication: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "replication")), Progress: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "progress")), + + WaitProfile: key.NewBinding(key.WithKeys("W"), key.WithHelp("W", "wait profile")), } } @@ -187,6 +195,10 @@ func (k *keyMap) applyContext(s *screen) { // key stays free for Params (captured values) on statement detail. k.Progress.SetEnabled(maint) + // W opens the wait-event profile over the activity table's sample stream. + k.WaitProfile.SetEnabled(activity) + k.waitProfileInFooter = activity + // s seeks on the index-tuples view: a key value on B-tree, a heap block // number on BRIN. GiST/GIN keys have no total order, so seek is disabled // there (use the / filter). The physical key is otherwise ShowQuery @@ -203,6 +215,9 @@ func (k keyMap) ShortHelp() []key.Binding { if k.shmemInFooter { b = append(b, k.ShmemMap) } + if k.waitProfileInFooter { + b = append(b, k.WaitProfile) + } return b } @@ -213,7 +228,7 @@ func (k keyMap) FullHelp() [][]key.Binding { {k.Filter, k.Seek, k.SortPrev, k.SortNext, k.ShowQuery, k.ReverseSort}, {k.Refresh, k.ToggleBloat, k.Install, k.Describe, k.DiskUsage}, {k.Rebaseline, k.ToggleRefresh, k.Params, k.Execute, k.Verbose, k.Export}, - {k.ActivityFilter, k.CancelBackend, k.TerminateBackend, k.LockTree}, + {k.ActivityFilter, k.CancelBackend, k.TerminateBackend, k.LockTree, k.WaitProfile}, {k.SaveSnapshot, k.Snapshots, k.DeleteSnapshot, k.Columns, k.WALByRelation, k.ShmemMap}, {k.Help, k.Quit}, } diff --git a/internal/tui/layout.go b/internal/tui/layout.go index 0b75a3b..7221854 100644 --- a/internal/tui/layout.go +++ b/internal/tui/layout.go @@ -132,9 +132,13 @@ func barReserve(s *screen) int { // cursor + offset + len + nulls/vars flags + ctid + key preview const idxTupleReserve = 2 + 6 + 8 + 8 + 14 + 4 return idxTupleReserve - case levelDescribe: - // Plain-text panel — no bar drawn, so no space needs reserving. + case levelDescribe, levelTriage: + // Plain-text panels — no bar drawn, so no space needs reserving. return 0 + case levelWaitProfile: + // cursor + share% + sparkline + class name + gloss text + return colCursor + waitPctColW + colGutter + waitSparkColW + colGutter + + colName + colDetail case levelWAL: // cursor + bar(brackets) + combined + record + fpi + count + mark + name return colCursor + colBrackets + walColCombined + colGutter + diff --git a/internal/tui/sparkline.go b/internal/tui/sparkline.go new file mode 100644 index 0000000..8af0df9 --- /dev/null +++ b/internal/tui/sparkline.go @@ -0,0 +1,49 @@ +package tui + +import ( + "math" + "strings" +) + +// sparkChars is the 8-step block ramp a sparkline cell scales into. +var sparkChars = [...]rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} + +// sparkline renders vals as one glyph per value across exactly width cells, +// scaled 0..max(vals, scaleMax). Only the most recent width values are shown; +// shorter histories are left-padded with spaces so the trace fills in from +// the right as data accumulates. Pass scaleMax > 0 to share a scale across +// rows (e.g. class shares all scaled to the same max); 0 self-scales. +func sparkline(vals []float64, width int, scaleMax float64) string { + if width <= 0 { + return "" + } + if len(vals) > width { + vals = vals[len(vals)-width:] + } + mx := scaleMax + for _, v := range vals { + if v > mx { + mx = v + } + } + var b strings.Builder + b.WriteString(strings.Repeat(" ", width-len(vals))) + for _, v := range vals { + if mx <= 0 || v <= 0 { + b.WriteRune(sparkChars[0]) + continue + } + // Ceil so any nonzero value lands at least one step up only when it + // crosses a bucket boundary: v==mx maps to the top glyph, v==mx/8 to + // the bottom one. + idx := int(math.Ceil(v/mx*float64(len(sparkChars)))) - 1 + if idx >= len(sparkChars) { + idx = len(sparkChars) - 1 + } + if idx < 0 { + idx = 0 + } + b.WriteRune(sparkChars[idx]) + } + return b.String() +} diff --git a/internal/tui/sparkline_test.go b/internal/tui/sparkline_test.go new file mode 100644 index 0000000..4a4e0b9 --- /dev/null +++ b/internal/tui/sparkline_test.go @@ -0,0 +1,26 @@ +package tui + +import "testing" + +func TestSparkline(t *testing.T) { + tests := []struct { + name string + vals []float64 + width int + scaleMax float64 + want string + }{ + {"empty pads", nil, 4, 0, " "}, + {"zero width", []float64{1}, 0, 0, ""}, + {"all zero no panic", []float64{0, 0, 0}, 3, 0, "▁▁▁"}, + {"ramp orders glyphs", []float64{1, 2, 3, 4, 5, 6, 7, 8}, 8, 0, "▁▂▃▄▅▆▇█"}, + {"short history left-pads", []float64{8, 8}, 4, 0, " ██"}, + {"long history keeps most recent", []float64{8, 8, 8, 1, 2}, 2, 0, "▄█"}, + {"shared scale", []float64{1, 1}, 2, 8, "▁▁"}, + } + for _, tt := range tests { + if got := sparkline(tt.vals, tt.width, tt.scaleMax); got != tt.want { + t.Errorf("%s: sparkline(%v, %d, %v) = %q, want %q", tt.name, tt.vals, tt.width, tt.scaleMax, got, tt.want) + } + } +} diff --git a/internal/tui/update.go b/internal/tui/update.go index 778ec00..17197c9 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -163,6 +163,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tableOverviewLoadedMsg: return m, m.onTableOverviewLoaded(msg) + case triageLoadedMsg: + return m, m.onTriageLoaded(msg) + case tea.KeyMsg: return m.handleKey(msg) } diff --git a/internal/tui/update_drill.go b/internal/tui/update_drill.go index 362c67f..724ece3 100644 --- a/internal/tui/update_drill.go +++ b/internal/tui/update_drill.go @@ -411,6 +411,33 @@ func (m *Model) drillIn() tea.Cmd { next := &screen{level: levelStatementDetail, title: "query", tool: s.tool, db: s.db, loading: true} m.stack = append(m.stack, next) return m.loadActivityStatementCmd(s.db, backendPID, cur.statQueryID, queryText) + case levelTriage: + // Drill into the screen that backs the selected triage line. The + // collapsed "N checks ok" summary row carries no TriageResult and is + // inert. + r, ok := cur.data.(pg.TriageResult) + if !ok { + return nil + } + switch r.Target { + case pg.TriageTargetLockTree: + m.stack = append(m.stack, &screen{ + level: levelLockTree, title: "lock tree", tool: toolActivity, + db: s.db, loading: true, + }) + return m.loadCurrent() + case pg.TriageTargetMaintenance: + m.stack = append(m.stack, m.toolEntryScreen(toolMaintenance)) + return m.loadCurrent() + default: + for i := range pg.Diagnostics { + if pg.Diagnostics[i].Key == r.DiagKey { + m.stack = append(m.stack, diagnosticResultScreen(&pg.Diagnostics[i], "", false)) + return m.loadCurrent() + } + } + return nil + } case levelParts: // Only the heap row drills further — into per-column space estimates. // Toast and index rows have no meaningful sub-breakdown. @@ -635,6 +662,10 @@ func (m *Model) toolEntryScreen(t tool) *screen { case toolMaintenance: // Maintenance dashboard is cluster-wide: skip the database picker. return &screen{level: levelMaintenance, title: "system overview", tool: toolMaintenance, db: m.client.DefaultDB()} + case toolTriage: + // Triage runs its battery cluster-wide: skip the database picker and go + // straight to the report, loading asynchronously (loadTriageCmd). + return &screen{level: levelTriage, title: "triage", tool: toolTriage, db: m.client.DefaultDB(), loading: true} case toolActivity: // Activity tool is cluster-wide: skip the database picker and go // directly to the live pg_stat_activity list. diff --git a/internal/tui/update_keys.go b/internal/tui/update_keys.go index 72ce9b4..427911d 100644 --- a/internal/tui/update_keys.go +++ b/internal/tui/update_keys.go @@ -196,7 +196,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { s.level == levelWALRelations || s.level == levelWALRelBlocks || s.level == levelStatements || s.level == levelStatementDetail || s.level == levelSnapshots || s.level == levelMaintenance || s.level == levelSettings || - s.level == levelActivity || s.level == levelTableStats { + s.level == levelActivity || s.level == levelTableStats || s.level == levelWaitProfile { m.showInfo = !m.showInfo if m.showInfo { m.infoOffset = 0 // always open scrolled to the top @@ -310,6 +310,18 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.loadCurrent() } } + case key.Matches(msg, m.keys.WaitProfile): + // Open the wait-event profile over the activity sample stream. No load + // Cmd: it renders from Model.waitRing, which the activity tick keeps + // feeding while the profile is on top (see onActivityTick). + if s.level == levelActivity { + next := &screen{ + level: levelWaitProfile, title: "wait profile", tool: toolActivity, + db: s.db, loaded: true, + } + m.stack = append(m.stack, next) + return m, nil + } case key.Matches(msg, m.keys.Progress): // Open the live progress monitor over the pg_stat_progress_* views. // Enabled only on levelMaintenance, so it never shadows Params. diff --git a/internal/tui/update_load.go b/internal/tui/update_load.go index e4c560b..34d2c71 100644 --- a/internal/tui/update_load.go +++ b/internal/tui/update_load.go @@ -235,6 +235,8 @@ func (m *Model) loadCurrent() tea.Cmd { return tea.Batch(cmds...) case levelTableStats: return m.loadTableOverviewCmd(s.db, s.schema) + case levelTriage: + return m.loadTriageCmd() case levelProgress: // Same live-refresh pattern as the activity table, reusing its tick loop. cmds := []tea.Cmd{m.loadProgressCmd(s.db)} diff --git a/internal/tui/update_msgs.go b/internal/tui/update_msgs.go index e868cac..82b9717 100644 --- a/internal/tui/update_msgs.go +++ b/internal/tui/update_msgs.go @@ -547,6 +547,9 @@ func (m *Model) onActivityLoaded(msg activityLoadedMsg) tea.Cmd { s.actHosts = make(map[string]string) } m.rebuildActivityItems(s) + // Feed the wait-event profile: every snapshot becomes one histogram bucket, + // whether or not the profile screen is open. + m.pushWaitBucket(msg.rows) // Collect PIDs for proc sampling and IPs for DNS resolution, both in background. pids := make([]int32, len(msg.rows)) @@ -587,7 +590,8 @@ func (m *Model) onActivityTick() tea.Cmd { // lock-tree child, or the progress monitor. Stop when they navigate fully // away so re-entry can start a fresh loop. top := m.top() - if top.level != levelActivity && top.level != levelLockTree && top.level != levelProgress { + if top.level != levelActivity && top.level != levelLockTree && top.level != levelProgress && + top.level != levelWaitProfile { m.activityTicking = false return nil } diff --git a/internal/tui/update_msgs_triage.go b/internal/tui/update_msgs_triage.go new file mode 100644 index 0000000..b1c8e8d --- /dev/null +++ b/internal/tui/update_msgs_triage.go @@ -0,0 +1,46 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "pgdu/internal/pg" +) + +func (m *Model) onTriageLoaded(msg triageLoadedMsg) tea.Cmd { + s := m.findLevel(levelTriage) + if s == nil { + return nil + } + s.loading = false + s.loaded = true + s.triageResults = msg.results + s.items = triageItems(msg.results) + s.itemsRev++ + s.resetCursor() + return nil +} + +// triageItems flattens the (already severity-sorted) triage results into list +// rows. Green checks collapse into one trailing summary row so the eye lands +// on red first; crit/warn rows carry their TriageResult for the Enter drill. +func triageItems(results []pg.TriageResult) []item { + items := make([]item, 0, len(results)+1) + var okNames []string + for _, r := range results { + if r.Severity == pg.SevOK { + okNames = append(okNames, r.Check) + continue + } + items = append(items, item{name: r.Check, detail: r.Detail, hasChildren: true, data: r}) + } + if len(okNames) > 0 { + items = append(items, item{ + name: fmt.Sprintf("%d check(s) ok", len(okNames)), + detail: strings.Join(okNames, " · "), + }) + } + return items +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 59bc250..6bc83e0 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -167,7 +167,7 @@ func (m *Model) View() string { s.level != levelStatementResult && s.level != levelSnapshots && s.level != levelBufferDetail && s.level != levelMaintenance && s.level != levelSettings && s.level != levelActivity && s.level != levelLockTree && s.level != levelTableStats && - s.level != levelProgress: + s.level != levelProgress && s.level != levelWaitProfile: // levelDescribe never populates items — it renders from s.describe. // levelDiagnosticResult and levelStatementResult with 0 items mean the // query returned no rows, which is valid; fall through to the renderer @@ -262,6 +262,10 @@ func (m *Model) View() string { b.WriteString(m.renderLockTree(s, contentHeight)) case levelProgress: b.WriteString(m.renderProgress(s, contentHeight)) + case levelTriage: + b.WriteString(m.renderTriageList(s, contentHeight)) + case levelWaitProfile: + b.WriteString(m.renderWaitProfile(s, contentHeight)) case levelTableStats: // The table overview is a generic diagnostic-style table too. b.WriteString(m.renderDiagResult(s, contentHeight)) @@ -437,6 +441,10 @@ func (m *Model) breadcrumb() string { parts = append(parts, "system overview") case levelSettings: parts = append(parts, "settings") + case levelTriage: + parts = append(parts, "triage") + case levelWaitProfile: + parts = append(parts, "wait profile") } } out := make([]string, len(parts)) diff --git a/internal/tui/view_overlays.go b/internal/tui/view_overlays.go index 7aaf825..759f034 100644 --- a/internal/tui/view_overlays.go +++ b/internal/tui/view_overlays.go @@ -18,7 +18,7 @@ func (m *Model) hasInfoOverlay(s *screen) bool { levelWAL, levelWALRecords, levelWALBlocks, levelWALRelations, levelWALRelBlocks, levelStatements, levelStatementDetail, levelStatementSamples, levelStatementResult, levelSnapshots, levelMaintenance, levelSettings, - levelActivity, levelTableStats: + levelActivity, levelTableStats, levelWaitProfile: return true } return false @@ -75,6 +75,8 @@ func (m *Model) renderInfoOverlay(s *screen, height int) string { return m.renderActivityInfo(height) case levelTableStats: return m.renderTableStatsInfo(height) + case levelWaitProfile: + return m.renderWaitProfileInfo(height) } return "" } diff --git a/internal/tui/view_triage.go b/internal/tui/view_triage.go new file mode 100644 index 0000000..f857b8c --- /dev/null +++ b/internal/tui/view_triage.go @@ -0,0 +1,107 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + + "pgdu/internal/pg" +) + +// triageGlyph is the severity marker at the head of each triage line, coloured +// with the shared triage-severity palette (error / accent / ok). +func triageGlyph(sev pg.Severity) string { + switch sev { + case pg.SevCrit: + return lipgloss.NewStyle().Foreground(colorError).Render("✗") + case pg.SevWarn: + return lipgloss.NewStyle().Foreground(colorAccent).Render("▲") + } + return lipgloss.NewStyle().Foreground(colorOK).Render("●") +} + +// triageTargetLabel names the screen Enter drills into for a triage line, for +// the muted "↵ …" hint at the end of crit/warn rows. +func triageTargetLabel(r pg.TriageResult) string { + switch r.Target { + case pg.TriageTargetLockTree: + return "lock tree" + case pg.TriageTargetMaintenance: + return "system overview" + } + if d, ok := pg.DiagnosticByKey(r.DiagKey); ok { + return d.Title + } + return "diagnostic" +} + +// renderTriageList renders the one-key health report: severity-sorted rows of +// glyph | check | detail | drill hint, with green checks collapsed into the +// trailing summary row (see triageItems). +func (m *Model) renderTriageList(s *screen, height int) string { + var b strings.Builder + + crit, warn := 0, 0 + for _, r := range s.triageResults { + switch r.Severity { + case pg.SevCrit: + crit++ + case pg.SevWarn: + warn++ + } + } + var summary string + if crit+warn > 0 { + if crit > 0 { + summary = triageGlyph(pg.SevCrit) + " " + lipgloss.NewStyle().Foreground(colorError).Render(fmt.Sprintf("%d critical", crit)) + } + if warn > 0 { + if summary != "" { + summary += styleMuted.Render(" · ") + } + summary += triageGlyph(pg.SevWarn) + " " + lipgloss.NewStyle().Foreground(colorAccent).Render(fmt.Sprintf("%d warning(s)", warn)) + } + } else { + summary = triageGlyph(pg.SevOK) + " " + styleMuted.Render("all checks ok") + } + b.WriteString(" " + summary + "\n") + height-- + + nameW := 0 + for i := range s.items { + if n := displayWidth(s.items[i].name); n > nameW { + nameW = n + } + } + + vis := s.visibleIndexes() + rowsH := height + if rowsH > 0 { + s.offset, _ = viewportRange(s.cursor, s.offset, rowsH, len(vis)) + } + end := min(s.offset+rowsH, len(vis)) + + for vi := s.offset; vi < end; vi++ { + it := s.items[vis[vi]] + selected := vi == s.cursor + cursor := " " + name := padRight(it.name, nameW) + if selected { + cursor = styleSelected.Render("▶ ") + name = styleSelected.Render(name) + } + glyph := triageGlyph(pg.SevOK) + hint := "" + if r, ok := it.data.(pg.TriageResult); ok { + glyph = triageGlyph(r.Severity) + hint = " " + styleMuted.Render("↵ "+triageTargetLabel(r)) + } + line := cursor + glyph + " " + name + " " + styleMuted.Render(it.detail) + hint + b.WriteString(truncateToWidth(line, m.width) + "\n") + } + for i := end - s.offset; i < rowsH; i++ { + b.WriteString("\n") + } + return b.String() +} diff --git a/internal/tui/view_triage_test.go b/internal/tui/view_triage_test.go new file mode 100644 index 0000000..231a2a7 --- /dev/null +++ b/internal/tui/view_triage_test.go @@ -0,0 +1,85 @@ +package tui + +import ( + "strings" + "testing" + + "pgdu/internal/pg" +) + +func TestTriageItemsCollapsesOK(t *testing.T) { + results := []pg.TriageResult{ + {Check: "invalid indexes", Severity: pg.SevCrit, Detail: "1 index invalid", DiagKey: "index_invalid"}, + {Check: "blocked backends", Severity: pg.SevWarn, Detail: "1 waiting", Target: pg.TriageTargetLockTree}, + {Check: "cache hit ratio", Severity: pg.SevOK, Detail: "99.3%"}, + {Check: "wraparound", Severity: pg.SevOK, Detail: "41%"}, + } + items := triageItems(results) + if len(items) != 3 { + t.Fatalf("got %d items, want 3 (2 findings + 1 ok summary)", len(items)) + } + if items[0].name != "invalid indexes" || items[1].name != "blocked backends" { + t.Errorf("finding rows out of order: %q, %q", items[0].name, items[1].name) + } + if _, ok := items[0].data.(pg.TriageResult); !ok { + t.Errorf("finding row must carry its TriageResult for the Enter drill") + } + sum := items[2] + if sum.data != nil { + t.Errorf("ok summary row must carry no TriageResult (inert on Enter)") + } + if !strings.Contains(sum.name, "2 check(s) ok") { + t.Errorf("summary name = %q, want a 2-checks-ok count", sum.name) + } + if !strings.Contains(sum.detail, "cache hit ratio") || !strings.Contains(sum.detail, "wraparound") { + t.Errorf("summary detail should list the ok checks, got %q", sum.detail) + } +} + +func TestTriageItemsAllOK(t *testing.T) { + items := triageItems([]pg.TriageResult{ + {Check: "a", Severity: pg.SevOK}, + {Check: "b", Severity: pg.SevOK}, + }) + if len(items) != 1 { + t.Fatalf("got %d items, want just the summary row", len(items)) + } +} + +func TestRenderTriageList(t *testing.T) { + m := &Model{width: 120} + s := &screen{ + level: levelTriage, + loaded: true, + triageResults: []pg.TriageResult{ + {Check: "idle-in-xact", Severity: pg.SevCrit, Detail: "pid 8123 idle 11m", DiagKey: "idle_in_xact_holders"}, + {Check: "blocked backends", Severity: pg.SevWarn, Detail: "2 waiting", Target: pg.TriageTargetLockTree}, + {Check: "cache hit ratio", Severity: pg.SevOK, Detail: "99.3%"}, + }, + } + s.items = triageItems(s.triageResults) + out := stripANSI(m.renderTriageList(s, 20)) + + for _, want := range []string{ + "1 critical", "1 warning(s)", + "✗ idle-in-xact", "▲ blocked backends", + "↵ lock tree", "↵ Idle-in-transaction lock holders", + "1 check(s) ok", "cache hit ratio", + } { + if !strings.Contains(out, want) { + t.Errorf("rendered triage list missing %q:\n%s", want, out) + } + } +} + +func TestTriageTargetLabel(t *testing.T) { + if got := triageTargetLabel(pg.TriageResult{Target: pg.TriageTargetLockTree}); got != "lock tree" { + t.Errorf("lock tree label = %q", got) + } + if got := triageTargetLabel(pg.TriageResult{Target: pg.TriageTargetMaintenance}); got != "system overview" { + t.Errorf("maintenance label = %q", got) + } + if got := triageTargetLabel(pg.TriageResult{DiagKey: "nope"}); got != "diagnostic" { + t.Errorf("unknown key fallback = %q", got) + } +} diff --git a/internal/tui/view_waitprofile.go b/internal/tui/view_waitprofile.go new file mode 100644 index 0000000..a8dbebd --- /dev/null +++ b/internal/tui/view_waitprofile.go @@ -0,0 +1,223 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" +) + +// Column widths for the wait-profile ranked list. +const ( + waitPctColW = 5 // "100%" + waitSparkColW = 24 // per-class trend, one glyph per recent bucket +) + +// waitProfileMaxClasses caps the ranked list / bar at the palette size; rarer +// classes fold into a single "other" line so colours stay unambiguous. +const waitProfileMaxClasses = 10 + +// waitGloss is a short human explanation for the wait classes worth knowing on +// sight. Anything unlisted renders without a gloss. +var waitGloss = map[string]string{ + waitCPUClass: "running, not waiting", + "LWLock:WALWrite": "WAL flush contention", + "LWLock:WALInsert": "WAL insert-slot contention", + "LWLock:BufferMapping": "buffer-table lookup contention", + "IO:DataFileRead": "heap/index reads from disk", + "IO:DataFileWrite": "heap/index writes to disk", + "IO:WALWrite": "WAL write I/O", + "IO:WALSync": "WAL fsync", + "Lock:transactionid": "row-lock waits (blocked on another xact)", + "Lock:tuple": "row-lock acquisition queue", + "Lock:relation": "table-level lock waits", + "IPC:SyncRep": "waiting for synchronous replica", + "Client:ClientRead": "waiting for the client to send", + "Client:ClientWrite": "waiting for the client to receive", +} + +// waitClassAgg is one wait class aggregated over the retained window. +type waitClassAgg struct { + name string + count int // Σ samples in this class + series []float64 // per-bucket share of that bucket's total, oldest→newest +} + +// aggregateWaitClasses folds the ring into ranked per-class aggregates plus +// the total sample count. Classes beyond waitProfileMaxClasses collapse into +// a trailing "other" aggregate (its series summed likewise). +func aggregateWaitClasses(buckets []waitBucket) (classes []waitClassAgg, totalSamples int) { + counts := make(map[string]int) + for _, b := range buckets { + for class, n := range b.counts { + counts[class] += n + } + totalSamples += b.total + } + names := make([]string, 0, len(counts)) + for name := range counts { + names = append(names, name) + } + sort.Slice(names, func(a, b int) bool { + if counts[names[a]] != counts[names[b]] { + return counts[names[a]] > counts[names[b]] + } + return names[a] < names[b] + }) + + series := func(match func(class string) bool) []float64 { + out := make([]float64, len(buckets)) + for i, b := range buckets { + if b.total == 0 { + continue + } + n := 0 + for class, c := range b.counts { + if match(class) { + n += c + } + } + out[i] = float64(n) / float64(b.total) + } + return out + } + + top := names + if len(top) > waitProfileMaxClasses { + top = names[:waitProfileMaxClasses] + } + for _, name := range top { + classes = append(classes, waitClassAgg{ + name: name, + count: counts[name], + series: series(func(c string) bool { return c == name }), + }) + } + if len(names) > len(top) { + rest := names[len(top):] + inRest := make(map[string]bool, len(rest)) + restCount := 0 + for _, name := range rest { + inRest[name] = true + restCount += counts[name] + } + classes = append(classes, waitClassAgg{ + name: "other", + count: restCount, + series: series(func(c string) bool { return inRest[c] }), + }) + } + return classes, totalSamples +} + +// renderWaitProfile draws the wait-event profile: an honest window label, the +// window's stacked class-mix bar, and the ranked class list with per-class +// trend sparklines. Renders straight from Model.waitRing — the screen itself +// has no loaded data. +func (m *Model) renderWaitProfile(s *screen, height int) string { + var b strings.Builder + lines := 0 + put := func(line string) { + b.WriteString(truncateToWidth(line, m.width) + "\n") + lines++ + } + + if m.waitRing == nil || m.waitRing.n == 0 { + put(" " + styleMuted.Render("no samples yet — the profile fills as the Activity view refreshes")) + for ; lines < height; lines++ { + b.WriteString("\n") + } + return b.String() + } + + buckets := m.waitRing.ordered() + classes, totalSamples := aggregateWaitClasses(buckets) + + // Window label: span, sample count, cadence. Honest about granularity — + // this is a sampled profile, not a continuous trace. + span := buckets[len(buckets)-1].at.Sub(buckets[0].at).Round(time.Second) + cadence := "paused" + if m.activityRefresh > 0 { + cadence = m.activityRefresh.String() + } + put(" " + styleMuted.Render(fmt.Sprintf( + "window: last %s · %d snapshots · %d samples · cadence %s", + span, len(buckets), totalSamples, cadence))) + put("") + + // Stacked class-mix bar over the whole window (integer truncation leaves at + // most a few cells of ░ tail — paintBar pads). + barW := min(m.width-4, summaryBarMax) + if barW > 0 && totalSamples > 0 { + segs := make([]barSegment, 0, len(classes)) + for i, c := range classes { + segs = append(segs, barSegment{cells: c.count * barW / totalSamples, style: waitClassStyle(i, c.name)}) + } + put(" " + paintBar(barW, segs...)) + // Legend: colour-matched class names in rank order. + legend := make([]string, 0, len(classes)) + for i, c := range classes { + legend = append(legend, waitClassStyle(i, c.name).Render(c.name)) + } + put(" " + strings.Join(legend, styleMuted.Render(" · "))) + put("") + } + + if totalSamples == 0 { + put(" " + styleMuted.Render("all snapshots were idle — nothing was running or waiting")) + } + + for i, c := range classes { + pct := 0.0 + if totalSamples > 0 { + pct = 100 * float64(c.count) / float64(totalSamples) + } + gloss := "" + if g, ok := waitGloss[c.name]; ok { + gloss = " " + styleMuted.Render(g) + } + style := waitClassStyle(i, c.name) + put(" " + padLeft(fmt.Sprintf("%.0f%%", pct), waitPctColW) + " " + + style.Render(sparkline(c.series, waitSparkColW, 0)) + " " + + style.Render(c.name) + gloss) + } + + for ; lines < height; lines++ { + b.WriteString("\n") + } + return b.String() +} + +// waitClassStyle colours a ranked wait class: the shared slice palette by +// rank, with the fold-over "other" line muted. +func waitClassStyle(rank int, name string) lipgloss.Style { + if name == "other" { + return styleMuted + } + return bufferSliceStyle(rank) +} + +// renderWaitProfileInfo is the ? reference for the wait profile. +func (m *Model) renderWaitProfileInfo(height int) string { + mu := styleMuted.Render + var b strings.Builder + infoHeader(&b, "Wait-profile reference") + + b.WriteString(" " + styleHeader.Render(" what you're seeing ") + "\n") + b.WriteString(" " + mu("Every Activity refresh samples pg_stat_activity; each non-idle backend is bucketed by") + "\n") + b.WriteString(" " + mu("wait_event_type:wait_event (or \""+waitCPUClass+"\" when it runs with no wait event).") + "\n") + b.WriteString(" " + mu("The bar and percentages are each class's share of all samples in the retained window —") + "\n") + b.WriteString(" " + mu("\"where did time go\", pg_wait_sampling-style, with no extension required.") + "\n\n") + + b.WriteString(" " + styleHeader.Render(" reading it honestly ") + "\n") + b.WriteString(" " + mu("This is a sampled profile, not a continuous trace: waits that start and end between two") + "\n") + b.WriteString(" " + mu("refreshes are invisible, and short spikes are under-represented at slow cadences.") + "\n") + b.WriteString(" " + mu("Cycle a faster cadence with ") + styleBadge.Render("t") + mu(" on the Activity view (500ms is supported) while profiling.") + "\n") + b.WriteString(" " + mu(fmt.Sprintf("Retention is bounded at %d snapshots; older buckets fall off the back.", waitRingCap)) + "\n\n") + + b.WriteString(" " + styleHeader.Render(" list columns ") + "\n") + b.WriteString(" " + mu("share of window · per-snapshot trend (sparkline, self-scaled) · wait class · gloss") + "\n") + return b.String() +} diff --git a/internal/tui/waitprofile.go b/internal/tui/waitprofile.go new file mode 100644 index 0000000..32f686c --- /dev/null +++ b/internal/tui/waitprofile.go @@ -0,0 +1,91 @@ +package tui + +import ( + "time" + + "pgdu/internal/pg" +) + +// waitCPUClass is the bucket for backends running with no wait event — time +// attributed to "on CPU" rather than any wait class. +const waitCPUClass = "CPU (running)" + +// waitRingCap bounds the sample retention: 600 buckets is 5 minutes at the +// fastest 500ms cadence (longer at slower cadences). Fixed so a long-lived +// session can't grow the profile without bound. +const waitRingCap = 600 + +// waitBucket is one sampled snapshot: non-idle backend counts per wait class +// ("LWLock:WALWrite", "IO:DataFileRead", waitCPUClass, …) at one activity tick. +type waitBucket struct { + at time.Time + counts map[string]int + total int +} + +// waitRing is a fixed-capacity circular buffer of waitBucket. +type waitRing struct { + buf []waitBucket + head int // next write position + n int // valid entries (≤ cap) +} + +func newWaitRing() *waitRing { return &waitRing{buf: make([]waitBucket, waitRingCap)} } + +func (r *waitRing) push(b waitBucket) { + r.buf[r.head] = b + r.head = (r.head + 1) % len(r.buf) + if r.n < len(r.buf) { + r.n++ + } +} + +// ordered returns the retained buckets oldest→newest. +func (r *waitRing) ordered() []waitBucket { + out := make([]waitBucket, 0, r.n) + start := (r.head - r.n + len(r.buf)) % len(r.buf) + for i := range r.n { + out = append(out, r.buf[(start+i)%len(r.buf)]) + } + return out +} + +// classifyWait buckets one non-idle backend: running with no wait event is +// CPU time; everything else keys on wait_event_type:wait_event. +func classifyWait(r pg.ActivityRow) string { + if r.WaitEventType == "" { + return waitCPUClass + } + return r.WaitEventType + ":" + r.WaitEvent +} + +// waitSampleRow reports whether an activity row belongs in the wait profile: +// non-idle backends only. Idle backends are parked on the client — counting +// them would drown the histogram in Client:ClientRead. +func waitSampleRow(r pg.ActivityRow) bool { + switch r.State { + case "", "idle", "idle in transaction", "idle in transaction (aborted)": + return false + } + return true +} + +// pushWaitBucket folds one activity snapshot into the Model's ring. Sampling +// is always on (the per-tick cost is one small map over ≤ a few hundred rows) +// so the profile already has history the first time W is pressed. An +// all-idle snapshot still pushes an empty bucket: elapsed window time with +// nothing running is real information, not a gap. +func (m *Model) pushWaitBucket(rows []pg.ActivityRow) { + if m.waitRing == nil { + m.waitRing = newWaitRing() + } + b := waitBucket{at: time.Now(), counts: make(map[string]int)} + for _, r := range rows { + if !waitSampleRow(r) { + continue + } + b.counts[classifyWait(r)]++ + b.total++ + } + m.waitRing.push(b) +} diff --git a/internal/tui/waitprofile_test.go b/internal/tui/waitprofile_test.go new file mode 100644 index 0000000..3d7f376 --- /dev/null +++ b/internal/tui/waitprofile_test.go @@ -0,0 +1,107 @@ +package tui + +import ( + "fmt" + "testing" + "time" + + "pgdu/internal/pg" +) + +func TestWaitRingWraparound(t *testing.T) { + r := newWaitRing() + for i := range waitRingCap + 5 { + r.push(waitBucket{at: time.Unix(int64(i), 0), total: i}) + } + got := r.ordered() + if len(got) != waitRingCap { + t.Fatalf("ordered() returned %d buckets, want %d", len(got), waitRingCap) + } + // Oldest entries evicted: the window starts at push #5 and stays in order. + for i, b := range got { + if want := i + 5; b.total != want { + t.Fatalf("bucket %d out of order: total %d, want %d", i, b.total, want) + } + } +} + +func TestClassifyWait(t *testing.T) { + if got := classifyWait(pg.ActivityRow{State: "active"}); got != waitCPUClass { + t.Errorf("active without wait = %q, want %q", got, waitCPUClass) + } + row := pg.ActivityRow{State: "active", WaitEventType: "LWLock", WaitEvent: "WALWrite"} + if got := classifyWait(row); got != "LWLock:WALWrite" { + t.Errorf("classifyWait = %q, want LWLock:WALWrite", got) + } +} + +func TestWaitSampleRow(t *testing.T) { + for _, state := range []string{"", "idle", "idle in transaction", "idle in transaction (aborted)"} { + if waitSampleRow(pg.ActivityRow{State: state}) { + t.Errorf("state %q should be excluded from sampling", state) + } + } + if !waitSampleRow(pg.ActivityRow{State: "active"}) { + t.Errorf("active backends must be sampled") + } +} + +func TestPushWaitBucket(t *testing.T) { + m := &Model{} + m.pushWaitBucket([]pg.ActivityRow{ + {State: "active"}, + {State: "active", WaitEventType: "IO", WaitEvent: "DataFileRead"}, + {State: "idle"}, + }) + if m.waitRing == nil || m.waitRing.n != 1 { + t.Fatalf("expected one bucket after first push") + } + b := m.waitRing.ordered()[0] + if b.total != 2 { + t.Errorf("bucket total = %d, want 2 (idle excluded)", b.total) + } + if b.counts[waitCPUClass] != 1 || b.counts["IO:DataFileRead"] != 1 { + t.Errorf("unexpected counts: %v", b.counts) + } +} + +func TestAggregateWaitClasses(t *testing.T) { + buckets := []waitBucket{ + {counts: map[string]int{"CPU (running)": 3, "IO:DataFileRead": 1}, total: 4}, + {counts: map[string]int{"IO:DataFileRead": 3, "Lock:tuple": 1}, total: 4}, + } + classes, total := aggregateWaitClasses(buckets) + if total != 8 { + t.Fatalf("total = %d, want 8", total) + } + if len(classes) != 3 { + t.Fatalf("got %d classes, want 3", len(classes)) + } + // Ranked by count: DataFileRead(4), CPU(3), tuple(1). + if classes[0].name != "IO:DataFileRead" || classes[0].count != 4 { + t.Errorf("rank 0 = %s/%d, want IO:DataFileRead/4", classes[0].name, classes[0].count) + } + // Series carries per-bucket shares oldest→newest. + if classes[0].series[0] != 0.25 || classes[0].series[1] != 0.75 { + t.Errorf("DataFileRead series = %v, want [0.25 0.75]", classes[0].series) + } +} + +func TestAggregateWaitClassesFoldsOther(t *testing.T) { + counts := make(map[string]int, waitProfileMaxClasses+3) + for i := range waitProfileMaxClasses + 3 { + counts[fmt.Sprintf("Lock:class%02d", i)] = 100 - i + } + classes, _ := aggregateWaitClasses([]waitBucket{{counts: counts, total: 100}}) + if len(classes) != waitProfileMaxClasses+1 { + t.Fatalf("got %d classes, want %d + other", len(classes), waitProfileMaxClasses) + } + last := classes[len(classes)-1] + if last.name != "other" { + t.Fatalf("last class = %q, want other", last.name) + } + wantOther := (100 - waitProfileMaxClasses) + (100 - waitProfileMaxClasses - 1) + (100 - waitProfileMaxClasses - 2) + if last.count != wantOther { + t.Errorf("other count = %d, want %d", last.count, wantOther) + } +} From a54cc9cb5862a740d6aff1a139cf20a33d02bde6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20D=C3=B6tsch?= Date: Sun, 5 Jul 2026 10:31:41 +0200 Subject: [PATCH 5/6] docs: drop future-planning notes for shipped diagnostics features health-triage, progress-monitor, and wait-event-profiler all landed in aee27be; the future/ planning docs describing them are now stale. --- docs/future/health-triage.md | 68 ------------------------------ docs/future/progress-monitor.md | 64 ---------------------------- docs/future/wait-event-profiler.md | 68 ------------------------------ 3 files changed, 200 deletions(-) delete mode 100644 docs/future/health-triage.md delete mode 100644 docs/future/progress-monitor.md delete mode 100644 docs/future/wait-event-profiler.md diff --git a/docs/future/health-triage.md b/docs/future/health-triage.md deleted file mode 100644 index c4cc15c..0000000 --- a/docs/future/health-triage.md +++ /dev/null @@ -1,68 +0,0 @@ -# Future: one-key health triage report - -Status: implemented ("Health triage" tool on the root picker → levelTriage; see internal/pg/triage.go). - -## Motivation - -A DBA opening pgdu on an unfamiliar or misbehaving server wants a single "what's -wrong right now?" answer before drilling into any one tool. Today that means -running eight diagnostics by hand. A one-key triage runs a curated battery -concurrently and returns a red/yellow/green checklist, each line drilling into -the diagnostic (or tool) that backs it. - -## The battery - -Reuse existing diagnostics/queries so the thresholds live in one place: - -| Check | Source | Red when … | -|--------------------------|-----------------------------------------------------|-------------------------------------| -| Transaction wraparound | `sqlMaintWraparound` / per-table `FreezeFrac` | age > 80% of autovacuum_freeze_max | -| Blocked backends | `ListLockWaiters` (D1) | any backend waiting > 30s | -| Idle-in-transaction | `idle_in_xact_holders` diagnostic | oldest xact > 5m | -| Replication lag / slots | `replication_slots` diagnostic | inactive slot or retained WAL > cap | -| Cache hit ratio | `database_stats` diagnostic | hit% < 90 | -| SLRU pressure | `slru_stats` diagnostic | any hit% < 90 with heavy reads | -| Sequence exhaustion | `sequences` diagnostic | consumed% > 80 | -| Bloat (top-N) | `bloat_table` / `bloat_index` | any > 50% and large | -| Invalid indexes | new small query on `pg_index.indisvalid = false` | any exist | -| Temp-file / deadlocks | `database_stats` | rising deadlocks / large temp_bytes | - -## UX sketch - -Selecting "Health triage" on `levelTools` runs the battery and pushes a -`levelTriage` screen: - -``` -● wraparound oldest datfrozenxid 41% of freeze_max ok -▲ blocked backends 2 backends waiting (longest 48s) warn → ↵ lock tree -✗ idle-in-xact pid 8123 idle in transaction 11m crit → ↵ diagnostic -● cache hit ratio 99.3% ok -✗ invalid indexes 1 index left INVALID by a failed build crit → ↵ describe -``` - -Enter drills into the backing diagnostic/tool for the selected line. Sort by -severity; green lines collapse to a summary count so the eye lands on red first. - -## Wiring outline - -- `internal/pg/triage.go`: a `Triage(ctx)` that fans the checks out concurrently - (each is an existing `RunDiagnostic` / method call) under one budget, returning - `[]TriageResult{Check, Severity, Detail, DiagKey}`. -- `levelTriage` enum; `view_triage.go` renderer (reuse `stateStyle`-like - severity colours and the graded styles from `styles.go`). -- `toolTriage` entry on the root tool picker → `toolEntryScreen` pushes - `levelTriage`; Enter maps `DiagKey` back to a `diagnosticResultScreen` push - (or the lock tree for the blocked-backends line). - -## Effort - -~4–5 days. The value is in curating thresholds, not new plumbing — every check -already has a query. Do it after the graded-kind work (bucket A4) so triage lines -reuse the same colour language. - -## Risks - -- Threshold opinions invite bikeshedding; keep them as named constants in - `triage.go` with a comment justifying each, and treat them as tunable. -- Concurrency under one 30s budget: cap the fan-out and let a slow/failed check - degrade to a "could not evaluate" line rather than failing the whole report. diff --git a/docs/future/progress-monitor.md b/docs/future/progress-monitor.md deleted file mode 100644 index 2bce8e8..0000000 --- a/docs/future/progress-monitor.md +++ /dev/null @@ -1,64 +0,0 @@ -# Future: unified live progress monitor - -Status: implemented — `levelProgress` (`internal/tui/view_progress.go`), opened -with `p` from the maintenance dashboard, live-refreshing on the activity tick. -The `progress_all` diagnostic (`internal/pg/queries_diag.go`, registered in -`diagnostic_defs.go`) remains as the point-in-time variant under -**Other Tools → Running operations (progress)**; both share `sqlProgressBase`. - -## Motivation - -Watching a long migration (`CREATE INDEX CONCURRENTLY`), a manual `VACUUM -FULL`/`CLUSTER`, a big `COPY`, or a base backup is a common "is it nearly done?" -question. The diagnostic result is a point-in-time snapshot — you have to press -refresh. A dedicated live level would auto-refresh and draw a real progress bar -per operation, so you can leave it open and watch a migration crawl to 100%. - -## Data sources - -Reuse `sqlDiagProgressAll` as-is (it already normalizes pid, command, relation, -phase, `done_pct`, `running_for`, username across -`pg_stat_progress_{vacuum,create_index,analyze,cluster,copy,basebackup}`). For a -richer bar, extend it to also return `done`/`total` raw counters so the bar can -render blocks-done/blocks-total instead of only the percentage. - -## UX sketch - -A new `levelProgress` under `toolMaintenance`, opened with `p` from -`levelMaintenance` (mirrors the B5 cross-links). One row per running operation: - -``` -CREATE INDEX public.orders_created_idx building index: 3 of 5 [██████████░░░░] 64% 4m12s -VACUUM public.events scanning heap [███░░░░░░░░░░░] 22% 1m03s -``` - -Auto-refresh on the Activity tool's tick cadence (reuse `activityTick` / -`cycleActivityRefresh`, gate the tick on `levelProgress` like the lock tree -does). Empty state: "no operations in progress". `d` describes the target -relation (reuse `describeTarget`'s by-name path). - -## Wiring outline (per CLAUDE.md "Adding a new entity") - -- `levelProgress` enum in `app.go`; screen fields `progressRows []pg.ProgressRow`. -- `internal/pg/progress.go` + a `ProgressRow` type; a `ListProgress(ctx, db)` - method over the extended `sqlDiagProgressAll`. -- Cmd `loadProgressCmd` in `cmds_maintenance.go` via the `query()` helper; - `progressLoadedMsg` handled in `update_msgs.go`. -- Reuse `paintBar` / `barSegment` (`row.go`) for the per-row progress bar. -- `view_progress.go` renderer; dispatch in `view.go`; `p` key enabled on - `levelMaintenance` in `keys.go`. -- Extend `onActivityTick` (or add a `progressTick`) to re-fire `loadProgressCmd` - while `levelProgress` is on top. - -## Effort - -~1–2 days. All the hard SQL exists; it's a renderer + a live tick loop, both of -which have close templates (lock tree, activity). - -## Risks - -- Low. `pg_stat_progress_*` rows vanish the instant an operation finishes, so the - list will flicker empty at completion — acceptable, but worth a brief "just - finished" grace note if it feels abrupt. -- `basebackup` has no `relid`; the relation column is blank for it (already - handled in the SQL). diff --git a/docs/future/wait-event-profiler.md b/docs/future/wait-event-profiler.md deleted file mode 100644 index f0eef13..0000000 --- a/docs/future/wait-event-profiler.md +++ /dev/null @@ -1,68 +0,0 @@ -# Future: wait-event sampling profiler ("poor man's ASH") - -Status: implemented (`W` on the Activity view → levelWaitProfile; see internal/tui/waitprofile.go). - -## Motivation - -`pg_stat_activity.wait_event` tells you what each backend is blocked on *right -now*, but a single glance misses the pattern. Oracle's ASH and pg_wait_sampling -answer "where did time actually go?" by sampling wait events over a window. pgdu -already polls `pg_stat_activity` on a timer for the Activity tool — piggybacking -a wait-event histogram on that loop turns those samples into a cheap, no-extension -time profile: "62% LWLock:WALWrite, 20% IO:DataFileRead, 12% running, 6% Lock". - -## Data sources - -No new query in the simple form: reuse each Activity refresh's -`pg_stat_activity` rows. On every tick, bucket each non-idle backend by -`wait_event_type:wait_event` (or "CPU/active" when running with no wait) into a -ring buffer of counts. The histogram is `Σ samples per class ÷ total samples` -over the retained window. - -For higher fidelity later, an optional dedicated query -`SELECT wait_event_type, wait_event, count(*) FROM pg_stat_activity WHERE state -!= 'idle' GROUP BY 1,2` decouples sampling cadence from the table refresh. - -## UX sketch - -`W` on `levelActivity` opens `levelWaitProfile`: a horizontal stacked bar (the -window's wait-class mix) over a ranked list, plus a sparkline-per-class of the -last N buckets so you can see a spike arrive. - -``` -window: last 5m · 300 samples · 1s cadence -[████████████ WALWrite ██████ DataFileRead ███ CPU ██ Lock ░ other] - -LWLock:WALWrite 61% ▁▂▃▅▇▇▅▃ WAL flush contention -IO:DataFileRead 19% ▁▁▂▂▃▂▁▁ heap/index reads from disk -(running, no wait) 12% ▃▃▂▃▄▃▂▃ -Lock:transactionid 6% ▁▁▂▁▁▁▁▁ row-lock waits -``` - -## Wiring outline - -- Ring buffer on the `Model` (not `screen`, since it accumulates across the tool's - lifetime): `waitSamples []waitBucket` with a fixed capacity and a head index. -- Hook the accumulation into `onActivityLoaded` (each tick already delivers the - rows) — no new command needed for the simple form. -- `levelWaitProfile` enum + `view_waitprofile.go` (reuse `paintBar` for the - stacked class bar; a tiny sparkline helper for the per-class trend). -- `W` key enabled on `levelActivity`; the profile keeps updating on the same - Activity tick while open. - -## Effort - -~2–3 days for the piggybacked version; +1 for the dedicated-query / sparkline -polish. - -## Risks - -- **Fidelity is bounded by cadence.** At 2s ticks you sample coarsely; short - spikes between ticks are invisible. Label the window honestly ("300 samples @ - 1s") so nobody reads it as continuous ASH. Encourage a faster cadence (500ms, - already supported) while profiling. -- **Buffer sizing.** A 5-minute window at 500ms is 600 buckets × a handful of - classes — trivial memory, but make the retention explicit and bounded so it - can't grow unbounded on a long-lived session. -- Sampling bias: a backend that waits *between* ticks never appears. Acceptable - for a "poor man's" profiler; note it in the `?` overlay. From b742e7717d20b4c30515e9145294f95712913038 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20D=C3=B6tsch?= Date: Sun, 5 Jul 2026 11:11:40 +0200 Subject: [PATCH 6/6] pg: split temp-files/deadlocks triage check, add 7 health checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deadlocks and temp-file spill were graded as one line even though they're unrelated signals sharing only the database_stats view — a clean deadlock count could hide critical temp-file spill under a single yellow/green verdict. Split into independent "deadlocks" and "temp files" checks. Added WAL archiver failure, connection saturation, checkpoint pressure, prepared-transaction (2PC) leak, rollback ratio, stale planner statistics, and FK-missing-index checks. All reuse data pgdu already fetches (mostly MaintenanceInfo, already pulled for the wraparound check) rather than new SQL. Triage now fetches MaintenanceInfo and database_stats once up front and runs the dependent checks as pure graders over the shared snapshot, instead of hitting the server once per check. --- internal/pg/triage.go | 327 +++++++++++++++++++++++++++++++++---- internal/pg/triage_test.go | 90 +++++++++- 2 files changed, 377 insertions(+), 40 deletions(-) diff --git a/internal/pg/triage.go b/internal/pg/triage.go index 2e40c02..592f7ed 100644 --- a/internal/pg/triage.go +++ b/internal/pg/triage.go @@ -86,6 +86,31 @@ const ( tempBytesWarn = 10 << 30 tempBytesCrit = 100 << 30 + // connection saturation: fraction of max_connections in use. Past ~80% a + // spike risks "too many clients"; superuser-reserved slots are the last line. + connSaturationWarnFrac = 0.80 + connSaturationCritFrac = 0.95 + + // checkpoints: a high share of "requested" (as opposed to timed) checkpoints + // means WAL volume keeps hitting max_wal_size before checkpoint_timeout — + // max_wal_size is too small. The floor keeps a freshly-started cluster (where + // the first checkpoint is often requested) green until there's a real sample. + checkpointReqWarnFrac = 0.30 + checkpointReqCritFrac = 0.50 + checkpointMinTotal = 10 + + // prepared (2PC) transactions: any open prepared xact pins the xmin horizon + // and delays autovacuum, so its mere presence is a warning; one left open for + // minutes is a coordinator that forgot to COMMIT/ROLLBACK. + preparedXactCritSecs = 300 + + // rollback ratio: a high share of transactions rolling back can mean app + // errors or serialization failures. Gated on a minimum volume so a nearly + // idle database (a handful of rollbacks) never trips it. + rollbackWarnFrac = 0.25 + rollbackCritFrac = 0.50 + rollbackMinXacts = 1000 + // fan-out: enough parallelism to finish fast without stampeding a server // that is already unwell; each check also gets its own sub-budget so one // hung catalog query degrades to "could not evaluate" instead of eating @@ -111,17 +136,82 @@ func (c *Client) Triage(ctx context.Context) []TriageResult { } db := c.DefaultDB() + + // Shared inputs, each an expensive one-shot, are fetched once here and handed + // to the pure graders below — several checks read the same MaintenanceInfo or + // database_stats view, and calling those N times would multiply the load on a + // server we may already suspect is unwell. + var ( + info *MaintenanceInfo + infoErr error + dbStats *DiagResult + dbErr error + ) + var pre errgroup.Group + pre.Go(func() error { + cctx, cancel := context.WithTimeout(ctx, triageCheckTimeout) + defer cancel() + info, infoErr = c.Maintenance(cctx, "") + // Maintenance is best-effort: it absorbs every sub-query failure and + // returns a zero struct with a nil error when the connection is dead. + // version() is always populated over a live connection, so an empty + // Version means "unreachable" — degrade every MaintenanceInfo-backed + // check uniformly instead of reporting false greens. + if infoErr == nil && (info == nil || info.Version == "") { + infoErr = errors.New("server unreachable") + } + return nil + }) + pre.Go(func() error { + cctx, cancel := context.WithTimeout(ctx, triageCheckTimeout) + defer cancel() + dbStats, dbErr = c.runTriageDiag(cctx, "", "database_stats") + return nil + }) + _ = pre.Wait() + + // mgrade/dgrade adapt a pure grader over a shared input into a check.run, + // short-circuiting to "could not evaluate" when that input failed to load. + mgrade := func(g func(*MaintenanceInfo) (Severity, string, error)) func(context.Context) (Severity, string, error) { + return func(context.Context) (Severity, string, error) { + if infoErr != nil { + return 0, "", infoErr + } + return g(info) + } + } + dgrade := func(g func(*DiagResult) (Severity, string, error)) func(context.Context) (Severity, string, error) { + return func(context.Context) (Severity, string, error) { + if dbErr != nil { + return 0, "", dbErr + } + return g(dbStats) + } + } + checks := []check{ - {"wraparound", TriageTargetMaintenance, "", c.triageWraparound}, + {"wraparound", TriageTargetMaintenance, "", mgrade(wraparoundGrade)}, + {"WAL archiver", TriageTargetMaintenance, "", mgrade(archiverGrade)}, + {"connection saturation", TriageTargetMaintenance, "", mgrade(connSaturationGrade)}, + {"checkpoint pressure", TriageTargetMaintenance, "", mgrade(checkpointGrade)}, + {"prepared transactions", TriageTargetMaintenance, "", mgrade(preparedXactGrade)}, {"blocked backends", TriageTargetLockTree, "", c.triageBlocked}, {"idle-in-xact", TriageTargetDiagnostic, "idle_in_xact_holders", c.triageIdleInXact}, {"replication slots", TriageTargetDiagnostic, "replication_slots", c.triageReplicationSlots}, - {"cache hit ratio", TriageTargetDiagnostic, "database_stats", c.triageCacheHit}, + {"cache hit ratio", TriageTargetDiagnostic, "database_stats", dgrade(cacheHitGrade)}, {"SLRU pressure", TriageTargetDiagnostic, "slru_stats", c.triageSLRU}, - {"temp files / deadlocks", TriageTargetDiagnostic, "database_stats", c.triageTempDeadlocks}, + {"deadlocks", TriageTargetDiagnostic, "database_stats", dgrade(deadlockGrade)}, + {"temp files", TriageTargetDiagnostic, "database_stats", dgrade(tempFilesGrade)}, + {"rollback ratio", TriageTargetDiagnostic, "database_stats", dgrade(rollbackGrade)}, {"sequence exhaustion", TriageTargetDiagnostic, "sequences", func(ctx context.Context) (Severity, string, error) { return c.triageSequences(ctx, db) }}, + {"stale statistics", TriageTargetDiagnostic, "stale_statistics", func(ctx context.Context) (Severity, string, error) { + return c.triageStaleStats(ctx, db) + }}, + {"FK missing index", TriageTargetDiagnostic, "fk_missing_index", func(ctx context.Context) (Severity, string, error) { + return c.triageFKMissingIndex(ctx, db) + }}, {"bloat", TriageTargetDiagnostic, "bloat_table", func(ctx context.Context) (Severity, string, error) { return c.triageBloat(ctx, db) }}, @@ -159,14 +249,9 @@ func (c *Client) Triage(ctx context.Context) []TriageResult { return results } -func (c *Client) triageWraparound(ctx context.Context) (Severity, string, error) { - info, err := c.Maintenance(ctx, "") - if err != nil { - return 0, "", err - } - // Maintenance is best-effort and absorbs sub-query failures; a zero - // FreezeMaxAge means the settings read itself failed, so degrade honestly - // instead of reporting a green 0%. +func wraparoundGrade(info *MaintenanceInfo) (Severity, string, error) { + // A zero FreezeMaxAge means the settings read failed even though the server + // is reachable, so degrade honestly instead of reporting a green 0%. if info.FreezeMaxAge <= 0 { return 0, "", errors.New("autovacuum_freeze_max_age unavailable") } @@ -175,6 +260,101 @@ func (c *Client) triageWraparound(ctx context.Context) (Severity, string, error) return sev, fmt.Sprintf("oldest datfrozenxid at %.0f%% of autovacuum_freeze_max_age", pct), nil } +// archiverGrade flags a stalled WAL archiver: pg_wal fills up silently when +// archiving fails, so any failure count is critical. +func archiverGrade(info *MaintenanceInfo) (Severity, string, error) { + sev := archiverSeverity(info.ArchiveFailed) + if sev == SevOK { + return sev, "no WAL archive failures", nil + } + detail := fmt.Sprintf("%d WAL archive failure(s)", info.ArchiveFailed) + if info.ArchiveLastFailed != "" { + detail += ", last " + info.ArchiveLastFailed + } + return sev, detail, nil +} + +func archiverSeverity(failed int64) Severity { + if failed > 0 { + return SevCrit + } + return SevOK +} + +func connSaturationGrade(info *MaintenanceInfo) (Severity, string, error) { + if info.MaxConns <= 0 { + return 0, "", errors.New("max_connections unavailable") + } + used := 0 + for _, n := range info.ConnByState { + used += n + } + sev := connSaturationSeverity(used, info.MaxConns) + pct := 100 * float64(used) / float64(info.MaxConns) + return sev, fmt.Sprintf("%d of %d connections in use (%.0f%%)", used, info.MaxConns, pct), nil +} + +func connSaturationSeverity(used, maxConns int) Severity { + if maxConns <= 0 { + return SevOK + } + frac := float64(used) / float64(maxConns) + switch { + case frac >= connSaturationCritFrac: + return SevCrit + case frac >= connSaturationWarnFrac: + return SevWarn + } + return SevOK +} + +// checkpointGrade watches the share of checkpoints forced by WAL volume rather +// than checkpoint_timeout — a high share means max_wal_size is too small. +func checkpointGrade(info *MaintenanceInfo) (Severity, string, error) { + total := info.CheckpointsTimed + info.CheckpointsReq + if total == 0 { + return SevOK, "no checkpoints recorded yet", nil + } + sev := checkpointSeverity(info.CheckpointsReq, total) + pct := 100 * float64(info.CheckpointsReq) / float64(total) + return sev, fmt.Sprintf("%d of %d checkpoints forced by WAL volume (%.0f%%)", + info.CheckpointsReq, total, pct), nil +} + +func checkpointSeverity(requested, total int64) Severity { + if total < checkpointMinTotal { + return SevOK + } + frac := float64(requested) / float64(total) + switch { + case frac >= checkpointReqCritFrac: + return SevCrit + case frac >= checkpointReqWarnFrac: + return SevWarn + } + return SevOK +} + +// preparedXactGrade flags open two-phase-commit transactions, which pin the +// xmin horizon and stall autovacuum until they are committed or rolled back. +func preparedXactGrade(info *MaintenanceInfo) (Severity, string, error) { + if info.PreparedXacts == 0 { + return SevOK, "no prepared transactions", nil + } + sev := preparedXactSeverity(info.OldestPrepSec) + return sev, fmt.Sprintf("%d prepared transaction(s), oldest %s", + info.PreparedXacts, triageDuration(info.OldestPrepSec)), nil +} + +// preparedXactSeverity is only called when at least one prepared xact exists, so +// the presence alone earns a warning and age escalates it. +func preparedXactSeverity(oldestSecs float64) Severity { + if oldestSecs > preparedXactCritSecs { + return SevCrit + } + return SevWarn +} + func wraparoundSeverity(xidAge, freezeMaxAge int64) Severity { if freezeMaxAge <= 0 { return SevOK @@ -317,11 +497,7 @@ func slotSeverity(inactive, lost int, overCap bool) Severity { return SevOK } -func (c *Client) triageCacheHit(ctx context.Context) (Severity, string, error) { - res, err := c.runTriageDiag(ctx, "", "database_stats") - if err != nil { - return 0, "", err - } +func cacheHitGrade(res *DiagResult) (Severity, string, error) { hitCol := diagColIdx(res, "hit_pct") dbCol := diagColIdx(res, "database") minHit, worstDB, seen := 100.0, "", false @@ -397,35 +573,93 @@ func slruSeverity(hitPct, blksRead float64) Severity { return SevOK } -func (c *Client) triageTempDeadlocks(ctx context.Context) (Severity, string, error) { - res, err := c.runTriageDiag(ctx, "", "database_stats") - if err != nil { - return 0, "", err - } +func deadlockGrade(res *DiagResult) (Severity, string, error) { dlCol := diagColIdx(res, "deadlocks") - tmpCol := diagColIdx(res, "temp_bytes") - var deadlocks, tempBytes float64 + var deadlocks float64 for _, row := range res.Rows { if v, ok := diagNum(row, dlCol); ok { deadlocks += v } + } + sev := deadlockSeverity(deadlocks) + if deadlocks == 0 { + return sev, "no deadlocks since stats reset", nil + } + return sev, fmt.Sprintf("%d deadlock(s) since stats reset", int64(deadlocks)), nil +} + +func deadlockSeverity(deadlocks float64) Severity { + switch { + case deadlocks >= deadlocksCrit: + return SevCrit + case deadlocks >= deadlocksWarn: + return SevWarn + } + return SevOK +} + +func tempFilesGrade(res *DiagResult) (Severity, string, error) { + tmpCol := diagColIdx(res, "temp_bytes") + var tempBytes float64 + for _, row := range res.Rows { if v, ok := diagNum(row, tmpCol); ok { tempBytes += v } } - sev := tempDeadlockSeverity(deadlocks, tempBytes) - if sev == SevOK { - return sev, "no deadlocks, little temp-file spill", nil + sev := tempBytesSeverity(tempBytes) + return sev, humanize.Bytes(int64(tempBytes)) + " spilled to temp files since stats reset", nil +} + +func tempBytesSeverity(tempBytes float64) Severity { + switch { + case tempBytes >= tempBytesCrit: + return SevCrit + case tempBytes >= tempBytesWarn: + return SevWarn } - return sev, fmt.Sprintf("%d deadlock(s), %s spilled to temp files since stats reset", - int64(deadlocks), humanize.Bytes(int64(tempBytes))), nil + return SevOK } -func tempDeadlockSeverity(deadlocks, tempBytes float64) Severity { +// rollbackGrade reports the database with the worst rollback ratio, ignoring +// databases below a minimum transaction volume so a quiet cluster stays green. +func rollbackGrade(res *DiagResult) (Severity, string, error) { + commitCol := diagColIdx(res, "commits") + rollbackCol := diagColIdx(res, "rollbacks") + dbCol := diagColIdx(res, "database") + worstFrac, worstDB, seen := 0.0, "", false + for _, row := range res.Rows { + commits, ok1 := diagNum(row, commitCol) + rollbacks, ok2 := diagNum(row, rollbackCol) + if !ok1 || !ok2 { + continue + } + total := commits + rollbacks + if total < rollbackMinXacts { + continue + } + seen = true + if frac := rollbacks / total; frac > worstFrac { + worstFrac = frac + if dbCol >= 0 { + worstDB = row[dbCol].Display + } + } + } + if !seen { + return SevOK, "not enough transactions to judge rollback ratio", nil + } + sev := rollbackSeverity(worstFrac) + if worstDB != "" { + return sev, fmt.Sprintf("worst rollback ratio %.1f%% (%s)", 100*worstFrac, worstDB), nil + } + return sev, fmt.Sprintf("worst rollback ratio %.1f%%", 100*worstFrac), nil +} + +func rollbackSeverity(frac float64) Severity { switch { - case deadlocks >= deadlocksCrit || tempBytes >= tempBytesCrit: + case frac >= rollbackCritFrac: return SevCrit - case deadlocks >= deadlocksWarn || tempBytes >= tempBytesWarn: + case frac >= rollbackWarnFrac: return SevWarn } return SevOK @@ -487,6 +721,37 @@ func (c *Client) triageInvalidIndexes(ctx context.Context, db string) (Severity, len(res.Rows), db), nil } +// triageStaleStats leans on the stale_statistics diagnostic's own server-side +// filter (rows modified since ANALYZE outgrow live rows): any returned row is +// already a table the planner is reasoning about with stale statistics. Stale +// stats degrade plans rather than break the server, so it grades as a warning. +func (c *Client) triageStaleStats(ctx context.Context, db string) (Severity, string, error) { + res, err := c.runTriageDiag(ctx, db, "stale_statistics") + if err != nil { + return 0, "", err + } + if len(res.Rows) == 0 { + return SevOK, fmt.Sprintf("no tables with stale planner statistics (in %s)", db), nil + } + return SevWarn, fmt.Sprintf("%d table(s) with stale planner statistics (in %s)", + len(res.Rows), db), nil +} + +// triageFKMissingIndex flags foreign keys whose referencing columns have no +// supporting index — a footgun for cascading updates/deletes and join plans. +// It is a performance risk, not an outage, so it grades as a warning. +func (c *Client) triageFKMissingIndex(ctx context.Context, db string) (Severity, string, error) { + res, err := c.runTriageDiag(ctx, db, "fk_missing_index") + if err != nil { + return 0, "", err + } + if len(res.Rows) == 0 { + return SevOK, fmt.Sprintf("all foreign keys have a supporting index (in %s)", db), nil + } + return SevWarn, fmt.Sprintf("%d foreign key(s) without a supporting index (in %s)", + len(res.Rows), db), nil +} + // runTriageDiag runs a registry diagnostic by key so the SQL and column // definitions stay single-sourced in diagnostic_defs.go. func (c *Client) runTriageDiag(ctx context.Context, db, key string) (*DiagResult, error) { diff --git a/internal/pg/triage_test.go b/internal/pg/triage_test.go index bedbdb0..d513414 100644 --- a/internal/pg/triage_test.go +++ b/internal/pg/triage_test.go @@ -110,15 +110,87 @@ func TestSequenceSeverity(t *testing.T) { } } -func TestTempDeadlockSeverity(t *testing.T) { - if got := tempDeadlockSeverity(0, 1<<20); got != SevOK { - t.Errorf("clean = %v, want SevOK", got) +func TestDeadlockSeverity(t *testing.T) { + if got := deadlockSeverity(0); got != SevOK { + t.Errorf("none = %v, want SevOK", got) } - if got := tempDeadlockSeverity(3, 0); got != SevWarn { - t.Errorf("a few deadlocks = %v, want SevWarn", got) + if got := deadlockSeverity(3); got != SevWarn { + t.Errorf("a few = %v, want SevWarn", got) } - if got := tempDeadlockSeverity(0, 200<<30); got != SevCrit { - t.Errorf("200GB temp = %v, want SevCrit", got) + if got := deadlockSeverity(200); got != SevCrit { + t.Errorf("many = %v, want SevCrit", got) + } +} + +func TestTempBytesSeverity(t *testing.T) { + if got := tempBytesSeverity(1 << 20); got != SevOK { + t.Errorf("1MB = %v, want SevOK", got) + } + if got := tempBytesSeverity(20 << 30); got != SevWarn { + t.Errorf("20GB = %v, want SevWarn", got) + } + if got := tempBytesSeverity(200 << 30); got != SevCrit { + t.Errorf("200GB = %v, want SevCrit", got) + } +} + +func TestArchiverSeverity(t *testing.T) { + if got := archiverSeverity(0); got != SevOK { + t.Errorf("no failures = %v, want SevOK", got) + } + if got := archiverSeverity(1); got != SevCrit { + t.Errorf("one failure = %v, want SevCrit", got) + } +} + +func TestConnSaturationSeverity(t *testing.T) { + if got := connSaturationSeverity(50, 100); got != SevOK { + t.Errorf("half = %v, want SevOK", got) + } + if got := connSaturationSeverity(85, 100); got != SevWarn { + t.Errorf("85%% = %v, want SevWarn", got) + } + if got := connSaturationSeverity(98, 100); got != SevCrit { + t.Errorf("98%% = %v, want SevCrit", got) + } + if got := connSaturationSeverity(10, 0); got != SevOK { + t.Errorf("unknown max = %v, want SevOK", got) + } +} + +func TestCheckpointSeverity(t *testing.T) { + if got := checkpointSeverity(3, 4); got != SevOK { + t.Errorf("too few to judge = %v, want SevOK", got) + } + if got := checkpointSeverity(1, 100); got != SevOK { + t.Errorf("mostly timed = %v, want SevOK", got) + } + if got := checkpointSeverity(40, 100); got != SevWarn { + t.Errorf("40%% requested = %v, want SevWarn", got) + } + if got := checkpointSeverity(70, 100); got != SevCrit { + t.Errorf("70%% requested = %v, want SevCrit", got) + } +} + +func TestPreparedXactSeverity(t *testing.T) { + if got := preparedXactSeverity(5); got != SevWarn { + t.Errorf("young = %v, want SevWarn", got) + } + if got := preparedXactSeverity(600); got != SevCrit { + t.Errorf("10m old = %v, want SevCrit", got) + } +} + +func TestRollbackSeverity(t *testing.T) { + if got := rollbackSeverity(0.05); got != SevOK { + t.Errorf("5%% = %v, want SevOK", got) + } + if got := rollbackSeverity(0.30); got != SevWarn { + t.Errorf("30%% = %v, want SevWarn", got) + } + if got := rollbackSeverity(0.60); got != SevCrit { + t.Errorf("60%% = %v, want SevCrit", got) } } @@ -166,8 +238,8 @@ func TestTriageDegradesOnFailure(t *testing.T) { cancel() results := c.Triage(ctx) - if len(results) != 10 { - t.Fatalf("Triage returned %d results, want 10", len(results)) + if len(results) != 18 { + t.Fatalf("Triage returned %d results, want 18", len(results)) } for _, r := range results { if r.Check == "" {