diff --git a/CHANGELOG.md b/CHANGELOG.md index ab2770d..fcf6265 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ separately by `model.SchemaVersion` (currently 1.2.0). ## [Unreleased] +### Changed +- **Gauge strip in the default `inspect` view.** Four vital signs sit right + under the header — cache hit, lock wait (naming the culprit query when + sessions are blocked), rollbacks, and idle index bytes as a share of the + database — each a bar, a value and a one-word status driven by the finding + that grades the same signal. Signals pgbot could not measure render dim + with `—` and say why (`thin sample`, `window < 15m`, `not measurable`). The + GOOD bullet list becomes a compact `checked · queries · vacuum · …` line of + the subsystems that were collected and produced no finding. Score, the + CRITICAL/WARNING/NOTE bullets, `--full`, `--json`, MCP, SARIF and the HTML + report are unchanged. The footer's ask hint now reads + `pgbot ask "why is it slow?"`. + ### Added - **`$PGSERVICE` as a connection fallback** (#25). When no connection string is passed and neither `$DATABASE_URL` nor `$PGBOT_DATABASE_URL` is set, diff --git a/README.md b/README.md index c53eebb..0ac3644 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,14 @@ pinning and model/endpoint overrides: [the AI layer](#explain--optional-ai-layer ``` connected · db.example.com · postgres 17.4 · read-only · 6h20m window + cache hit [████████████████████] 99.4% ok + lock wait [████████████░░░░░░░░] 61.0% query 4f2a + rollbacks [██░░░░░░░░░░░░░░░░░░] 12.0% watch + idle idx [██████░░░░░░░░░░░░░░] 18.0 GiB review + +checked · queries · vacuum · replication · checkpoints + · connections · settings · deadlocks + Database health: 82/100 CRITICAL @@ -90,19 +98,16 @@ WARNING ● 3 unused indexes consume 18 GB ● connection usage reached 87% -GOOD -● cache hit ratio 99.4% -● replication healthy -● no deadlocks - Details: pgbot inspect --full · Machine-readable: --json -Ask it: pgbot ask "what's wrong?" +Ask it: pgbot ask "why is it slow?" ``` -The default report is a **graded read**: a health score, findings bucketed -CRITICAL / WARNING / NOTE, then a GOOD list naming the healthy subsystems with -their values (a tool that names what it verified reads like a colleague who -looked, not an alarm). `pgbot inspect --full` adds a subsystem status board plus +The default report is a **graded read**: a four-row gauge strip of vital signs +(cache hit, lock wait with the culprit query, rollbacks, idle index bytes as a +share of the database), a `checked` line naming the subsystems that came back +clean (a tool that names what it verified reads like a colleague who looked, +not an alarm), a health score, then findings bucketed CRITICAL / WARNING / +NOTE. `pgbot inspect --full` adds a subsystem status board plus the section tables and per-finding caveats; focused commands (`indexes`, `queries`, `tables`, `vacuum`) each drill into one signal; `pgbot ask "…"` and `pgbot explain` put a plain-language AI reading on top of the same findings. diff --git a/internal/render/dashboard.go b/internal/render/dashboard.go index f631182..87c911f 100644 --- a/internal/render/dashboard.go +++ b/internal/render/dashboard.go @@ -8,9 +8,10 @@ import ( "github.com/pgrundev/pgbot/internal/model" ) -// The default view is a graded, grouped read: a 0–100 health score, then the -// findings bucketed CRITICAL / WARNING / NOTE, then a GOOD list of the healthy -// subsystems named with their values. Fast to read top-to-bottom. +// The default view is a graded, grouped read: a four-row gauge strip of vital +// signs (gauges.go), the "checked" line naming the subsystems that came back +// clean, a 0–100 health score, then the findings bucketed CRITICAL / WARNING / +// NOTE. Fast to read top-to-bottom. type statusKind int @@ -35,6 +36,15 @@ func statusColor(st styler, k statusKind) func(string) string { } func renderGrouped(b *strings.Builder, st styler, c *model.Context, width int) { + // The strip and the checked line infer health from measured signals and + // from the ABSENCE of findings — valid only when the checks actually ran. A + // schema profile skips the workload/infra collectors, so neither belongs + // there; the header already says this is schema-only. + if c.Profile != "schema" { + renderGauges(b, st, c, width) + renderChecked(b, st, buildChecked(c), width) + } + score := computeHealthScore(c) paintScore := st.good switch { @@ -117,22 +127,8 @@ func renderGrouped(b *strings.Builder, st styler, c *model.Context, width int) { fmt.Fprintln(b) } - // The GOOD list infers health from the ABSENCE of a finding — valid only when - // the check actually ran. A schema profile skips the workload/infra collectors, - // so "no blocking locks" there would be a claim about a database it never - // examined. Suppress it; the header already says this is schema-only. - if c.Profile != "schema" { - if good := buildGood(c); len(good) > 0 { - fmt.Fprintln(b, st.good("GOOD")) - for _, g := range good { - fmt.Fprintf(b, "%s %s\n", st.good("●"), st.dim(g)) - } - fmt.Fprintln(b) - } - } - fmt.Fprintln(b, st.dim("Details: pgbot inspect --full · Machine-readable: --json")) - fmt.Fprintln(b, st.dim(`Ask it: pgbot ask "what's wrong?"`)) + fmt.Fprintln(b, st.dim(`Ask it: pgbot ask "why is it slow?"`)) } // renderSafetyGuards prints a finding's structured destructive-action guards @@ -212,57 +208,6 @@ func computeHealthScore(c *model.Context) int { return s } -// buildGood names the subsystems pgbot checked and found healthy, with their -// values — the "a colleague who looked" signal. Only names things actually -// examined and clean, capped so the list stays scannable. -func buildGood(c *model.Context) []string { - fired := map[string]bool{} - for _, f := range c.Findings { - fired[f.ID] = true - } - var g []string - if h := c.Health; h != nil { - if h.CacheHitUsable() && !fired["low_cache_hit"] { - g = append(g, fmt.Sprintf("cache hit ratio %.1f%%", *h.CacheHitRatio*100)) - } - if h.DeadlocksPerMin != nil && *h.DeadlocksPerMin == 0 { - g = append(g, "no deadlocks") - } - } - if c.Locks != nil && c.Locks.BlockedCount == 0 { - g = append(g, "no blocking locks") - } - if r := c.Replication; r != nil { - switch { - case r.IsReplica: - g = append(g, "replication healthy (replica)") - case len(r.Replicas) > 0: - g = append(g, fmt.Sprintf("replication healthy (%d streaming)", len(r.Replicas))) - } - } - if c.Schema != nil && !fired["index_invalid"] { - g = append(g, "no invalid indexes") - } - if c.Tables != nil && !fired["table_bloat"] { - g = append(g, "no significant table bloat") - } - if c.Limits != nil { - if !fired["txid_wraparound"] && c.Limits.MaxXIDAge > 0 { - g = append(g, "no wraparound risk") - } - if !fired["connection_saturation"] && c.Limits.ConnectionsMax > 0 { - g = append(g, fmt.Sprintf("connections %d/%d", c.Limits.ConnectionsUsed, c.Limits.ConnectionsMax)) - } - } - if c.Queries != nil && c.Queries.Enabled && !fired["pg_stat_statements_missing"] { - g = append(g, "query stats available") - } - if len(g) > 6 { - g = g[:6] - } - return g -} - // pgLower renders "postgres 16.3" for the header. func pgLower(num int) string { if num == 0 { diff --git a/internal/render/gauges.go b/internal/render/gauges.go new file mode 100644 index 0000000..786500f --- /dev/null +++ b/internal/render/gauges.go @@ -0,0 +1,286 @@ +package render + +import ( + "fmt" + "strings" + "unicode/utf8" + + "github.com/pgrundev/pgbot/internal/model" +) + +// The gauge strip: four vital signs right under the header of the default +// view, each a filled bar, a value and a one-word status. It reads in a +// second and never contradicts the findings below it — every status word is +// driven by the finding that grades the same signal, never by a threshold of +// its own. A signal pgbot could not measure renders dim with "—" and says +// why, instead of showing an empty bar that looks like zero. +// +// cache hit [████████████████████] 99.2% ok +// lock wait [████████████░░░░░░░░] 61.0% query 4f2a +// rollbacks [██░░░░░░░░░░░░░░░░░░] 12.0% watch +// idle idx [██████░░░░░░░░░░░░░░] 43.0 GiB review +// +// Below it, the "checked" line names the subsystems that were collected and +// produced no finding — the compact form of the old GOOD list. + +// gaugeWidth is the bar length in cells. +const gaugeWidth = 20 + +type gauge struct { + label string + share float64 // bar fill, 0..1 + value string // "99.4%", "43.0 GiB", or "—" + status string // "ok", "low", "watch", "review", "query 4f2a", "2 blocked", or why it is not measurable + kind statusKind + measurable bool +} + +func notMeasurable(label, why string) gauge { + return gauge{label: label, value: "—", status: why, kind: kInfo} +} + +// gaugeCells rounds a share to the nearest cell, with a one-cell minimum for +// any non-zero value so a small but real signal never disappears. +func gaugeCells(share float64) int { + if share <= 0 { + return 0 + } + if share > 1 { + share = 1 + } + n := int(share*gaugeWidth + 0.5) + if n < 1 { + n = 1 + } + if n > gaugeWidth { + n = gaugeWidth + } + return n +} + +func gaugeBar(share float64) string { + n := gaugeCells(share) + return strings.Repeat("█", n) + strings.Repeat("░", gaugeWidth-n) +} + +// firedIDs is the set of finding ids present on the context. Suppressed and +// preexisting findings count: a gauge must never read "ok" over a signal that +// produced a finding, however it is being displayed. +func firedIDs(c *model.Context) map[string]bool { + m := make(map[string]bool, len(c.Findings)) + for _, f := range c.Findings { + m[f.ID] = true + } + return m +} + +// cacheHitGauge: the sampled cache-hit ratio. Graded by the low_cache_hit +// finding; a thin sample (under CacheHitMinBlocks) is not graded at all. +func cacheHitGauge(c *model.Context) gauge { + h := c.Health + if h == nil || h.CacheHitRatio == nil { + return notMeasurable("cache hit", "not measurable") + } + if !h.CacheHitUsable() { + return notMeasurable("cache hit", "thin sample") + } + g := gauge{label: "cache hit", share: *h.CacheHitRatio, value: pct(*h.CacheHitRatio), status: "ok", kind: kOK, measurable: true} + if firedIDs(c)["low_cache_hit"] { + g.status, g.kind = "low", kBad + } + return g +} + +// lockWaitGauge: the Lock bucket's share of sampled active time, from the wait +// profile. With blocked sessions the status names the culprit — the query +// with the highest lock share — or falls back to the blocked count when +// attribution is missing. Without a wait profile only the status is shown. +func lockWaitGauge(c *model.Context) gauge { + wp := c.WaitProfile + profiled := wp != nil && wp.Available + if !profiled && c.Locks == nil { + return notMeasurable("lock wait", "not measurable") + } + g := gauge{label: "lock wait", value: "—", status: "—", kind: kInfo, measurable: true} + if profiled { + for _, b := range wp.Buckets { + if b.Type == "Lock" { + g.share = b.Share + break + } + } + g.value = pct(g.share) + } + if c.Locks == nil { + return g + } + blocked := c.Locks.BlockedCount + if blocked == 0 { + g.status, g.kind = "ok", kOK + return g + } + g.kind = kBad + g.status = fmt.Sprintf("%d blocked", blocked) + if profiled { + best := -1.0 + var culprit int64 + for _, q := range wp.ByQuery { + if q.LockShare > best && q.LockShare > 0 { + best, culprit = q.LockShare, q.QueryID + } + } + if best > 0 { + g.status = "query " + queryHex4(culprit) + } + } + return g +} + +// queryHex4 is the first four hex digits of a query_id — enough to find it in +// `pgbot queries` / pg_stat_statements without eating the row. +func queryHex4(id int64) string { + return fmt.Sprintf("%016x", uint64(id))[:4] +} + +// rollbacksGauge: the rollback ratio over the sample window, graded by the +// high_rollback_ratio finding. +func rollbacksGauge(c *model.Context) gauge { + h := c.Health + if h == nil || h.RollbackRatio == nil { + return notMeasurable("rollbacks", "not measurable") + } + g := gauge{label: "rollbacks", share: *h.RollbackRatio, value: pct(*h.RollbackRatio), status: "ok", kind: kOK, measurable: true} + if firedIDs(c)["high_rollback_ratio"] { + g.status, g.kind = "watch", kWatch + } + return g +} + +// idleIndexGauge: the summed size of zero-scan indexes; the bar is that size +// as a share of the database, so it answers "how much of my storage is dead +// weight". Graded by the unused_indexes finding. Meaningless in a cold stats +// window, exactly like the finding. +func idleIndexGauge(c *model.Context) gauge { + if c.Window.ColdWindow() { + return notMeasurable("idle idx", "window < 15m") + } + if c.Indexes == nil { + return notMeasurable("idle idx", "not measurable") + } + var idle int64 + for _, ix := range c.Indexes.Unused { + if ix.Scans == 0 { + idle += ix.Bytes + } + } + g := gauge{label: "idle idx", value: humanBytes(idle), status: "ok", kind: kOK, measurable: true} + if c.Tables != nil && c.Tables.DBSizeBytes > 0 { + g.share = float64(idle) / float64(c.Tables.DBSizeBytes) + } + if firedIDs(c)["unused_indexes"] { + g.status, g.kind = "review", kWatch + } + return g +} + +// renderGauges prints the strip. Layout is fixed-width so it fits the +// 80-column floor with the longest status: two spaces, a 9-wide label, the +// bracketed bar, an 8-wide value, then the status. +func renderGauges(b *strings.Builder, st styler, c *model.Context, _ int) { + for _, g := range []gauge{cacheHitGauge(c), lockWaitGauge(c), rollbacksGauge(c), idleIndexGauge(c)} { + paint := statusColor(st, g.kind) + if !g.measurable { + paint = st.dim + } + label := fmt.Sprintf("%-9s", g.label) + value := fmt.Sprintf("%-8s", g.value) + fmt.Fprintf(b, " %s [%s] %s %s\n", st.dim(label), paint(gaugeBar(g.share)), value, paint(g.status)) + } + fmt.Fprintln(b) +} + +// checkedSubsystem is a subsystem the "checked" line may name: it counts as +// checked-clean when it was collected and none of its findings fired. +type checkedSubsystem struct { + name string + collected bool + findings []string +} + +// buildChecked names, in a fixed order, the subsystems pgbot collected and +// found nothing to say about. Only collected subsystems are ever named — a +// section pgbot did not gather cannot be "checked". +func buildChecked(c *model.Context) []string { + fired := firedIDs(c) + deadlocksClean := c.Health != nil && c.Health.DeadlocksPerMin != nil && *c.Health.DeadlocksPerMin == 0 + subsystems := []checkedSubsystem{ + {"queries", c.Queries != nil && c.Queries.Enabled, + []string{"query_slowdown", "seq_scan_heavy", "partition_seq_scan_heavy", "pgss_entries_evicted", "pg_stat_statements_missing"}}, + {"indexes", c.Indexes != nil, + []string{"unused_indexes", "index_invalid", "redundant_indexes", "fk_unindexed"}}, + {"vacuum", c.Tables != nil, + []string{"table_bloat", "autovacuum_disabled_on_table", "table_never_vacuumed", "autovacuum_starved", "autovacuum_saturated", + "autovacuum_long_running", "stale_statistics", "never_analyzed", "low_hot_update_ratio", "vacuum_horizon_blocked", "autovacuum_off"}}, + {"replication", c.Replication != nil, + []string{"sync_rep_degraded", "replica_lag_time", "recovery_conflicts", "replica_disconnected", "replication_slot_inactive", "subscription_worker_down"}}, + {"checkpoints", c.WAL != nil, []string{"checkpoints_forced"}}, + {"connections", c.Limits != nil && c.Limits.ConnectionsMax > 0, + []string{"connection_saturation", "connections_overprovisioned", "idle_in_transaction", "long_running_transaction", "prepared_xact_abandoned"}}, + {"settings", c.Settings != nil, + []string{"work_mem_low", "fsync_off", "full_page_writes_off", "autovacuum_off", "random_page_cost_high", "work_mem_overcommit", + "statement_timeout_unset", "io_timing_off", "checksums_disabled", "ignore_checksum_failure_on"}}, + {"wraparound", c.Limits != nil && c.Limits.MaxXIDAge > 0, []string{"txid_wraparound", "mxid_wraparound", "sequence_exhaustion"}}, + {"deadlocks", deadlocksClean, []string{"blocking_chains"}}, + } + // A subsystem whose gauge is already orange or red stays off the line, so + // the line never contradicts the strip. + if g := idleIndexGauge(c); g.measurable && g.kind != kOK { + fired["unused_indexes"] = true + } + var out []string + for _, s := range subsystems { + if !s.collected { + continue + } + clean := true + for _, id := range s.findings { + if fired[id] { + clean = false + break + } + } + if clean { + out = append(out, s.name) + } + } + return out +} + +// renderChecked prints "checked · a · b · c", wrapping whole items with an +// eight-space hanging indent so continuation lines align under the first item. +func renderChecked(b *strings.Builder, st styler, items []string, width int) { + if len(items) == 0 { + return + } + const indent = " " + var lines []string + cur := "checked" + for _, it := range items { + piece := " · " + it + if utf8.RuneCountInString(cur+piece) > width && cur != "checked" && cur != indent+"·" { + lines = append(lines, cur) + cur = indent + "·" + " " + it + continue + } + cur += piece + } + lines = append(lines, cur) + for i, l := range lines { + if i == 0 { + fmt.Fprintf(b, "%s%s\n", st.good("checked"), st.dim(strings.TrimPrefix(l, "checked"))) + } else { + fmt.Fprintln(b, st.dim(l)) + } + } + fmt.Fprintln(b) +} diff --git a/internal/render/gauges_test.go b/internal/render/gauges_test.go new file mode 100644 index 0000000..ee5393e --- /dev/null +++ b/internal/render/gauges_test.go @@ -0,0 +1,269 @@ +package render + +import ( + "regexp" + "strings" + "testing" + "unicode/utf8" + + "github.com/pgrundev/pgbot/internal/model" +) + +func f64(v float64) *float64 { return &v } +func i64(v int64) *int64 { return &v } + +// gaugeContext is a busy database: every gauge measurable, two of them hot. +func gaugeContext() *model.Context { + c := sampleContext() + c.Health.RollbackRatio = f64(0.12) + c.Locks = &model.Locks{BlockedCount: 2} + c.WaitProfile = &model.WaitProfile{ + Available: true, Samples: 100, WindowSeconds: 10, + Buckets: []model.WaitBucket{{Type: "Lock", Count: 61, Share: 0.61}, {Type: "CPU", Count: 39, Share: 0.39}}, + ByQuery: []model.QueryWaits{ + {QueryID: 0x1111000000000000, Count: 30, Share: 0.3, LockShare: 0.2}, + {QueryID: 0x4f2a000000000000, Count: 40, Share: 0.4, LockShare: 0.9}, + }, + } + c.Tables = &model.Tables{DBSizeBytes: 100 << 30} + c.Indexes = &model.Indexes{Total: 12, Unused: []model.IndexStat{ + {Name: "a", Scans: 0, Bytes: 40 << 30}, + {Name: "b", Scans: 0, Bytes: 3 << 30}, + {Name: "c", Scans: 5, Bytes: 9 << 30}, // scanned — not idle + }} + c.Findings = append(c.Findings, model.Finding{ID: "high_rollback_ratio", Severity: model.SeverityWarn, Title: "rollbacks 12%"}) + return c +} + +func TestGauge_fillRoundsToNearestCellWithOneCellMinimum(t *testing.T) { + cases := []struct { + share float64 + cells int + }{{0, 0}, {0.001, 1}, {0.024, 1}, {0.026, 1}, {0.074, 1}, {0.076, 2}, {0.5, 10}, {0.974, 19}, {0.976, 20}, {1, 20}, {1.4, 20}} + for _, tc := range cases { + if got := gaugeCells(tc.share); got != tc.cells { + t.Errorf("gaugeCells(%v) = %d, want %d", tc.share, got, tc.cells) + } + } + if got := strings.Count(gaugeBar(0.5), "█"); got != 10 { + t.Errorf("bar at 0.5 has %d filled cells, want 10", got) + } + if got := utf8.RuneCountInString(gaugeBar(0.3)); got != gaugeWidth { + t.Errorf("bar is %d runes wide, want %d", got, gaugeWidth) + } +} + +func TestGauge_cacheHit(t *testing.T) { + c := gaugeContext() + g := cacheHitGauge(c) + if g.value != "99.4%" || g.status != "ok" || g.kind != kOK || g.measurable != true { + t.Errorf("healthy cache hit: %+v", g) + } + c.Findings = append(c.Findings, model.Finding{ID: "low_cache_hit", Severity: model.SeverityWarn}) + if g := cacheHitGauge(c); g.status != "low" || g.kind != kBad { + t.Errorf("low cache hit should read low/red: %+v", g) + } + c.Health.CacheBlocks = i64(100) + if g := cacheHitGauge(c); g.measurable || g.value != "—" || g.status != "thin sample" { + t.Errorf("thin sample must not be graded: %+v", g) + } + c.Health = nil + if g := cacheHitGauge(c); g.measurable { + t.Errorf("no health section must be not measurable: %+v", g) + } +} + +func TestGauge_lockWait(t *testing.T) { + c := gaugeContext() + g := lockWaitGauge(c) + if g.value != "61.0%" || g.share != 0.61 || g.status != "query 4f2a" || g.kind != kBad { + t.Errorf("blocked with attribution should name the culprit: %+v", g) + } + c.WaitProfile.ByQuery = nil + if g := lockWaitGauge(c); g.status != "2 blocked" || g.kind != kBad { + t.Errorf("blocked without attribution falls back to the count: %+v", g) + } + c.Locks.BlockedCount = 0 + if g := lockWaitGauge(c); g.status != "ok" || g.kind != kOK || g.value != "61.0%" { + t.Errorf("no blocked sessions is ok even with lock samples: %+v", g) + } + c.WaitProfile = nil + if g := lockWaitGauge(c); g.value != "—" || g.status != "ok" || g.share != 0 { + t.Errorf("no wait profile shows only the status: %+v", g) + } + c.Locks = nil + if g := lockWaitGauge(c); g.measurable { + t.Errorf("neither profile nor locks is not measurable: %+v", g) + } + // A negative query_id (int64 hash) still renders as 4 hex digits. + c = gaugeContext() + c.WaitProfile.ByQuery = []model.QueryWaits{{QueryID: -1, Count: 1, Share: 1, LockShare: 1}} + if g := lockWaitGauge(c); g.status != "query ffff" { + t.Errorf("negative query id: %+v", g) + } +} + +func TestGauge_rollbacks(t *testing.T) { + c := gaugeContext() + if g := rollbacksGauge(c); g.value != "12.0%" || g.status != "watch" || g.kind != kWatch { + t.Errorf("rollback finding fired should read watch: %+v", g) + } + c.Findings = nil + if g := rollbacksGauge(c); g.status != "ok" || g.kind != kOK { + t.Errorf("no finding is ok: %+v", g) + } + c.Health.RollbackRatio = nil + if g := rollbacksGauge(c); g.measurable || g.value != "—" || g.status != "not measurable" { + t.Errorf("nil ratio is not measurable: %+v", g) + } +} + +func TestGauge_idleIndexes(t *testing.T) { + c := gaugeContext() + g := idleIndexGauge(c) + // 43 GiB of zero-scan indexes over a 100 GiB database: 0.43 → 9 cells. + if g.value != "43.0 GiB" || gaugeCells(g.share) != 9 || g.status != "review" || g.kind != kWatch { + t.Errorf("unused indexes fired: %+v", g) + } + c.Findings = nil + if g := idleIndexGauge(c); g.status != "ok" || g.kind != kOK || g.value != "43.0 GiB" { + t.Errorf("below threshold is ok but still sized: %+v", g) + } + c.Indexes.Unused = nil + if g := idleIndexGauge(c); g.value != "0 B" || g.status != "ok" || g.share != 0 { + t.Errorf("zero idle bytes: %+v", g) + } + c.Tables = nil + c.Indexes = gaugeContext().Indexes + if g := idleIndexGauge(c); g.share != 0 || g.value != "43.0 GiB" { + t.Errorf("no database size: value stays, fill is empty: %+v", g) + } + c.Window.WindowAgeSeconds = i64(120) + if g := idleIndexGauge(c); g.measurable || g.status != "window < 15m" { + t.Errorf("cold window: %+v", g) + } + c.Window.WindowAgeSeconds = nil + c.Indexes = nil + if g := idleIndexGauge(c); g.measurable || g.status != "not measurable" { + t.Errorf("no index section: %+v", g) + } +} + +func TestGaugeStrip_layoutNoColorAndWidth(t *testing.T) { + var b strings.Builder + renderGauges(&b, styler{on: false}, gaugeContext(), 80) + out := b.String() + if regexp.MustCompile("\x1b\\[").MatchString(out) { + t.Error("no-color strip must contain no ANSI escapes") + } + want := []string{ + " cache hit [████████████████████] 99.4% ok", + " lock wait [████████████░░░░░░░░] 61.0% query 4f2a", + " rollbacks [██░░░░░░░░░░░░░░░░░░] 12.0% watch", + " idle idx [█████████░░░░░░░░░░░] 43.0 GiB review", + } + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + if len(lines) != len(want) { + t.Fatalf("strip has %d lines, want %d:\n%s", len(lines), len(want), out) + } + for i, w := range want { + if lines[i] != w { + t.Errorf("line %d:\n got %q\nwant %q", i, lines[i], w) + } + if n := utf8.RuneCountInString(lines[i]); n > 80 { + t.Errorf("line %d is %d columns wide, over the 80-column floor", i, n) + } + } + // Colour on: the block characters survive so the bar reads with color stripped. + b.Reset() + renderGauges(&b, styler{on: true}, gaugeContext(), 80) + if !strings.Contains(b.String(), "█") || !strings.Contains(b.String(), "query 4f2a") { + t.Error("colored strip lost its bar or status") + } +} + +func TestGaugeStrip_notMeasurableRowsAreDim(t *testing.T) { + var b strings.Builder + renderGauges(&b, styler{on: false}, sampleContext(), 80) + out := b.String() + // sampleContext has cache hit only: the other three rows are empty bars with "—". + for _, w := range []string{"cache hit [████████████████████] 99.4%", "lock wait [░░░░░░░░░░░░░░░░░░░░] —", "rollbacks [░░░░░░░░░░░░░░░░░░░░] —", "idle idx [░░░░░░░░░░░░░░░░░░░░] —"} { + if !strings.Contains(out, w) { + t.Errorf("strip missing %q in:\n%s", w, out) + } + } +} + +func TestChecked_orderExclusionAndWrap(t *testing.T) { + c := gaugeContext() + c.Queries = &model.Queries{Enabled: true} + c.Replication = &model.Replication{} + c.WAL = &model.WAL{} + c.Limits = &model.Limits{ConnectionsMax: 100, ConnectionsUsed: 10, MaxXIDAge: 1000} + c.Settings = &model.Settings{} + c.Health.DeadlocksPerMin = f64(0) + // unused_indexes fired in gaugeContext → indexes is left out; everything else is clean. + got := buildChecked(c) + want := []string{"queries", "vacuum", "replication", "checkpoints", "connections", "settings", "wraparound", "deadlocks"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("checked = %v, want %v", got, want) + } + // A finding in a subsystem removes it, wherever it sits in the order. + c.Findings = append(c.Findings, model.Finding{ID: "replica_lag_time", Severity: model.SeverityWarn}) + c.Health.DeadlocksPerMin = f64(0.5) + got = buildChecked(c) + for _, s := range got { + if s == "replication" || s == "deadlocks" { + t.Errorf("%q must not be listed as checked-clean: %v", s, got) + } + } + // Nothing collected → nothing claimed. + if got := buildChecked(sampleContext()); len(got) != 0 { + t.Errorf("bare context claims %v", got) + } + + var b strings.Builder + renderChecked(&b, styler{on: false}, want, 60) + lines := strings.Split(strings.TrimRight(b.String(), "\n"), "\n") + if len(lines) < 2 { + t.Fatalf("expected wrapping at width 60:\n%s", b.String()) + } + if !strings.HasPrefix(lines[0], "checked · queries") { + t.Errorf("first line: %q", lines[0]) + } + for _, l := range lines[1:] { + if !strings.HasPrefix(l, " · ") { + t.Errorf("continuation lacks the 8-space hanging indent: %q", l) + } + } + for _, l := range lines { + if n := utf8.RuneCountInString(l); n > 60 { + t.Errorf("line over width: %d %q", n, l) + } + } +} + +func TestGrouped_stripReplacesGoodAndSkipsSchema(t *testing.T) { + var b strings.Builder + renderGrouped(&b, styler{on: false}, gaugeContext(), 100) + out := b.String() + if strings.Contains(out, "GOOD") { + t.Error("GOOD block should be gone") + } + for _, w := range []string{"cache hit [", "rollbacks [", "Database health:", `pgbot ask "why is it slow?"`} { + if !strings.Contains(out, w) { + t.Errorf("grouped view missing %q:\n%s", w, out) + } + } + // The strip comes before the score. + if strings.Index(out, "cache hit") > strings.Index(out, "Database health:") { + t.Error("strip must sit above the score") + } + c := gaugeContext() + c.Profile = "schema" + b.Reset() + renderGrouped(&b, styler{on: false}, c, 100) + if strings.Contains(b.String(), "cache hit [") || strings.Contains(b.String(), "checked ·") { + t.Error("schema profile must not render the strip or the checked line") + } +} diff --git a/internal/render/terminal.go b/internal/render/terminal.go index 25c1afc..f4f1898 100644 --- a/internal/render/terminal.go +++ b/internal/render/terminal.go @@ -151,7 +151,8 @@ func Terminal(w io.Writer, c *model.Context, opts Options) error { fmt.Fprintln(&b, st.dim("baseline: "+opts.BaselinePath)) } } else { - // Graded, grouped summary: health score, CRITICAL/WARNING/NOTE, then GOOD. + // Graded, grouped summary: gauge strip, checked line, health score, then + // CRITICAL/WARNING/NOTE. renderGrouped(&b, st, c, width) } diff --git a/internal/render/terminal_test.go b/internal/render/terminal_test.go index 2217689..d4dd049 100644 --- a/internal/render/terminal_test.go +++ b/internal/render/terminal_test.go @@ -37,9 +37,9 @@ func TestTerminal_groupedIsDefault(t *testing.T) { t.Error("no-color output must contain no ANSI escapes") } out := buf.String() - // Grouped view: header, a health score, the warning group with the finding - // title bulleted, a GOOD list, and the --full pointer. No section tables. - for _, want := range []string{"connected", "postgres 17", "Database health:", "/100", "WARNING", "● 1 unused index", "GOOD", "--full"} { + // Grouped view: header, the gauge strip, a health score, the warning group + // with the finding title bulleted, and the --full pointer. No section tables. + for _, want := range []string{"connected", "postgres 17", "cache hit [", "Database health:", "/100", "WARNING", "● 1 unused index", "--full"} { if !strings.Contains(out, want) { t.Errorf("grouped output missing %q", want) } @@ -94,7 +94,7 @@ func TestFull_leadsWithStatusBoard(t *testing.T) { } } -func TestTerminal_cleanGroupedScoresHighAndListsGood(t *testing.T) { +func TestTerminal_cleanGroupedScoresHighAndShowsGauges(t *testing.T) { c := sampleContext() c.Findings = nil var buf bytes.Buffer @@ -102,9 +102,9 @@ func TestTerminal_cleanGroupedScoresHighAndListsGood(t *testing.T) { t.Fatal(err) } out := buf.String() - // No findings → perfect score, no CRITICAL/WARNING groups, a GOOD list that - // names the healthy cache hit with its value. - for _, want := range []string{"100/100", "GOOD", "cache hit ratio 99.4%"} { + // No findings → perfect score, no CRITICAL/WARNING groups, and the gauge + // strip still names the healthy cache hit with its value. + for _, want := range []string{"100/100", "cache hit [████████████████████] 99.4% ok"} { if !strings.Contains(out, want) { t.Errorf("clean grouped view missing %q", want) } @@ -115,8 +115,9 @@ func TestTerminal_cleanGroupedScoresHighAndListsGood(t *testing.T) { } // DoD 13: a schema-profile report states it is a schema check and makes no claim -// about a running database's health — no GOOD list (it infers health from a -// finding's absence), and the score is relabeled. +// about a running database's health — no gauge strip or checked line (they infer +// health from measurements and from a finding's absence), and the score is +// relabeled. func TestTerminal_schemaProfileIsHonest(t *testing.T) { c := sampleContext() c.Profile = "schema" @@ -130,8 +131,8 @@ func TestTerminal_schemaProfileIsHonest(t *testing.T) { t.Errorf("schema-profile header missing %q", want) } } - if strings.Contains(out, "GOOD") { - t.Error("schema profile must not print a GOOD list (it never ran those checks)") + if strings.Contains(out, "cache hit [") || strings.Contains(out, "checked ·") { + t.Error("schema profile must not print the gauge strip or the checked line (it never ran those checks)") } if strings.Contains(out, "Database health:") { t.Error("schema profile must relabel the score, not claim overall database health") diff --git a/internal/render/testdata/terminal_grouped.txt b/internal/render/testdata/terminal_grouped.txt index 5a69b00..30115af 100644 --- a/internal/render/testdata/terminal_grouped.txt +++ b/internal/render/testdata/terminal_grouped.txt @@ -1,12 +1,14 @@ connected · app · postgres 17.10 · read-only · — window + cache hit [████████████████████] 99.4% ok + lock wait [░░░░░░░░░░░░░░░░░░░░] — not measurable + rollbacks [░░░░░░░░░░░░░░░░░░░░] — not measurable + idle idx [░░░░░░░░░░░░░░░░░░░░] — not measurable + Database health: 97/100 WARNING ● 1 unused index -GOOD -● cache hit ratio 99.4% - Details: pgbot inspect --full · Machine-readable: --json -Ask it: pgbot ask "what's wrong?" +Ask it: pgbot ask "why is it slow?"