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/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/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/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..a3b4855 100644 --- a/internal/pg/diagnostic_defs.go +++ b/internal/pg/diagnostic_defs.go @@ -13,6 +13,22 @@ 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 +} + +// 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. @@ -29,6 +45,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 +74,34 @@ 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, + 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 +160,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 +178,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 +188,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 +199,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 +236,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 +274,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 +328,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 +364,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 +374,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..0dd6a35 --- /dev/null +++ b/internal/pg/diagnostics_integration_test.go @@ -0,0 +1,241 @@ +package pg + +import ( + "context" + "net/url" + "os" + "strconv" + "strings" + "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") +} + +// 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/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/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_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_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/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..a94e7d1 100644 --- a/internal/pg/queries_diag.go +++ b/internal/pg/queries_diag.go @@ -766,3 +766,283 @@ 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 +` + +// 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, + 'blocks'::text AS unit + FROM pg_stat_progress_vacuum + UNION ALL + 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, 'blocks' + FROM pg_stat_progress_analyze + UNION ALL + 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, 'bytes' + FROM pg_stat_progress_copy + UNION ALL + 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, + 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 +` + +// 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. +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 +` + +// 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/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/reindex.go b/internal/pg/reindex.go index ad04120..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 @@ -13,9 +16,46 @@ 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) } 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/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/triage.go b/internal/pg/triage.go new file mode 100644 index 0000000..592f7ed --- /dev/null +++ b/internal/pg/triage.go @@ -0,0 +1,795 @@ +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 + + // 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 + // 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() + + // 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, "", 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", dgrade(cacheHitGrade)}, + {"SLRU pressure", TriageTargetDiagnostic, "slru_stats", c.triageSLRU}, + {"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) + }}, + {"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 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") + } + 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 +} + +// 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 + } + 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 cacheHitGrade(res *DiagResult) (Severity, string, error) { + 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 deadlockGrade(res *DiagResult) (Severity, string, error) { + dlCol := diagColIdx(res, "deadlocks") + 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 := 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 SevOK +} + +// 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 frac >= rollbackCritFrac: + return SevCrit + case frac >= rollbackWarnFrac: + 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 +} + +// 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) { + 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..d513414 --- /dev/null +++ b/internal/pg/triage_test.go @@ -0,0 +1,269 @@ +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 TestDeadlockSeverity(t *testing.T) { + if got := deadlockSeverity(0); got != SevOK { + t.Errorf("none = %v, want SevOK", got) + } + if got := deadlockSeverity(3); got != SevWarn { + t.Errorf("a few = %v, want SevWarn", 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) + } +} + +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) != 18 { + t.Fatalf("Triage returned %d results, want 18", 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/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/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/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/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..4a28fcc 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -46,7 +46,11 @@ 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) + 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. @@ -63,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 { @@ -85,6 +90,8 @@ func (t tool) Name() string { return "activity" case toolTableStats: return "tables" + case toolTriage: + return "triage" } return "?" } @@ -232,6 +239,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 @@ -328,6 +339,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 +457,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 @@ -460,6 +489,17 @@ 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. + progressRows []pg.ProgressRow + progressErr error + // ── Table maintenance panel (levelParts) ────────────────────────────────── // tableStats is the maintenance snapshot for the current table, loaded // asynchronously alongside the parts list. @@ -567,6 +607,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 @@ -574,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. @@ -643,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 } @@ -706,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}, @@ -713,22 +770,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..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 @@ -556,6 +566,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 @@ -584,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_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/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/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/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/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..98a1a02 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 { @@ -131,12 +132,20 @@ 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" + case levelTriage: + return "triage" + case levelWaitProfile: + return "wait-profile" } return "?" } @@ -162,3 +171,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/keys.go b/internal/tui/keys.go index f7aed82..7463418 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,21 @@ 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 + 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 @@ -92,9 +108,17 @@ 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")), + Progress: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "progress")), + + WaitProfile: key.NewBinding(key.WithKeys("W"), key.WithHelp("W", "wait profile")), } } @@ -122,14 +146,14 @@ 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) + // 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) @@ -142,9 +166,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 +185,20 @@ 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) + // 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) + + // 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 @@ -171,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 } @@ -181,6 +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.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 d11162a..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 + @@ -149,6 +153,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 new file mode 100644 index 0000000..94bb61b --- /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"} + // 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} + 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.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/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/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/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..17197c9 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: @@ -141,6 +147,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: @@ -155,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_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..427911d 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 @@ -158,6 +161,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 { @@ -188,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 @@ -248,8 +256,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 +271,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) @@ -288,6 +294,43 @@ 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.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. + 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): @@ -297,6 +340,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): @@ -338,21 +392,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 @@ -375,6 +443,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 { @@ -388,7 +461,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 { @@ -473,6 +546,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) } @@ -534,8 +610,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 { @@ -585,13 +660,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 @@ -632,6 +708,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 { @@ -658,6 +748,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). @@ -732,6 +834,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 @@ -745,11 +870,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 @@ -797,6 +927,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 { @@ -819,6 +986,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_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/update_load.go b/internal/tui/update_load.go index 60328f4..34d2c71 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,8 +223,30 @@ 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) + 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)} + 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 7734da4..82b9717 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" @@ -364,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 @@ -374,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 { @@ -386,30 +407,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 } @@ -546,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)) @@ -582,11 +586,12 @@ 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, 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 { + if top.level != levelActivity && top.level != levelLockTree && top.level != levelProgress && + top.level != levelWaitProfile { m.activityTicking = false return nil } @@ -596,9 +601,49 @@ func (m *Model) onActivityTick() tea.Cmd { m.activityTicking = false return nil } + 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) } +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) 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 { @@ -615,7 +660,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 +680,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_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/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..6bc83e0 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,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 != levelTableStats: + s.level != levelActivity && s.level != levelLockTree && s.level != levelTableStats && + 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 @@ -255,6 +258,14 @@ 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 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)) @@ -430,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_activity.go b/internal/tui/view_activity.go index 3d41c86..cb4216c 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") @@ -185,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 c7f3ddb..dc1bf26 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 @@ -177,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 @@ -227,15 +228,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 +241,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 +263,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 +316,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 +342,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 +383,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..fbb0238 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 @@ -224,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 { @@ -233,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") @@ -255,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. @@ -377,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 @@ -540,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 { @@ -552,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) @@ -688,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_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_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 9e4ac66..78e28ea 100644 --- a/internal/tui/view_maintenance.go +++ b/internal/tui/view_maintenance.go @@ -91,7 +91,11 @@ 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 · ") + + styleBadge.Render("p") + mu(" progress · space refresh") + "\n") full.WriteString(body.String()) return scrollWindow(full.String(), &s.offset, height) @@ -363,10 +367,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..759f034 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" @@ -17,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 @@ -74,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 "" } @@ -83,7 +86,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 { @@ -193,7 +196,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 ") + @@ -255,11 +273,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 +308,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_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_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) + } + } +} 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..451867d 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 { @@ -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 @@ -255,10 +264,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 +293,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_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/view_wal.go b/internal/tui/view_wal.go index 406dc2c..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 { @@ -556,12 +563,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 +626,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 +642,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 +662,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 +682,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 +708,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 +735,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() -} 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) + } +}