Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 15 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
83 changes: 14 additions & 69 deletions internal/render/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading