diff --git a/.gitignore b/.gitignore index 3c8d9a12..9bf2fdeb 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ Thumbs.db # Debug *.dSYM/ .claude/worktrees/ + +# vhs intermediate output for `bash demos/render-top.sh` +demos/top-demo.mp4 diff --git a/.samo/spec/top/SPEC.md b/.samo/spec/top/SPEC.md new file mode 100644 index 00000000..ac574c04 --- /dev/null +++ b/.samo/spec/top/SPEC.md @@ -0,0 +1,712 @@ +# /top — live TUI Postgres monitor + +> Spec version: **v0.2** (open questions resolved) · Slug: `top` · Status: **ready for S1** +> Format: samospec ([NikolayS/samospec](https://github.com/NikolayS/samospec)) +> Stack: Rust, ratatui 0.30, crossterm, tokio-postgres +> Target rpg release: next minor after v0.11.x + +--- + +## 1. Goal & Why + +**Goal.** Ship `/top`: a real-time, ratatui-powered TUI inside rpg that fuses the +best of `top(1)`, [`pg_top`](https://pg_top.gitlab.io/), and +[`pgcenter top`](https://github.com/lesovsky/pgcenter) into a single experience +a DBA reaches for during an incident — without leaving the rpg session. + +**Why.** + +- rpg already ships `/ash` (active session history) and `/dba` (one-shot + diagnostics), but lacks the always-on, top-style monitor that DBAs expect as + their first reflex. Today they shell out to `pgcenter` or `pg_top`. Closing + that gap completes the "modern Postgres terminal" promise from + `docs/blueprints/SPEC.md`. +- pg_top is unmaintained-ish (last release sparse, requires `pg_proctab` + extension for remote OS stats); pgcenter is excellent but Linux-only for + procfs and Go-binary separate from the user's psql session. +- rpg has the perfect substrate: an established ratatui flow (`/ash`), + a tokio-postgres connection, schema-aware completion, AI hooks, and a + single-binary distribution. `/top` is the natural next big feature. +- It must feel **cool** — modern color, sparklines, smooth refresh, mouse, + fuzzy filter, drill-down overlays, blocking tree, and confirm-before-kill + bulk admin actions. Not just a port; an upgrade. + +**Non-goals (this spec).** + +- Long-term recording (`/record`) or offline reports (`/report`) — separate spec. +- Wait-event sampling profiler (`/profile`) — separate spec, will share helpers. +- Replacing `/ash` (per-second active-session history) — `/ash` stays the + history view; `/top` is the live now-view. They cross-link via hotkey. +- Replacing `/dba` one-shot tables — `/dba` stays the scriptable form; many + views are shared via a common SQL crate. +- Linux-kernel-level metrics that require `pg_proctab` or a custom extension + in v1. Server-side helpers are a v1.1 extension point (§4.10). + +--- + +## 2. User Stories + +Written from the operator's perspective. Each maps to acceptance tests in §5. + +**US-1 · Incident triage.** *As an on-call DBA*, when an alert fires I run +`/top` and within 2 seconds see: active session count, top long-running +queries (sorted by `qtime` desc), wait-event distribution, blocked/blocker +chains, and a sparkline of TPS / deadlocks / temp-files for the last 60 s. + +**US-2 · Drill into a pid.** *As a DBA*, I move the cursor to a row and press +`Enter` (or click). An overlay opens with five tabs: **Query** (full text, +syntax-highlighted), **Plan** (`EXPLAIN`, no execute), **Explain Analyze** +(re-run with confirmation), **Locks** (held + waited), **Waits** (last-N +samples for this pid). `Esc` closes. + +**US-3 · Switch view.** *As an operator*, I press `1`–`9` (or the named keys +`a / d / t / i / s / r / p / w / f`) to jump between **Activity, Databases, +Tables, Indexes, Statements, Replication, Progress, WAL, Functions** — all +backed by Postgres `pg_stat_*` views. The header re-fits, sort order remembers +per-view. + +**US-4 · Sort & filter.** *As a DBA*, I press `o` to pick a sort column (or +click a column header), `O` to invert, and `/` to enter a fuzzy filter that +matches across query text, user, db, app_name, client_addr, state. `Esc` +clears. + +**US-5 · Blocking tree.** *As an oncall*, I press `b` (or open the dedicated +view from `/top`) and see an ASCII tree of blocked → blocker chains, with +each node showing pid, user, state, wait_event, txn age, query summary. +Roots are blockers with no parent. The tree updates each refresh. + +**US-6 · Bulk kill idle-in-transaction.** *As a DBA*, I press `K` and a modal +asks "cancel / terminate / dry-run", then "filter by state? (active / +idle-in-transaction / idle-in-transaction (aborted))", then "min duration?". +Confirmation lists pids that match before doing anything. `y` to commit, `n` +to cancel. Refusal-to-fire if >200 pids match without `--force`. + +**US-7 · Watch a long vacuum.** *As an operator*, I open the **Progress** +view; it streams `pg_stat_progress_vacuum / analyze / create_index / cluster +/ basebackup / copy` rows, each with a percent gauge, scanned/total blocks, +and ETA (linear extrapolation). + +**US-8 · Standby lag.** *As an oncall*, I open the **Replication** view; it +shows per-standby application_name, state, sent/write/flush/replay LSN, and +lag in **bytes** *and* **time** (using `pg_last_wal_receive_lsn()` / +`replay_lag` columns). Replication slots' restart_lsn and active flag. + +**US-9 · Pause & rewind.** *As a DBA*, I press `Space` to pause the refresh +loop. `[` and `]` step backward/forward through the in-memory ring buffer +(default 60 snapshots) so I can study a transient spike without losing it. + +**US-10 · Snapshot export.** *As an SRE*, I press `S` to dump the current +visible state (header + active view + timestamp) to +`./rpg-top-YYYY-MM-DDTHH-MM-SSZ.{json,txt}` for sharing in a postmortem. + +**US-11 · Batch mode.** *As a CI scripter*, I run `rpg --command "/top --once +--view activity --limit 20 --json"` and get a single JSON snapshot to stdout, +no TUI, suitable for piping into `jq`. + +**US-12 · Cross-link to `/ash` and AI.** *As a DBA*, I press `Shift-A` to +open `/ash` zoomed on the selected pid's session (when pg_ash is available), +or `Shift-X` to send the selected query to `/explain` and get an AI-augmented +plan walkthrough. + +**US-12b · AI overview of the whole view.** *As a DBA who just opened +`/top` mid-incident*, I press `Shift-I` ("Info") and rpg's AI reads the +current header, active view, sparklines, top-N rows, wait-event mix, and +any blocking chains, then produces a streaming Markdown summary like: +"17 active sessions, 3 idle-in-tx > 30 s. pid 12345 has held a lock for +17 m and is blocking 4 backends. Wait events are 60 % `IO.DataFileRead` — +buffer pressure. Suggested next steps: 1) inspect pid 12345 (Enter), 2) +check `shared_buffers`, 3) consider cancelling the idle-in-tx ring with +`K`." `Esc` closes; works offline-degraded (shows raw stats summary if no +AI provider is configured). + +**US-13 · Theme & accessibility.** *As a colorblind user*, I set +`top.theme = "deuteranopia"` in `.rpg.toml` and the threshold colors switch +to a colorblind-safe palette. 24-bit truecolor when available, 256-color +indexed fallback (matches `/ash` policy in `src/ash/renderer.rs`). + +**US-14 · Connection resilience.** *As a remote user*, when the DB connection +drops, the header turns red, the table freezes with a "stale 12s" badge, and +the sampler retries with exponential backoff (1s, 2s, 5s, 10s, capped). On +recovery, fresh data flows in without restart. + +--- + +## 3. Architecture + +`/top` mirrors `/ash`'s module layout (see `src/ash/{mod,renderer,sampler, +state}.rs`) so reviewers can pattern-match. New code lives under +`src/top/`. + + +``` + ┌────────────────────────────────┐ + │ rpg REPL (src/repl/, metacmd) │ + │ /top dispatcher │ + └──────────────┬─────────────────┘ + │ + ▼ + ┌───────────────────────────────────────────────────┐ + │ src/top/mod.rs │ + │ • run_top(client, settings, args) -- inline loop │ + │ • TUI lifecycle: enter raw + alt-screen │ + │ • crossterm event loop, redraw budget │ + └─────┬───────────────┬───────────────┬─────────────┘ + │ │ │ + ▼ ▼ ▼ + ┌────────────────┐ ┌──────────────┐ ┌────────────────┐ + │ state.rs │ │ sampler.rs │ │ renderer.rs │ + │ • App │ │ inline tick │ │ ratatui Frame │ + │ • View enum │ │ ─ snapshot │ │ per-view fns │ + │ • RingBuffer │ │ every │ │ Tabs, Table, │ + │ • Filter/Sort │ │ refresh_ │ │ Sparkline, │ + │ • KillSpec │ │ interval │ │ Gauge, Chart │ + │ • Theme │ │ ─ DB stats │ │ + Overlays │ + │ • Settings │ │ only (v1) │ │ │ + └──────┬─────────┘ └──────┬───────┘ └────────┬───────┘ + │ │ │ + ▼ ▼ ▼ + ┌────────────────────────────────────────────────────┐ + │ src/top/views/ │ + │ activity.rs databases.rs tables.rs indexes.rs │ + │ statements.rs replication.rs progress.rs │ + │ wal.rs functions.rs blocking.rs │ + └─────────────────────┬──────────────────────────────┘ + │ + ▼ + ┌────────────────────────────────────────────────────┐ + │ src/top/sql.rs │ + │ Static SQL strings for each view, version-gated │ + │ (PG14..PG18). Reused by /dba where overlap. │ + └─────────────────────┬──────────────────────────────┘ + │ + ▼ + ┌────────────────────────────────────────────────────┐ + │ tokio-postgres connection │ + │ (existing rpg session connection reused) │ + └────────────────────────────────────────────────────┘ +``` + + +### 3.1 Components + +- **`src/top/mod.rs`** — entry + `run_top(client: &Client, settings: &ReplSettings, args: TopArgs) -> Result<()>` + in S1 (mirrors `/ash`). Sets up alt-screen + raw mode (same RAII guard + pattern as `src/ash/mod.rs:53`), runs the sample → draw → poll-events + loop inline, restores terminal on drop or panic. Provides `--once` + headless path that bypasses the loop and prints text. + *Future evolution:* later sprints may move sampling to a separate + `tokio::task` with an `Arc>` once we add the drill-down + sub-sampler in S4 (so the overlay can sample at a different rate without + blocking the main view loop). +- **`src/top/state.rs`** — `App` struct: current `View`, `RingBuffer`, + `FilterState`, `SortState`, `KillSpec`, `Theme`, `Settings`, `LastError`. + All UI is a pure projection of `App`. +- **`src/top/sampler.rs`** — runs each tick from inside `run_top`'s loop in + S1 (the spec's separate-task design is deferred to a later sprint, see + §3.1 above). Each tick: timestamp, run the always-on header SQL plus the + active view's SQL, pack into a `Snapshot`, store on `App`. System stats + (cpu/mem/io/net) are out of scope for v1 per §4.10. +- **`src/top/renderer.rs`** — ratatui `draw(frame: &mut Frame, app: &App)`. + Layout: header bar (3 rows) → tabs (1 row) → body (flex) → footer (1 row). + Body delegates to a per-view renderer. +- **`src/top/views/*.rs`** — one file per view. Each exports `sql(pg_version) + -> &'static str`, `parse(rows) -> ViewData`, `render(frame, area, data, + app)`. Adding a view is a single new file plus a `View` enum variant. +- **`src/top/overlay.rs`** — drill-down modal (Q / E / A / L / W tabs), kill + confirmation modal, help (`?`), snapshot-export progress. +- **`src/top/sql.rs`** — co-locates SQL strings; tagged with the minimum PG + version they support; uses `pg_stat_activity.backend_type` (PG10+) and + `wait_event_type`/`wait_event` (PG9.6+); falls back gracefully when a + column is missing. +- **`src/top/theme.rs`** — palette, threshold colors (warn/crit by metric), + truecolor vs 256-color (reuse `terminal_has_truecolor` from + `src/ash/renderer.rs:25`), colorblind variants. +- **`src/top/keys.rs`** — keymap as data: `KeyEvent → Action`. Loaded from + defaults + optional `[top.keys]` table in `.rpg.toml`. Consistent with + `/ash` muscle memory where overlapping (`q` quits, `?` help, `Esc` cancels). +- **`src/top/admin.rs`** — `pg_cancel_backend`/`pg_terminate_backend` runner + with two-step confirm, dry-run mode, refusal threshold, and audit-log line + printed to stderr after exit. + +### 3.2 Data flow + +1. `/top` dispatcher passes the existing rpg `&Client` straight to `run_top`. + In S1 the loop is inline (sampler awaited, then redraw, then event poll); + later sprints can lift sampling onto a separate `tokio::task` if the + drill-down sub-sampler in S4 needs concurrent rates. +2. Each tick (default 1 s), the loop runs the active view SQL plus the + header SQL (always-on summary). System stats (cpu/mem/io/net) are out of + scope for v1 per §4.10. +3. Snapshot is stored on `App` (single source of truth for the renderer). +4. UI loop on every crossterm event or watch change calls + `terminal.draw(|f| renderer::draw(f, &app))`. Frame budget <16 ms; if a + view query exceeds it, sampler dispatches in a separate `spawn_blocking` + so the UI never stalls. +5. Pause (`Space`) freezes ingestion. `[`/`]` step the cursor through the + ring buffer; renderer reads the chosen snapshot instead of head. + +### 3.3 Trust boundaries & safety + +- **Read-only by default.** No mutating SQL runs without an explicit kill or + reset action initiated from the modal. +- **Confirm before kill.** Modal shows the exact pids and queries about to be + affected. Bulk >200 pids requires `--force` flag passed at launch. +- **Audit trail.** Every `pg_cancel_backend` / `pg_terminate_backend` is + appended to rpg's session log (`src/logging.rs`), with operator, target + pid, query digest, and outcome. +- **No shell escapes from the modal.** Filters and SQL parameters use + parameterized queries. +- **Connection isolation.** Sampler and admin actions share the rpg session + connection but always issue `SET LOCAL statement_timeout = '5s'` for + sampler queries to avoid wedging on a hostile lock. + +### 3.4 PostgreSQL compatibility (per `CLAUDE.md` matrix) + +| Feature | PG14 | PG15 | PG16 | PG17 | PG18 | +|---|---|---|---|---|---| +| Activity / blocking / locks | ✅ | ✅ | ✅ | ✅ | ✅ | +| `pg_stat_statements` (extension) | ✅ | ✅ | ✅ | ✅ | ✅ | +| `pg_stat_progress_*` core 6 | ✅ | ✅ | ✅ | ✅ | ✅ | +| `pg_stat_wal` | ✅ | ✅ | ✅ | ✅ | ✅ | +| `pg_stat_io` | — | — | ✅ | ✅ | ✅ | +| `pg_stat_subscription_stats` | ✅ | ✅ | ✅ | ✅ | ✅ | + +Version detection at startup; views that need newer columns are gated and +show a "requires PG ≥ 16" stub instead of a hard error. + +--- + +## 4. Implementation Details + +### 4.1 Command surface + +Per `docs/COMMANDS.md`, `/top` is an rpg-specific extension. Inside the REPL: + +``` +/top # interactive TUI, default view = activity +/top # open directly to a view + # activity, databases, tables, indexes, + # statements, replication, progress, wal, + # functions, blocking +/top --refresh 2s # custom refresh interval +/top --no-color # disable colors (CI / pipes) +/top --once # one snapshot, exit +/top --json # implies --once, JSON to stdout +/top --view activity --limit 20 # batch-mode filter +/top --filter "state = 'active'" # initial filter +/top --kill-allow-bulk # opt-in for bulk kill > 200 pids +/top --pid 12345 # open drill-down on a pid +``` + +CLI is also reachable from outside the REPL via the existing +`rpg --command "/top --once …"` path (see `src/main.rs`), making US-11 work. + +**Bare alias outside the REPL.** `rpg top [args...]` is a thin alias for +`rpg --command "/top args..."`, registered in the clap subcommand table next +to the existing rpg sub-commands. Mirrors `psql -c "\l"` muscle memory and +removes a quoting layer for shell users. + +### 4.2 Layout (default 80×24) + +``` +┌─ rpg /top ─ db=prod user=nik pg=16.4 uptime=14d ─ load 0.42 0.31 0.28 ─┐ +│ active 17 idle-in-tx 3 waiting 2 TPS ▁▂▃▅▇█▆▄▂▁ deadlocks · · · · 1 │ +│ cpu 23% mem 72% io 18 MiB/s net 4.2 MiB/s connection ● rt │ +├──────────────────────────────────────────────────────────────────────────┤ +│ [1]Activity [2]Db [3]Tables [4]Idx [5]Stmts [6]Repl [7]Prog [?] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ pid user db state wait_event qtime xtime query │ +│ 12345 app prod active IO.DataFileRead 42 s 42 s UPDATE…│ +│ 12346 etl analytics active Lock.transactionid 2.3s 17 m SELECT…│ +│ ... │ +├──────────────────────────────────────────────────────────────────────────┤ +│ q quit ↑↓ move ⏎ drill / filter o sort Space pause K kill ? help │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +Wider terminals get more columns (client_addr, application_name, locks, +transaction id, backend_type). Narrow terminals collapse to pid/qtime/query. + +### 4.3 ratatui specifics + +- **Widgets used:** `Tabs`, `Table` (with `Row::new`/`Cell`), `Paragraph`, + `Sparkline`, `Gauge`, `Chart`/`Dataset` (for the wait-event distribution + bar), `Block`/`Borders`, `List` for help and dropdowns. Custom mini-widgets + for the lock blocking tree and the "lag in bytes/time" two-cell gauge. +- **Async + crossterm:** event polling on a dedicated thread bridged to a + `mpsc` channel (same pattern as `src/ash/mod.rs:112`). Render at most once + per 16 ms (60 FPS cap) regardless of event volume. +- **Mouse:** `crossterm::event::EnableMouseCapture`. Click on tab → switch + view. Click on column header → sort. Click on row → select. Drag on + sparkline → time-cursor in pause mode. +- **Truecolor:** reuse `terminal_has_truecolor()` from `src/ash/renderer.rs:25`. + Theme provides `(rgb, idx256)` pairs and picks at draw time. +- **Min terminal size:** 24 rows × 80 cols. Below that, a centered "terminal + too small" panel like `src/ash/renderer.rs:1463`. + +### 4.4 Refresh & throttling + +- Default `refresh_interval = 1s`, configurable in `.rpg.toml` + `[top] refresh = "1s"`, runtime via `r` (prompt) or `+`/`-` ±0.5 s. +- Auto-throttle: if the previous tick took >500 ms, double the interval + until it recovers; surface as a yellow badge "throttled 4s". +- Pause when the rpg pane loses focus (terminal `FocusLost` event) — opt-in + via `[top] pause_on_blur = true`. + +### 4.5 Filtering & sort + +- `/` opens a fuzzy filter prompt at the footer. Match algorithm: + case-insensitive substring across (user, db, state, wait_event, app_name, + client_addr, query). Backslash-escaped `state:active` syntax for column- + scoped match (`state:active wait:Lock`). +- `o` opens a sort-column picker (List widget). `O` toggles ascending / + descending. Sort state persists per-view in `App`. +- Filter and sort survive view switches when the column exists on the new + view; otherwise they reset with a one-line toast. + +### 4.6 Drill-down overlay + +- Layout: 80% × 80% centered popup, 5 tabs. + 1. **Query** — full text from `pg_stat_activity.query`, syntax-highlighted + using rpg's existing highlighter (`src/highlight.rs`). + 2. **Plan** — `EXPLAIN` of the query (no execute). Cached for 5 s. + 3. **Plan+Analyze** — `EXPLAIN ANALYZE` (re-runs!). Yellow warning, + requires `y`-confirm. Disabled when query is not a plain `SELECT`/CTE. + 4. **Locks** — held + waited locks for this pid via `pg_locks` joined to + `pg_class` for relation names; mode, granted, transactionid. + 5. **Waits** — last 60 wait-event samples for this pid (1 Hz polling), + rendered as a stacked bar like `/ash`'s view. +- `Tab` / `Shift-Tab` move between tabs. `Esc` closes. + +### 4.7 Blocking tree (US-5) + +- SQL: `pg_blocking_pids()` per active row, then a recursive walk in Rust + to build the forest. (Pure-SQL recursion via CTE is also viable but the + Rust walk lets us inject query-summary and wait-event nicely.) +- Render: indented ASCII tree using `└─` / `├─` / `│ ` (matches `tree(1)`). + Node line: `pid state wait txn_age user@db : query_summary`. +- Cycles defended: track visited pids; cap depth at 32 with a `…` truncation + marker. + +### 4.8 Bulk admin actions (US-6) + +- Trigger keys: `k` (cancel single, prompt pid), `K` (bulk modal). +- Modal flow: + 1. Action: cancel | terminate | dry-run. + 2. Filter: state = active | idle in transaction | idle in transaction + (aborted) | idle > N | matches current `/` filter. + 3. Min duration in seconds. + 4. Confirmation panel listing matched rows (pid, user, db, state, age, + query summary), capped at 50 rows preview, with `(+N more)`. + 5. `y` to fire, `n` to cancel. If >200 rows and `--kill-allow-bulk` not + passed, refuse and instruct. +- Audit line example: + `[2026-05-12T14:33:11Z] /top kill nik@prod terminate state=idle-in-tx + min_age=30s matched=12 succeeded=12 failed=0`. + +### 4.9 Configuration (`.rpg.toml` extension) + +```toml +[top] +refresh = "1s" +default_view = "activity" +hide_idle = true +theme = "default" # "default" | "dark" | "light" | "deuteranopia" +pause_on_blur = false +ringbuffer_size = 60 # snapshots kept for rewind +sparkline_window = "60s" + +[top.thresholds] +qtime_warn_s = 1.0 +qtime_crit_s = 30.0 +xtime_warn_s = 60.0 +xtime_crit_s = 600.0 +locks_warn = 5 +locks_crit = 50 + +[top.keys] +# overrides; full default list in `src/top/keys.rs` +quit = "q" +help = "?" +filter = "/" +sort = "o" +kill = "k" +kill_bulk = "K" +ai_explain = "X" # Shift-X — eXplain selected pid via AI +ai_info = "I" # Shift-I — Info overview of whole /top view via AI +mouse = true +``` + +### 4.10 System stats — out of scope for v1 + +Per the v0.2 review, **no procfs reader and no server-side helpers in v1**. +The header shows DB-side stats only (active sessions, TPS, deadlocks, temp +files, replication lag) and intentionally leaves the cpu/mem/io/net cells +blank with a one-line "system stats: not collected" caption. Reintroduction +is a separate spec — likely `.samo/spec/top-system-stats/` — and is a clean +additive change because `Snapshot` already has the optional fields. + +### 4.11 AI hand-offs + +Two AI keys, both gated on rpg's existing AI provider configuration +(`/budget`, `/clear`, `/ask` already wired). When no provider is configured, +each key shows a help line pointing at `/init`. + +- **`Shift-X` — eXplain selection.** Sends the selected pid's row plus + the current `Plan` (cached EXPLAIN, not ANALYZE) to `/explain`. Streams + the response into the right half of the drill-down overlay so the user + can read the plan and the AI commentary side-by-side. Cancel with `Esc`. + Mnemonic matches the existing rpg `/explain` slash-command. +- **`Shift-I` — Info on whole view.** Packages the header (DB summary, + load, sparklines), the active view name, current sort/filter, top-N + rows (default 20, configurable), wait-event mix, and the blocking-tree + summary into a structured prompt and streams a Markdown answer into a + bottom-up panel. Includes "next-step" hints the operator can act on. + Has `--ai-info` batch flag for `--once --json` mode (returns + `{view: ..., info_markdown: ...}`). + +Both hand-offs reuse `src/repl/ai_commands.rs` plumbing; nothing new in +the AI surface, only new entry points. The packaged context is capped at +~2k tokens by sampling rows and truncating long queries (with a +"truncated" marker). + +### 4.12 WASM target + +`/top` is gated `#[cfg(not(target_arch = "wasm32"))]` like `/ash` and `/rpg`. +WASM users get a friendly stub (mirrors `src/repl/ai_commands.rs:530`). + +### 4.13 Mouse default + +Mouse is **on by default** (v0.2 decision). `crossterm::EnableMouseCapture` +is set during alt-screen entry. Terminals that don't support mouse simply +ignore the escape sequences. Opt-out: `[top] mouse = false` in `.rpg.toml` +or `--no-mouse` at launch (also useful for users who want native terminal +copy/paste, which mouse capture intercepts). + +--- + +## 5. Tests Plan (red/green TDD) + +Per `CLAUDE.md`, every fix gets a failing test first. New features here are +tested via four layers, each with concrete files and runners. + +### 5.1 Unit tests (Rust `#[cfg(test)]`) + +- `src/top/state.rs`: filter parsing (`state:active wait:Lock`), sort-state + invariants, ring-buffer wrap, kill-spec validation, threshold mapping. +- `src/top/sql.rs`: per-PG-version SQL string compiles and round-trips + through `tokio-postgres::statement::Statement::parse_for(...)` (no DB). +- `src/top/keys.rs`: keymap merge (defaults vs user overrides) and conflict + detection. + +### 5.2 Snapshot rendering tests (`insta`) + +Add `insta` to `[dev-dependencies]`. Tests build an `App` with fixture +snapshots and call `renderer::draw` into a fixed-size `TestBackend` +(`ratatui::backend::TestBackend`), then assert against committed snapshots. + +```rust +#[test] +fn renders_activity_view_default() { + let mut term = Terminal::new(TestBackend::new(120, 30)).unwrap(); + let app = App::with_snapshot(fixture("activity_busy.json")); + term.draw(|f| renderer::draw(f, &app)).unwrap(); + insta::assert_snapshot!(term.backend()); +} +``` + +Cover: each view × (light/dark theme) × narrow/wide layout × stale-data +badge × kill-confirm modal × help overlay × empty-state. + +### 5.3 Property tests (`proptest`) + +- Sort: round-trip `sort(filter(snapshot)) == filter(sort(snapshot))` (when + filter is not column-scoped). Stable sort preserves insertion order on ties. +- Ring buffer: `push` then `cursor_at(-i)` returns the i-th-latest item for + any `i ≤ size`. +- Filter parser: any string parses to a non-crashing `FilterSpec`. + +### 5.4 Integration tests (gated by `--features integration`, real PG) + +CI already runs an embedded Postgres matrix (PG14–PG18) — see `.github/ +workflows/`. Add: + +- `tests/top_smoke.rs`: spin up rpg against the test cluster, run + `/top --once --json` for each view, assert non-empty JSON with the + expected schema. +- `tests/top_blocking.rs`: open two sessions, deliberately create a + blocker → blocked pair, run `/top blocking --once`, assert the tree has + exactly one root and one leaf with matching pids. +- `tests/top_kill.rs`: spawn a long-running `pg_sleep(60)`; run + `/top --once --kill-action cancel --filter "state='active'" + --kill-allow-bulk`; assert the spawned session is cancelled and audit + line is emitted to stderr. + +These fixtures must follow the new "serialize catalog-mutating smoke tests" +pattern from #836. + +### 5.5 Manual TUI checklist + +Reviewers run a 12-step manual test before approval (paste into PR +description, tick each): + +1. `/top` opens, header populated within 2 s. +2. Press `1`–`9`, each view renders without overflow at 80×24. +3. `↑/↓` selects rows; `Enter` opens drill-down; `Esc` closes. +4. `/` filter narrows results; `Esc` clears. +5. `o` opens sort picker; `O` inverts. +6. `Space` pauses; `[`/`]` rewind; header shows "PAUSED @ T-3s". +7. `k` cancels single pid (with confirmation). +8. `K` bulk modal: dry-run shows preview without firing. +9. Disconnect the DB (kill psql server briefly); header turns red, + reconnect succeeds within 10 s. +10. `S` saves snapshot to `./rpg-top-*.json`, file is valid JSON. +11. `?` help overlay lists all keymaps. +12. `q` exits cleanly; terminal restored, no leftover alt-screen. + +--- + +## 6. Team of Veteran Experts + +The lean delivery panel for `/top`: + +- **Rust + tokio engineer (lead).** Owns `mod.rs`, `state.rs`, `sampler.rs`, + the event loop, and the WASM cfg gate. Familiar with the existing rpg + REPL and `/ash` patterns. +- **Ratatui / TUI specialist.** Owns `renderer.rs`, all `views/*`, the + drill-down and kill modals, theme, mouse and focus handling. Comfortable + with crossterm event multiplexing. +- **Postgres internals SME.** Owns `sql.rs` and `views/*` SQL bodies; PG14–18 + matrix; understands `pg_stat_activity` semantics, locking, replication, + progress views, and `pg_stat_io` (PG16+). Reviews kill semantics. +- **DBA reviewer (UX).** Sets the bar for "feels like a tool I'd use during + an incident." Validates keymap, default sort, threshold colors, and the + blocking tree layout. +- **QA + property-test author.** Writes `proptest` and `insta` snapshot + suites, defines the integration matrix, owns the manual-test checklist. + +Reuses the AI-panel approach codified in samospec (lead drafts; ops/security +reviewer + QA reviewer critique; convergence). + +--- + +## 7. Sprint Plan + +Eight sprints, ~1 week each. Each sprint ends with a green PR (per +`CLAUDE.md` PR workflow: CI green → REV review → squash merge). + +**S1 · Scaffold & Activity view (the MVP demo).** +- Wire `/top` into the metacmd dispatcher. +- `mod.rs` lifecycle: enter alt-screen, raw mode, panic-safe restore. +- Header bar (DB summary + load + connection LED). +- `views/activity.rs` reading `pg_stat_activity` + `pg_blocking_pids`. +- Footer with hint line; `q` to quit. +- Tests: unit + first `insta` snapshot for activity view. +- **Definition of done:** a screencast showing live updates and a + green CI on PG14–18. + +**S2 · Tabs & view switching.** +- `Tabs` widget; numeric and named hotkeys. +- Add `databases`, `tables`, `indexes`, `statements`, `wal`, `functions` + views (read-only, no drill-in yet). +- Per-view sort state. +- Tests: snapshot per view × narrow/wide. + +**S3 · Filter, sort, mouse, theming.** +- `/` fuzzy filter + scoped syntax. +- `o`/`O` sort picker. +- Mouse: tab click, column-header sort, row select. +- Theme: default + dark + light + deuteranopia, truecolor detection. +- Tests: property tests on filter parser + sort. + +**S4 · Drill-down overlay + AI hand-offs.** +- Q / E / A / L / W tabs. +- EXPLAIN cache; EXPLAIN ANALYZE confirmation. +- Locks pane joining `pg_locks` to `pg_class`. +- Waits sub-sampler (1 Hz) for the selected pid. +- `Shift-X` — AI eXplain on the selected pid (streams into the right half + of the overlay; reuses `src/repl/ai_commands.rs`). +- `Shift-I` — AI Info overview of the whole `/top` view (bottom-up streaming + panel; structured prompt with header + top-N rows + waits + blockers). +- `--ai-info` batch flag for headless mode. +- Tests: integration test with a deliberately blocked session; AI hand-off + uses a stub provider in tests to assert prompt contents (no real API call). + +**S5 · Blocking tree, replication, progress.** +- `views/blocking.rs` recursive walk, ASCII tree. +- `views/replication.rs` per-standby with bytes + time lag. +- `views/progress.rs` for the six `pg_stat_progress_*`. +- Sparklines (TPS, deadlocks, temp files) in header. +- Tests: integration test that creates a blocker chain and asserts the tree. + +**S6 · Admin actions (cancel/terminate, bulk, audit).** +- `k` single, `K` bulk modal flow with two-step confirm and dry-run. +- Audit line into rpg log. +- Refuse-bulk threshold + `--kill-allow-bulk`. +- Tests: integration with deliberate `pg_sleep` victims; assert cancel + outcome and audit emission. + +**S7 · Pause/rewind, batch mode, snapshot export, config.** +- Ring buffer + pause/step. +- `--once` and `--json` headless paths. +- `S` snapshot export (json + txt). +- `.rpg.toml [top]` config plumbing + key remap. +- Tests: golden JSON for `--once`; round-trip of saved snapshot file. + +**S8 · Polish, perf, docs, REV gate, release.** +- Profile: `criterion` micro-benches for sampler parse + renderer draw. +- Frame-budget guard + auto-throttle badge. +- Update `docs/COMMANDS.md`, write `docs/top.md` user guide. +- Update `CHANGELOG.md` and `Cargo.toml` per release checklist in + `CLAUDE.md`. +- REV review pass; address blockers; squash merge. + +**Critical-path risks & mitigations.** +- *Render perf at high session counts:* incremental table virtualization + (only render visible rows). Bench in S1, fix in S8 if needed. +- *Connection contention with the rpg REPL session:* sampler always + releases the lock between queries; admin actions take the lock once + with a 5 s timeout. +- *PG version drift:* version-gated SQL with stub views and a single + `pg_version_at_least(major, minor)` helper, tested in CI matrix. + +--- + +## 8. Embedded Changelog + +- **v0.2 — 2026-05-07.** Open-question review round. Resolutions: + (1) **No system stats in v1** — header shows DB-side stats only; cpu/ + mem/io/net cells left blank with a "not collected" caption; reintroduction + is a separate spec. (2) **`rpg top` alias** added as a clap subcommand. + (3) **AI hand-offs accepted into S4**: `Shift-X` ("eXplain") for AI + deep-dive on the selected pid; `Shift-I` ("Info") streams an AI overview + of the entire `/top` view — header + sparklines + top-N rows + waits + + blocking tree — into a bottom-up panel, with `--ai-info` for headless. + Mnemonic: X = eXplain (matches the existing `/explain` slash-command), + I = Info. (4) **Mouse on by default**, opt-out via `[top] mouse = false` + or `--no-mouse`. Sprint count unchanged (8); S4 widened to include both + AI keys; §4.10 rewritten; §4.11 added; §4.13 added. +- **v0.1 — 2026-05-07.** Initial draft authored against the rpg `main` + branch at commit `7f93ce3`. Sourced from a comparative study of pg_top + (gitlab.com/pg_top/pg_top) and pgcenter (github.com/lesovsky/pgcenter). + Mirrors `/ash`'s module layout. Ratatui-first, 80×24-friendly, mouse- + optional, kill-with-confirm. Eight-sprint plan ending with a release + through the standard CI → REV → squash-merge flow. + +--- + +## 9. Open questions — resolved at v0.2 + +1. **`/top` vs extending `/ash`.** Resolved at v0.1: separate command; + cross-link via `Shift-A`. +2. **System stats in v1.** Resolved at v0.2: **out of scope for v1.** + No procfs reader, no server-side helpers; header omits cpu/mem/io/net + cleanly. Future spec to reintroduce. +3. **`rpg top` outside the REPL.** Resolved at v0.2: **yes**, ship as a + clap subcommand alias. +4. **AI hand-off.** Resolved at v0.2: **yes, in S4**, with two keys — + `Shift-X` (eXplain selected pid) and `Shift-I` (Info overview of the + whole view). +5. **Mouse default.** Resolved at v0.2: **on by default**, opt-out via + `[top] mouse = false` or `--no-mouse`. diff --git a/.samo/spec/top/architecture.json b/.samo/spec/top/architecture.json new file mode 100644 index 00000000..baa91859 --- /dev/null +++ b/.samo/spec/top/architecture.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://raw.githubusercontent.com/NikolayS/samospec/main/schemas/architecture.schema.json", + "version": "0.2", + "slug": "top", + "title": "rpg /top — live TUI Postgres monitor", + "groups": [ + { "id": "ui", "label": "ratatui UI" }, + { "id": "core", "label": "/top core" }, + { "id": "data", "label": "data plane" }, + { "id": "ai", "label": "AI hand-offs" }, + { "id": "rpg", "label": "rpg host" }, + { "id": "pg", "label": "PostgreSQL 14–18" } + ], + "nodes": [ + { "id": "repl", "group": "rpg", "label": "REPL / metacmd dispatcher", "kind": "module" }, + { "id": "clap", "group": "rpg", "label": "clap subcommand: rpg top", "kind": "module" }, + { "id": "log", "group": "rpg", "label": "session log (audit)", "kind": "module" }, + { "id": "highlight", "group": "rpg", "label": "syntax highlighter", "kind": "module" }, + { "id": "ai_cmds", "group": "ai", "label": "src/repl/ai_commands.rs", "kind": "module" }, + { "id": "ai_explain", "group": "ai", "label": "Shift-X — eXplain selection", "kind": "feature" }, + { "id": "ai_info", "group": "ai", "label": "Shift-I — Info on whole view","kind": "feature" }, + + { "id": "modrs", "group": "core", "label": "src/top/mod.rs (lifecycle)", "kind": "module" }, + { "id": "state", "group": "core", "label": "state.rs (App, ringbuf, filter, sort)", "kind": "module" }, + { "id": "keys", "group": "core", "label": "keys.rs (keymap)", "kind": "module" }, + { "id": "theme", "group": "core", "label": "theme.rs (palette, thresholds)", "kind": "module" }, + { "id": "admin", "group": "core", "label": "admin.rs (cancel/terminate, bulk)", "kind": "module" }, + + { "id": "renderer", "group": "ui", "label": "renderer.rs (draw frame)", "kind": "module" }, + { "id": "views", "group": "ui", "label": "views/* (per-view render+SQL)", "kind": "module" }, + { "id": "overlay", "group": "ui", "label": "overlay.rs (drill-down, modals, help)", "kind": "module" }, + + { "id": "sampler", "group": "data", "label": "sampler.rs (tokio task)", "kind": "module" }, + { "id": "sql", "group": "data", "label": "sql.rs (PG-version-gated SQL)", "kind": "module" }, + { "id": "pgconn", "group": "data", "label": "tokio-postgres connection (shared)", "kind": "module" }, + + { "id": "pgstats", "group": "pg", "label": "pg_stat_* views", "kind": "external" }, + { "id": "pglocks", "group": "pg", "label": "pg_locks / pg_blocking_pids", "kind": "external" }, + { "id": "pgprogress", "group": "pg", "label": "pg_stat_progress_*", "kind": "external" }, + { "id": "pgrepl", "group": "pg", "label": "pg_stat_replication / slots", "kind": "external" } + ], + "edges": [ + { "from": "repl", "to": "modrs", "label": "/top dispatch" }, + { "from": "clap", "to": "modrs", "label": "rpg top alias" }, + { "from": "overlay", "to": "ai_explain","label": "Shift-X" }, + { "from": "renderer", "to": "ai_info", "label": "Shift-I (bottom panel)" }, + { "from": "ai_explain","to": "ai_cmds", "label": "/explain on selected pid" }, + { "from": "ai_info", "to": "ai_cmds", "label": "Info prompt (header + rows + waits)" }, + { "from": "modrs", "to": "state", "label": "owns App" }, + { "from": "modrs", "to": "sampler", "label": "spawn task" }, + { "from": "modrs", "to": "renderer", "label": "draw loop" }, + { "from": "modrs", "to": "keys", "label": "key → action" }, + { "from": "renderer", "to": "views", "label": "delegate per view" }, + { "from": "renderer", "to": "overlay", "label": "modal stack" }, + { "from": "renderer", "to": "theme", "label": "palette + truecolor" }, + { "from": "overlay", "to": "highlight","label": "query syntax" }, + { "from": "sampler", "to": "sql", "label": "fetch query strings" }, + { "from": "sampler", "to": "pgconn", "label": "execute" }, + { "from": "sampler", "to": "state", "label": "Snapshot" }, + { "from": "admin", "to": "pgconn", "label": "pg_cancel / pg_terminate" }, + { "from": "admin", "to": "log", "label": "audit line" }, + { "from": "pgconn", "to": "pgstats", "label": "SELECT" }, + { "from": "pgconn", "to": "pglocks", "label": "SELECT" }, + { "from": "pgconn", "to": "pgprogress","label": "SELECT" }, + { "from": "pgconn", "to": "pgrepl", "label": "SELECT" } + ], + "notes": [ + "All /top mutating actions (cancel/terminate, EXPLAIN ANALYZE) are gated by an explicit modal confirmation and an audit line.", + "Sampler queries always run with SET LOCAL statement_timeout = '5s' to avoid wedging on hostile locks.", + "v1 ships with no system-stats collection (no procfs, no server-side helpers); the header omits cpu/mem/io/net cells. Reintroduction is a separate spec.", + "Compiled out on wasm32; /top is a native-only command (matches /ash, /rpg).", + "Module layout intentionally mirrors src/ash/{mod,renderer,sampler,state}.rs for reviewer pattern-matching." + ] +} diff --git a/.samo/spec/top/decisions.md b/.samo/spec/top/decisions.md new file mode 100644 index 00000000..6850b5c2 --- /dev/null +++ b/.samo/spec/top/decisions.md @@ -0,0 +1,88 @@ +# /top — decisions log + +samospec convention: every accepted/rejected/deferred design choice is captured +here so future reviewers can see *why*, not only *what*. + +## v0.2 — open-question review (2026-05-07) + +### Accepted (this round) + +- **System stats out of v1.** No procfs reader and no server-side helpers + in v1 of `/top`. Header omits cpu/mem/io/net cells with a one-line + caption. Reintroduction lives in a separate spec + (`.samo/spec/top-system-stats/`, future). `Snapshot` keeps the optional + fields so adding it later is additive. +- **`rpg top` clap subcommand alias.** Bare `rpg top [args...]` aliases + to `rpg --command "/top args..."`. Mirrors `psql -c "\l"` muscle memory + and removes a quoting layer. +- **Two AI hand-off keys, both in S4.** Mnemonic split: `X` = eXplain + (matches the existing `/explain` slash-command), `I` = Info. + - `Shift-X` — eXplain the selected pid (sends row + cached EXPLAIN + through `/explain`, streams into the drill-down overlay). + - `Shift-I` — Info overview of the whole view (packages header, + sparklines, top-N rows, waits, blocking tree; streams to a bottom-up + panel; has `--ai-info` for headless mode). + Both reuse `src/repl/ai_commands.rs` plumbing; no new AI surface. +- **Mouse on by default.** `crossterm::EnableMouseCapture` is set on + alt-screen entry. Opt-out via `[top] mouse = false` or `--no-mouse` + (useful for native terminal copy/paste). + +### Implications for sprint plan + +- S4 grows to absorb both AI keys (still bounded by stub-provider tests, + no real API calls in CI). +- S5 no longer carries any procfs / system-stats work — pure DB-side + features (replication, progress, sparklines). +- No new dependencies needed. Ratatui 0.30 and crossterm already cover + mouse capture; AI plumbing already in tree. + +--- + +## v0.1 — initial draft (2026-05-07) + +### Accepted + +- **Single new command, `/top`, in the `/` namespace.** Per `docs/COMMANDS.md`, + any rpg-specific extension uses `/`. Adding `/top` (vs hijacking the existing + psql-style `\watch`) keeps the namespace policy intact. +- **Mirror `/ash` module layout.** New code under `src/top/{mod,renderer, + sampler,state}.rs` so reviewers can pattern-match. Reuse `terminal_has_ + truecolor()` and the small-terminal stub from `src/ash/renderer.rs`. +- **Ratatui 0.30** (already in `Cargo.toml`). No new heavyweight dep. +- **Native-only.** `#[cfg(not(target_arch = "wasm32"))]` gate, mirroring + `/ash` and `/rpg` in `src/repl/ai_commands.rs`. +- **Read-only by default.** Cancel/terminate require an explicit modal + confirm; bulk >200 needs `--kill-allow-bulk`. +- **Eight-sprint plan.** S1 ships an MVP demo (activity view + header) + behind CI-green + REV review; later sprints layer features. + +### Deferred + +- **Server-side OS-stats helper functions** (`rpg.system_stats()`). Out of + scope for v1; v0.2 confirms system stats overall are deferred to a + separate future spec. v1 of `/top` shows DB-side stats only. +- **`/profile` (wait-event sampler) and `/record`/`/report` (offline + recordings).** These are separate features identified during the same + pg_top + pgcenter study; their specs will live alongside this one under + `.samo/spec/{profile,record,report}/`. +- **`pg_proctab`-based remote OS stats** (pg_top's mechanism). Not adopted; + we prefer rpg-native helper functions when extension installation is + acceptable, and procfs when local. +- **AI hand-off (`Shift-I` send selected query to `/explain`).** Listed in + Open Questions; deferred to S4 or later depending on review. + +### Rejected + +- **Extending `/ash` instead of a new command.** `/ash` is a *history* view; + `/top` is a *now* view. Conflating them confuses semantics and key bindings. + Cross-link with `Shift-A` instead. +- **Pure-SQL recursive blocking tree.** Doable via CTE on `pg_blocking_pids` + but harder to enrich with query summaries and wait events. Rust-side + walk picked for clarity; SQL fallback can be added later if needed. +- **Hard requirement on `pg_stat_statements`.** Optional — the Statements + view shows a clear "extension not installed" stub instead of breaking. + +### Open + +See §9 of `SPEC.md` ("Open questions for the review round"). These are +expected to converge by v0.2 of this spec. diff --git a/demos/README.md b/demos/README.md index 6910f365..f2bcbd57 100644 --- a/demos/README.md +++ b/demos/README.md @@ -10,6 +10,7 @@ the [VHS](https://github.com/charmbracelet/vhs) tape files used to render them. | `gif3_t2s.gif` | `\t2s` text-to-SQL with confirmation, then `\yolo` auto-execute | | `gif4_pspg.gif` | Built-in pager → `\set PAGER pspg` → same query routed through pspg | | `gif5_lua.gif` | Custom Lua commands: `\commands`, `\slow_mean`, `\slow_total`, `\table_info` | +| `top-demo.gif` | `/top` live TUI Postgres monitor — Activity view + cursor navigation + `/top --once` headless mode | ## Prerequisites @@ -46,12 +47,33 @@ vhs demos/gif2_typo.tape vhs demos/gif3_t2s.tape vhs demos/gif4_pspg.tape vhs demos/gif5_lua.tape +bash demos/render-top.sh # /top — live TUI monitor (PR #837) ``` -Or render all at once: +The `/top` demo uses `demos/top-workload.sh` to spawn a steady stream of +mixed backends (active queries, idle-in-tx, advisory-lock contention). +The tape expects a local Postgres on `localhost:55433` as `postgres` — +adjust the `PG{HOST,PORT,USER,DATABASE}` env block at the top of the +tape and the `CONNINFO` argument of `top-workload.sh` for your setup. + +`render-top.sh` runs vhs to produce both `.gif` and `.mp4`, then re-encodes +the gif from the mp4 through ffmpeg's palette pipeline (15 fps, bayer +dither). The native vhs gif quantizer drops frames during the busy +workload section and looks laggy; the ffmpeg pass costs ~3× file size +(11 MiB vs 3 MiB) but plays smoothly. The intermediate `top-demo.mp4` is +gitignored. The tape passes `--show-keys` so each keystroke flashes on +screen — drop the flag if you want a clean recording. + +Or render all at once (the `top-demo` tape is skipped here because it +needs the `render-top.sh` ffmpeg post-process — running plain `vhs` on +it produces the laggy gif the script was written to fix): ```bash -for tape in demos/*.tape; do vhs "$tape"; done +for tape in demos/*.tape; do + [[ "$tape" == *top-demo.tape ]] && continue + vhs "$tape" +done +bash demos/render-top.sh ``` ## Note on gif1 re-renders diff --git a/demos/render-top.sh b/demos/render-top.sh new file mode 100755 index 00000000..f4c604c3 --- /dev/null +++ b/demos/render-top.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +IFS=$'\n\t' + +# Render the /top demo gif. vhs's native gif quantizer produces visible +# stuttering on the long workload section; using vhs's mp4 as the source +# and re-encoding through ffmpeg's palette pipeline gives a much smoother +# result at the cost of file size (~11 MiB vs ~3 MiB). +# +# Usage: +# bash demos/render-top.sh +# +# Output: demos/top-demo.gif (final), demos/top-demo.mp4 (intermediate, +# not committed to the repo). + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# Explicit ${TMPDIR}-rooted path: macOS BSD `mktemp -t prefix` does not +# treat XXXXXX as a template — it appends a random suffix to the whole +# string, so a request for `…palette.XXXXXX.png` ends in `.RANDOM` and +# ffmpeg's image2 muxer can't infer the codec. PID-stamped path keeps +# the .png extension intact and is safe under the trap-based cleanup. +PALETTE="${TMPDIR:-/tmp}/top-demo-palette-$$.png" +readonly REPO_ROOT +readonly TAPE="${REPO_ROOT}/demos/top-demo.tape" +readonly GIF="${REPO_ROOT}/demos/top-demo.gif" +readonly MP4="${REPO_ROOT}/demos/top-demo.mp4" +readonly PALETTE + +cleanup() { + rm -f "${PALETTE}" + rm -f "${MP4}" +} +trap cleanup EXIT + +main() { + pkill -f top-workload 2>/dev/null || true + sleep 1 + + # vhs reads the tape; the tape declares both gif + mp4 outputs. + vhs "${TAPE}" + + # Re-encode the mp4 through ffmpeg's palette pipeline (15 fps, 1200 px + # wide, bayer dither) and overwrite the vhs gif with the smoother one. + ffmpeg -y -i "${MP4}" \ + -vf "fps=15,scale=1200:-1:flags=lanczos,palettegen=stats_mode=diff" \ + "${PALETTE}" + + ffmpeg -y -i "${MP4}" -i "${PALETTE}" \ + -lavfi "fps=15,scale=1200:-1:flags=lanczos[x];\ +[x][1:v]paletteuse=dither=bayer:bayer_scale=4:diff_mode=rectangle" \ + "${GIF}" + + printf "Wrote %s (%s bytes)\n" "${GIF}" \ + "$(stat -f '%z' "${GIF}" 2>/dev/null || stat -c '%s' "${GIF}")" +} + +main "$@" diff --git a/demos/top-demo.gif b/demos/top-demo.gif new file mode 100644 index 00000000..c3caa9ae Binary files /dev/null and b/demos/top-demo.gif differ diff --git a/demos/top-demo.tape b/demos/top-demo.tape new file mode 100644 index 00000000..c3dc2e82 --- /dev/null +++ b/demos/top-demo.tape @@ -0,0 +1,147 @@ +Output demos/top-demo.gif +Output demos/top-demo.mp4 +Set FontSize 14 +Set Width 1400 +Set Height 800 +Set Theme "Dracula" +Set Shell "zsh" +Set TypingSpeed 40ms +Set PlaybackSpeed 1.0 +Set Framerate 30 + +# --- Hidden setup: PATH + connection env + start workload + clear --- +Hide +Type "export PATH=/Users/nik/github/rpg/target/release:$PATH" +Enter +Sleep 200ms +Type "export PGHOST=localhost PGPORT=55433 PGUSER=postgres PGDATABASE=postgres" +Enter +Sleep 200ms +Type "bash /Users/nik/github/rpg/demos/top-workload.sh &" +Enter +Sleep 4s +Type "clear" +Enter +Sleep 500ms +Show + +# --- Connect with global --show-keys (turns on the on-screen +# --- "key pressed" overlay for every TUI command in this rpg session). --- +Type "rpg --show-keys" +Sleep 1500ms +Enter +Sleep 3s + +# --- Launch /top with default 1 s refresh --- +# Visible features (all default): +# • two-line header: db/user/pg/recovery/uptime/clock UTC +# + active/idle-in-tx/wait/total/max + longest-tx, +# longest-q + cumulative deadlocks + temp_files + ● +# • activity table — pid, user, db, state, wait, qtime, xtime, locks, query +# • wait coloring follows the pg_ash palette (CPU green, IO vivid blue, +# Lock red, LWLock pink, IPC cyan, Client yellow, Timeout orange, +# IdleTx light yellow, …) and uses ':' as the Type:Event delimiter +# • qtime warn (>1 s yellow) / crit (>30 s red) +# • sticky table header — rows scroll, header stays +Type "/top" +Sleep 1500ms +Enter +Sleep 6s + +# --- Cursor navigation: scroll past the visible window so the sticky +# --- header is visible while body content scrolls underneath. The corner +# --- key overlay (⌨) labels each press as it happens. --- +Down +Sleep 600ms +Down +Sleep 600ms +Down +Sleep 600ms +Down +Sleep 600ms +Down +Sleep 600ms +Down +Sleep 800ms +PageDown +Sleep 1800ms +PageDown +Sleep 2s +PageUp +Sleep 1800ms + +# --- Cycle sort columns with → / ← (or ); reverse with r. The active +# --- column header carries ▼ (desc) / ▲ (asc). Arrow follows each press. --- +Right +Sleep 2500ms +Right +Sleep 2500ms +Right +Sleep 2500ms +Type "r" +Sleep 2500ms +Left +Sleep 2500ms +Left +Sleep 2500ms + +# --- Toggle extended columns: app, client, backend appear --- +Type "e" +Sleep 3500ms + +# --- Collapse back to default columns --- +Type "e" +Sleep 2s + +# --- Set refresh delay to 0.5 s via the interactive prompt --- +Type "s" +Sleep 1500ms +Backspace +Backspace +Backspace +Backspace +Type "0.5" +Sleep 1500ms +Enter +Sleep 4s + +# --- Pick a row and ask to cancel it. The footer flips to a CANCEL +# --- confirmation showing pid / user@db / state / qtime / query summary; +# --- y fires, n cancels. We press n to keep the demo idempotent. --- +Down +Sleep 800ms +Down +Sleep 800ms +Type "k" +Sleep 3500ms +Type "n" +Sleep 1500ms + +# --- Same flow with Shift-K (TERMINATE — heavier weapon, separate verb +# --- in the footer). Press n again to dismiss without firing. --- +Type "K" +Sleep 3500ms +Type "n" +Sleep 1500ms + +# --- Quit /top with q --- +Type "q" +Sleep 1500ms + +# --- Headless `--once` mode for scripting / CI snapshots --- +Type "/top --once" +Sleep 1500ms +Enter +Sleep 5s + +# --- Clean exit from rpg --- +Type "\q" +Sleep 1500ms +Enter +Sleep 1s + +# --- Hidden cleanup: kill workload group --- +Hide +Type "kill %1 2>/dev/null; wait 2>/dev/null; pkill -f top-workload 2>/dev/null; true" +Enter +Sleep 800ms diff --git a/demos/top-workload.sh b/demos/top-workload.sh new file mode 100755 index 00000000..9feaa118 --- /dev/null +++ b/demos/top-workload.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +IFS=$'\n\t' + +# Background workload for /top demo recording. +# Spawns a steady stream of mixed backends so the activity table has +# something interesting to display: long-running queries, idle-in-tx, +# idle-with-Client-wait, lock waits, IO scans, short bursts. Self-contained: +# uses generate_series + pg_sleep + advisory locks, no schema dependencies. +# +# Usage: +# bash demos/top-workload.sh # defaults +# bash demos/top-workload.sh "host=… port=… user=… dbname=…" +# +# Per CLAUDE.md "PostgreSQL command execution" rule, every psql call uses +# PAGER=cat + --no-psqlrc + long options. Short -c is avoided. + +readonly CONNINFO="${1:-host=localhost port=55433 user=postgres dbname=postgres}" + +cleanup() { + pkill -P "$$" 2>/dev/null || true +} +trap cleanup EXIT + +# Wrapper that applies the project's psql conventions to every call. +run_psql() { + env PAGER=cat psql \ + --no-psqlrc \ + --dbname="${CONNINFO}" \ + "$@" +} + +run_psql_stdin() { + env PAGER=cat psql \ + --no-psqlrc \ + --dbname="${CONNINFO}" +} + +# --------------------------------------------------------------------------- +# Persistent backends — these stay connected for ~30 s so the demo always +# captures them. We feed psql via stdin (not --command) so the connection +# stays alive between commands; the wait_event will be Client:ClientRead +# while we sleep on the producer side. +# --------------------------------------------------------------------------- + +# Long-lived idle backend: connects, runs a trivial select, then sits +# idle for 30 s with state='idle' and wait_event='ClientRead'. +( { echo "select 1;"; sleep 30; echo "\\q"; } \ + | run_psql_stdin >/dev/null 2>&1 ) & + +# Long-lived idle-in-transaction backend: opens a tx, runs select, +# then sits with state='idle in transaction' until rollback. +( { echo "begin; select 1;"; sleep 30; echo "rollback; \\q"; } \ + | run_psql_stdin >/dev/null 2>&1 ) & + +# A second idle-in-tx backend with a real lock so the locks_held column +# shows interesting numbers. +( { + echo "begin;" + echo "select pg_advisory_xact_lock(7);" + echo "create temp table demo_t(x int);" + echo "insert into demo_t select generate_series(1,1000);" + sleep 30 + echo "rollback; \\q" + } | run_psql_stdin >/dev/null 2>&1 ) & + +# --------------------------------------------------------------------------- +# Active-query churn: spawns short-lived workers in a loop so the +# activity table is constantly rotating. +# --------------------------------------------------------------------------- +main() { + while true; do + # Long active query — hits qtime warn (>1s) and crit (>30s) thresholds. + run_psql --command="select pg_sleep(10)" >/dev/null 2>&1 & + + # Medium active query — 3-5 s lifetime. + run_psql \ + --command="select count(*) from generate_series(1, 5000000)" \ + >/dev/null 2>&1 & + + # Short bursts (Client wait events). + for _ in $(seq 1 4); do + run_psql --command="select 1" >/dev/null 2>&1 & + done + + # Short idle-in-transaction (commits cleanly). + run_psql \ + --command="begin; select pg_sleep(5); commit" \ + >/dev/null 2>&1 & + + # Lock contention via advisory locks: two backends compete on lock 42. + run_psql --command=" + select pg_advisory_lock(42); + select pg_sleep(2); + select pg_advisory_unlock(42) + " >/dev/null 2>&1 & + run_psql --command=" + select pg_advisory_lock(42); + select pg_sleep(0.3); + select pg_advisory_unlock(42) + " >/dev/null 2>&1 & + + sleep 1.2 + done +} + +main "$@" diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index d740b07c..bad9e497 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -46,6 +46,7 @@ These match psql behaviour and must not be changed: |---|---| | `/dba [subcommand]` | database diagnostics (activity, locks, bloat, etc.) | | `/ash [args]` | active session history (requires pg_ash extension) | +| `/top [flags]` | live TUI monitor — top-like view of `pg_stat_activity`. Flags: `--once` (one snapshot), `--batch` / `-b` (continuous text log), `--refresh ` / `-s ` (interval, 0.1–60 s), `--ts-format ` (strftime prefix used by `--batch`, default `%Y-%m-%dT%H:%M:%SZ`), `--show-keys` (overlay each keystroke as a billboard — recording aid, can also be set globally via `rpg --show-keys`). Interactive keys: `↑/↓` move cursor · `PgUp/PgDn` page · `Home/End` first/last · `Space` immediate refresh · `←/→` (or `<`/`>`) cycle sort column · `r` reverse direction · `e` extended columns · `s` set refresh delay · `k`/`K` cancel/terminate selected backend (`y/N` confirm) · `q`/`Esc` quit. Run interactive `/top` and parallel `/top --batch > /tmp/top.log` from two rpg sessions for live-view + log-to-file. | ### Input/execution modes diff --git a/src/main.rs b/src/main.rs index 71064a0a..375e876c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -62,6 +62,8 @@ mod setup; mod ssh_tunnel; mod statusline; mod term; +#[cfg(not(target_arch = "wasm32"))] +mod top; mod update; mod vars; // WASM browser support: WebSocket connector and wasm-bindgen entry point. @@ -342,6 +344,13 @@ struct Cli { #[arg(long)] no_highlight: bool, + /// Show a temporary on-screen badge for each key press. Off by + /// default. Useful when recording animated GIFs / asciinema demos + /// of any rpg TUI command — currently honored by `/top`; later + /// sprints will extend it to `/ash` and the bare REPL prompt. + #[arg(long = "show-keys")] + show_keys: bool, + /// Enable text-to-SQL mode: translate natural language to SQL. #[arg(long)] text2sql: bool, @@ -598,6 +607,7 @@ fn build_settings( quiet: cli.quiet, debug: cli.debug, no_highlight, + show_keys: cli.show_keys, pager_enabled, pager_command, pager_min_lines, diff --git a/src/repl/ai_commands.rs b/src/repl/ai_commands.rs index 237828b9..73a3272b 100644 --- a/src/repl/ai_commands.rs +++ b/src/repl/ai_commands.rs @@ -425,6 +425,7 @@ pub(super) async fn dispatch_ai_command( || input.starts_with("/np ") || input.starts_with("/n ") || input.starts_with("/ash") + || input.starts_with("/top") || input == "/rpg"; if !is_budget_exempt && check_token_budget(settings) { return None; @@ -529,6 +530,33 @@ pub(super) async fn dispatch_ai_command( #[cfg(target_arch = "wasm32")] rpg_eprintln!("/ash requires ratatui which is not available on wasm32-unknown-unknown"); + // /top — live TUI Postgres monitor (S1: activity view). + } else if input == "/top" || input.starts_with("/top ") { + #[cfg(not(target_arch = "wasm32"))] + { + use std::io::IsTerminal; + let raw_args = input.strip_prefix("/top").map_or("", str::trim); + let mut top_args = crate::top::TopArgs::parse(raw_args); + // The global `--show-keys` flag wins over the per-/top arg — + // either source can enable the recording overlay. + if settings.show_keys { + top_args.show_keys = true; + } + let headless = top_args.once || top_args.batch; + if !headless && !std::io::stdout().is_terminal() { + rpg_eprintln!( + "/top requires an interactive terminal (use `--once` for a snapshot, \ + `--batch` for continuous logging)" + ); + return None; + } + if let Err(e) = crate::top::run_top(client, settings, top_args).await { + rpg_eprintln!("/top: {e}"); + } + } + #[cfg(target_arch = "wasm32")] + rpg_eprintln!("/top requires ratatui which is not available on wasm32-unknown-unknown"); + // /sql — switch to SQL input mode. } else if input == "/sql" { let result = MetaResult::SetInputMode(InputMode::Sql); diff --git a/src/repl/mod.rs b/src/repl/mod.rs index b0afaf26..3736acb1 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -1285,6 +1285,13 @@ pub struct ReplSettings { /// /// Set by `--no-highlight` CLI flag or `\set HIGHLIGHT off`. pub no_highlight: bool, + /// Show a temporary on-screen badge for every key press in TUI + /// commands. Off by default — turned on globally by the + /// `--show-keys` CLI flag. The intent is to record animated GIFs + /// / asciinema demos of arbitrary rpg commands with the keystroke + /// labelled on screen. Currently honored by `/top`; later sprints + /// extend it to `/ash` and the bare REPL prompt. + pub show_keys: bool, /// Disable schema-aware tab completion in the interactive REPL. /// /// Toggled by the F2 key or `\f2` metacommand. @@ -1674,6 +1681,7 @@ impl Default for ReplSettings { pending_pset_opts: Vec::new(), named_statements: HashSet::new(), no_highlight: false, + show_keys: false, no_completion: false, // Pager is enabled by default in interactive mode. pager_enabled: true, @@ -1795,11 +1803,24 @@ pub fn startup_file() -> Option { // Non-interactive (piped / -c / -f) execution // --------------------------------------------------------------------------- +/// Returns `true` when the input string starts (after any leading +/// whitespace) with a `/` — the rpg-extension command namespace +/// (`/top`, `/dba`, `/ash`, `/ask`, …). See `docs/COMMANDS.md` for the +/// `\` vs `/` namespace policy. +/// +/// Used by [`exec_command`] to route `-c` / stdin invocations through +/// `dispatch_ai_command` instead of letting them fall through to SQL +/// execution (where they would raise a `syntax error at or near "/"`). +pub fn is_slash_extension_command(sql: &str) -> bool { + sql.trim_start().starts_with('/') +} + /// Execute a single SQL command string (from `-c`) and exit. /// /// Mirrors psql behaviour: if the string starts with a backslash it is /// dispatched as a meta-command (using only the first line as the command, -/// matching psql's `-c` meta-command handling). Otherwise it is sent as SQL. +/// matching psql's `-c` meta-command handling). If it starts with a slash +/// it is dispatched as an rpg-extension command. Otherwise it is sent as SQL. pub async fn exec_command( client: &Client, sql: &str, @@ -1850,6 +1871,22 @@ pub async fn exec_command( } return 0; } + if is_slash_extension_command(sql) { + // Slash (rpg-extension) command in -c mode. Mirrors the interactive + // REPL's branch around `dispatch_ai_command`; without this `rpg + // --command "/top --once"` would fall through to SQL execution and + // raise a syntax error. + // + // KNOWN LIMITATION: this path always returns 0, even when + // dispatch_ai_command prints "Unknown command:" to stderr or when a + // /-command's internal handler errors. Distinguishing matched vs + // unmatched needs a richer return type from dispatch_ai_command and + // is tracked as a follow-up (see PR #837 review B1). + let mut tx = TxState::default(); + let interpolated = settings.vars.interpolate(sql.trim()); + let _ = dispatch_ai_command(&interpolated, client, params, settings, &mut tx).await; + return 0; + } let mut tx = TxState::default(); i32::from(!execute_query(client, sql, settings, &mut tx).await) } @@ -3428,6 +3465,7 @@ AI commands: /compact [focus] compact conversation context (optional focus topic) /budget show token usage and remaining budget /ash show live Active Session History (poll pg_stat_activity; uses pg_ash if installed) + /top live TUI Postgres monitor (top-like; --once / --batch for non-interactive) DBA diagnostics: /dba show available diagnostics @@ -10877,6 +10915,26 @@ mod tests { // -- quit/exit in non-interactive (exec_lines / piped) path --------------- + // -- /-extension command predicate (used by exec_command's -c routing) --- + + #[test] + fn slash_predicate_recognises_rpg_extensions() { + assert!(is_slash_extension_command("/top")); + assert!(is_slash_extension_command("/top --once")); + assert!(is_slash_extension_command("/dba activity")); + assert!(is_slash_extension_command(" /ash")); + assert!(is_slash_extension_command("\t/ask hello")); + } + + #[test] + fn slash_predicate_rejects_sql_and_backslash() { + assert!(!is_slash_extension_command("")); + assert!(!is_slash_extension_command("select 1")); + assert!(!is_slash_extension_command("\\d")); + // A `/` mid-statement is SQL division, not a command. + assert!(!is_slash_extension_command("select 10/2")); + } + /// Simulate `exec_lines` processing a single "quit" line with an empty /// buffer. The loop must break immediately — no SQL dispatched. #[test] diff --git a/src/top/keyfont.rs b/src/top/keyfont.rs new file mode 100644 index 00000000..ff2e762e --- /dev/null +++ b/src/top/keyfont.rs @@ -0,0 +1,176 @@ +//! Compact 3×5 block-letter font for the key-press overlay. +//! +//! Each known glyph renders as a 3-row × 5-col `[&'static str; 3]` +//! built from `█` filled cells and `' '` empty cells. Uppercase only — +//! pressing `k` or `K` both render the capital form. The width and +//! height are the smallest combination that keeps every letter legible +//! and lets multi-character labels (`PGDN`, `ESC`, `HOME`, …) render at +//! the same scale as single-character labels. +//! +//! When a label has no hand-drawn glyph for a character, the renderer +//! falls back to centred plain text inside the same yellow billboard. + +/// Width and height of every glyph in the font. `GLYPH_W` is part of +/// the public surface so future callers can compute label widths +/// without reading them off `render_label`'s output; `GLYPH_H` is used +/// by both the test suite and `render_label`. +#[allow(dead_code)] +pub const GLYPH_W: usize = 5; +pub const GLYPH_H: usize = 3; + +/// Number of blank cells between two glyphs in a multi-character label. +pub const GLYPH_GAP: usize = 1; + +type Glyph = [&'static str; GLYPH_H]; + +/// Look up the 3×5 glyph for a single character, if known. Returns +/// `None` for chars without a hand-drawn glyph; the caller falls back +/// to plain-text rendering. +/// +/// Some glyphs are visually identical at this resolution (e.g. `I` and +/// `Z` both reduce to a thin vertical bar in 3×5 cells). Clippy's +/// `match_same_arms` would have us collapse them into one arm, but +/// keeping them distinct keeps the table readable for font tweaks. +#[allow(clippy::match_same_arms)] +pub fn glyph(c: char) -> Option { + let upper = c.to_ascii_uppercase(); + Some(match upper { + 'A' => [" ███ ", "█████", "█ █"], + 'B' => ["████ ", "█████", "████ "], + 'C' => [" ████", "█ ", " ████"], + 'D' => ["███ ", "█ █ ", "███ "], + 'E' => ["█████", "███ ", "█████"], + 'F' => ["█████", "███ ", "█ "], + 'G' => [" ████", "█ ██", " ████"], + 'H' => ["█ █", "█████", "█ █"], + 'I' => [" █ ", " █ ", " █ "], + 'J' => ["█████", " █", " ███ "], + 'K' => ["█ █ ", "███ ", "█ █ "], + 'L' => ["█ ", "█ ", "█████"], + 'M' => ["█▄ ▄█", "█ █ █", "█ █"], + 'N' => ["██ █", "█ █ █", "█ ██"], + 'O' => [" ███ ", "█ █", " ███ "], + 'P' => ["████ ", "████ ", "█ "], + 'Q' => [" ███ ", "█ █", " ██ █"], + 'R' => ["████ ", "███ ", "█ █ "], + 'S' => [" ████", " ███ ", "████ "], + 'T' => ["█████", " █ ", " █ "], + 'U' => ["█ █", "█ █", " ███ "], + 'V' => ["█ █", " █ █ ", " █ "], + 'W' => ["█ █", "█ █ █", " █ █ "], + 'X' => ["█ █", " █ ", "█ █"], + 'Y' => ["█ █", " █ ", " █ "], + 'Z' => ["█████", " █ ", "█████"], + '0' => [" ███ ", "█ █ █", " ███ "], + '1' => [" █ ", " █ ", "█████"], + '2' => ["████ ", " ██ ", "█████"], + '3' => ["████ ", " ██ ", "████ "], + '4' => ["█ █", "█████", " █"], + '5' => ["█████", "████ ", "████ "], + '6' => [" ████", "████ ", " ███ "], + '7' => ["█████", " █ ", " █ "], + '8' => [" ███ ", " ███ ", " ███ "], + '9' => [" ███ ", " ████", "████ "], + // Sort cyclers + miscellaneous one-glyph ASCII. + '<' | ',' => [" █", "███ ", " █"], + '>' | '.' => ["█ ", " ███", "█ "], + '/' => [" █", " █ ", "█ "], + '-' => [" ", "█████", " "], + '+' => [" █ ", "█████", " █ "], + '=' => ["█████", " ", "█████"], + '?' => ["████ ", " ██ ", " █ "], + '!' => [" █ ", " █ ", " █ "], + ':' => [" █ ", " ", " █ "], + ' ' => [" ", " ", " "], + // Filled directional triangles — each direction gets a + // distinct shape. Up/down are pointing along the rows; left + // and right are pointing along the columns. + '▲' | '↑' => [" █ ", " ███ ", "█████"], + '▼' | '↓' => ["█████", " ███ ", " █ "], + '◀' | '←' => [" ███", "█████", " ███"], + '▶' | '→' => ["███ ", "█████", "███ "], + _ => return None, + }) +} + +/// Render a label as a multi-glyph block, joining each character's +/// glyph with `GLYPH_GAP` blank columns. Returns `None` when *any* +/// character in the label has no hand-drawn glyph; the caller falls +/// back to centred plain text. +pub fn render_label(label: &str) -> Option> { + let chars: Vec = label.chars().collect(); + let mut glyphs = Vec::with_capacity(chars.len()); + for c in &chars { + glyphs.push(glyph(*c)?); + } + + let gap: String = " ".repeat(GLYPH_GAP); + let mut rows = Vec::with_capacity(GLYPH_H); + for r in 0..GLYPH_H { + let mut line = String::new(); + for (i, g) in glyphs.iter().enumerate() { + if i > 0 { + line.push_str(&gap); + } + line.push_str(g[r]); + } + rows.push(line); + } + Some(rows) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_glyph_is_3x5() { + for c in ('A'..='Z') + .chain('0'..='9') + .chain(['<', '>', '/', '-', '+', '=', '?', '!', ':', ' ']) + .chain(['▲', '▼', '◀', '▶']) + { + let g = glyph(c).unwrap_or_else(|| panic!("missing glyph for {c:?}")); + for (i, row) in g.iter().enumerate() { + assert_eq!( + row.chars().count(), + GLYPH_W, + "{c:?} row {i} has wrong width: {row:?}", + ); + } + assert_eq!(g.len(), GLYPH_H); + } + } + + #[test] + fn lowercase_letters_map_to_uppercase() { + assert_eq!(glyph('e'), glyph('E')); + assert_eq!(glyph('q'), glyph('Q')); + assert_eq!(glyph('z'), glyph('Z')); + } + + #[test] + fn unknown_chars_return_none() { + assert!(glyph('@').is_none()); + assert!(glyph('λ').is_none()); + } + + #[test] + fn render_label_concatenates_glyphs_with_gap() { + let rows = render_label("AB").expect("known label"); + assert_eq!(rows.len(), GLYPH_H); + // Each row: 5 glyph cols + 1 gap col + 5 glyph cols = 11. + for row in &rows { + assert_eq!(row.chars().count(), GLYPH_W * 2 + GLYPH_GAP); + } + // Should contain the top row of A followed by gap then top row of B. + assert!(rows[0].starts_with(" ███ ")); + assert!(rows[0].ends_with("████ ")); + } + + #[test] + fn render_label_returns_none_for_unknown_char() { + // λ has no glyph → entire label fails (caller falls back to text). + assert!(render_label("Aλ").is_none()); + } +} diff --git a/src/top/mod.rs b/src/top/mod.rs new file mode 100644 index 00000000..4229e526 --- /dev/null +++ b/src/top/mod.rs @@ -0,0 +1,461 @@ +//! `/top` — live TUI Postgres monitor (S1: scaffold + activity view). +//! +//! Mirrors the `/ash` module layout (`mod.rs`, `state.rs`, `sampler.rs`, +//! `renderer.rs`) so reviewers can pattern-match between the two. +//! +//! ```text +//! mod.rs — public entry point: run_top(); event/render loop. +//! state.rs — App, View, Snapshot, ActivityRow. +//! sampler.rs — Postgres-side data: server summary + pg_stat_activity. +//! sql.rs — version-gated SQL strings. +//! renderer.rs — ratatui frame: header / tabs / body / footer. +//! views/ — one file per view; S1 ships only `activity`. +//! theme.rs — palette + truecolor detection. +//! ``` +//! +//! S1 scope is intentionally small: a single Activity view with live +//! refresh and `q`/`Esc`/`Ctrl-C` exit. View switching, drill-down, kill, +//! sparklines, and snapshot export land in S2–S7 per +//! `.samo/spec/top/SPEC.md`. + +pub mod keyfont; +pub mod renderer; +pub mod sampler; +pub mod sql; +pub mod state; +pub mod theme; +pub mod views; + +use std::io::{self, IsTerminal, Write}; +use std::time::{Duration, Instant}; + +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event}, + execute, + terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; +use tokio_postgres::Client; + +use sampler::TickResult; +use state::{AdminMessage, AdminMessageLevel, App, KillRequest}; +use theme::Theme; + +use crate::repl::ReplSettings; + +/// Per-query observer-effect timeout. Mirrors `/ash`'s default sample +/// timeout. +const SAMPLE_TIMEOUT_MS: u64 = 5_000; + +// --------------------------------------------------------------------------- +// TerminalGuard — RAII alt-screen + raw-mode wrapper. +// +// Same pattern as `src/ash/mod.rs`. Drop runs even on panic so the user's +// terminal is always restored. +// --------------------------------------------------------------------------- + +struct TerminalGuard; + +impl TerminalGuard { + fn new() -> io::Result { + terminal::enable_raw_mode()?; + execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?; + Ok(Self) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = execute!(io::stdout(), DisableMouseCapture, LeaveAlternateScreen); + let _ = terminal::disable_raw_mode(); + let _ = io::stderr().write_all(b"\x1b[r"); + let _ = io::stderr().flush(); + } +} + +/// Parsed `/top` invocation flags. +#[derive(Debug, Default, Clone)] +pub struct TopArgs { + /// Headless mode: take one snapshot, dump a plain-text rendering to + /// stdout, and exit. Skips the alt-screen + raw-mode setup so it is + /// safe to run from `rpg --command "/top --once"` and from CI. + pub once: bool, + /// Continuous batch logging: print a timestamped snapshot every + /// `refresh_secs` until interrupted. Designed for `tmux pipe-pane` / + /// shell redirection. Skips alt-screen + raw-mode entirely. + pub batch: bool, + /// Sampler refresh interval in seconds. `None` keeps + /// [`state::DEFAULT_REFRESH_SECS`]. CLI: `--refresh ` or `-s `, + /// matching `pg_top` / `pgcenter`. + pub refresh_secs: Option, + /// Strftime-style format used for the timestamp prefix in `--batch` + /// mode. `None` uses [`state::DEFAULT_TS_FORMAT`] (ISO 8601 UTC). + pub ts_format: Option, + /// Show a billboard-style overlay flashing the most recent + /// keystroke. Off by default — it's a recording aid, not something + /// an interactive user wants every keypress flashing on screen. + /// The demo tape passes `--show-keys` so the gif explains itself. + pub show_keys: bool, +} + +impl TopArgs { + /// Parse the argument string passed after `/top` in the REPL (or via + /// `rpg --command`). Unknown tokens are silently ignored. + pub fn parse(args: &str) -> Self { + use state::{MAX_REFRESH_SECS, MIN_REFRESH_SECS}; + + let mut out = Self::default(); + let mut iter = args.split_whitespace().peekable(); + while let Some(tok) = iter.next() { + match tok { + "--once" => out.once = true, + "--batch" | "-b" => out.batch = true, + "--refresh" | "-s" => { + if let Some(val) = iter.next() { + if let Ok(n) = val.parse::() { + if (MIN_REFRESH_SECS..=MAX_REFRESH_SECS).contains(&n) { + out.refresh_secs = Some(n); + } + } + } + } + "--ts-format" => { + if let Some(val) = iter.next() { + out.ts_format = Some(val.to_owned()); + } + } + "--show-keys" => out.show_keys = true, + _ => {} + } + } + out + } +} + +/// Public entry point. Blocks until the user exits with `q`, `Esc`, or +/// `Ctrl-C`. The `_settings` parameter is currently unused; later sprints +/// read theme + refresh interval from it. +pub async fn run_top( + client: &Client, + _settings: &ReplSettings, + args: TopArgs, +) -> anyhow::Result<()> { + if args.once { + return run_once(client).await; + } + if args.batch { + return run_batch(client, &args).await; + } + + if !io::stdout().is_terminal() { + anyhow::bail!( + "/top requires an interactive terminal (use `--once` for a snapshot, \ + `--batch` for continuous logging)" + ); + } + + let mut app = App::new(); + if let Some(secs) = args.refresh_secs { + app.refresh_secs = secs; + } + app.show_keys = args.show_keys; + let theme = Theme::default_theme(); + + let _guard = TerminalGuard::new()?; + let backend = CrosstermBackend::new(io::stdout()); + let mut terminal = Terminal::new(backend)?; + + 'outer: loop { + // 0. Execute any pending kill before the next tick so its outcome + // is visible immediately in the refreshed snapshot. + if let Some(req) = app.kill_pending.take() { + let msg = run_kill(client, &req).await; + app.admin_message = Some(msg); + } + + // 1. Sampler tick. + app.force_refresh = false; + match sampler::tick(client, SAMPLE_TIMEOUT_MS).await { + Ok(TickResult::Ok(snap)) => app.set_snapshot(*snap), + Ok(TickResult::Missed) => app.note_stale(), + Err(e) => app.note_error(format!("{e}")), + } + + // 2. Draw frame. + terminal.draw(|f| renderer::draw(f, &app, &theme))?; + + // 3. Drain key/mouse events until the next refresh deadline. + // The deadline is recomputed each tick so an interactive change to + // `app.refresh_secs` (via the `s` prompt) takes effect immediately. + // `Space` short-circuits the wait by setting `app.force_refresh` — + // matches `top`'s "redisplay now" convention. + let deadline = Instant::now() + Duration::from_secs_f64(app.refresh_secs); + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + if !event::poll(remaining)? { + break; + } + match event::read()? { + Event::Key(key) => { + let page = page_size(&terminal); + if app.handle_key(key, page) { + break 'outer; + } + // Redraw after every state-changing key for snappy UX. + terminal.draw(|f| renderer::draw(f, &app, &theme))?; + if app.force_refresh { + break; + } + } + Event::Resize(_, _) => { + terminal.draw(|f| renderer::draw(f, &app, &theme))?; + } + _ => {} + } + } + } + + Ok(()) +} + +fn page_size(terminal: &Terminal) -> usize { + let area = terminal.size().unwrap_or_default(); + // Leave room for header (3) + tabs (1) + table header (1) + footer (1) + + // borders (2) ≈ 8 rows of chrome. + let body = u32::from(area.height).saturating_sub(8); + body.max(1) as usize +} + +/// Off-screen buffer dimensions used by `--once` and `--batch`. Wide +/// enough to render the default-mode columns + a trimmed query string. +const HEADLESS_WIDTH: u16 = 130; +const HEADLESS_HEIGHT: u16 = 30; + +/// Headless `--once` mode: take one snapshot, render into a fixed-size +/// off-screen buffer, write the cell contents to stdout as plain text, and +/// exit. Used for scripting, CI smoke tests, and PR evidence capture. +async fn run_once(client: &Client) -> anyhow::Result<()> { + use ratatui::backend::TestBackend; + let mut app = App::new(); + sample_into_app(client, &mut app).await; + let mut terminal = Terminal::new(TestBackend::new(HEADLESS_WIDTH, HEADLESS_HEIGHT))?; + write_text_frame(&app, &mut terminal, io::stdout().lock())?; + Ok(()) +} + +/// Continuous `--batch` mode: print a timestamped snapshot every +/// `refresh_secs` to stdout until SIGINT. Designed for tmux `pipe-pane`, +/// shell redirection (`> top.log`), and other long-running log capture +/// workflows. Skips alt-screen + raw-mode entirely so the output is plain +/// text. Each snapshot is preceded by a separator line containing the +/// strftime-formatted timestamp, e.g. +/// +/// ```text +/// ===== 2026-05-08T12:13:14Z ===== +/// ┌ rpg /top ────────… +/// … +/// ``` +async fn run_batch(client: &Client, args: &TopArgs) -> anyhow::Result<()> { + use std::time::Duration; + + use state::{format_strftime, DEFAULT_REFRESH_SECS, DEFAULT_TS_FORMAT}; + + let interval = Duration::from_secs_f64(args.refresh_secs.unwrap_or(DEFAULT_REFRESH_SECS)); + let fmt = args + .ts_format + .as_deref() + .unwrap_or(DEFAULT_TS_FORMAT) + .to_owned(); + + let mut interrupt = std::pin::pin!(tokio::signal::ctrl_c()); + + // Build the headless terminal once and reuse across ticks. Each + // TestBackend::new allocates two double-buffered Cell grids + // (HEADLESS_WIDTH × HEADLESS_HEIGHT each); reconstructing them on + // every tick wastes ~8 kB of heap per second for no reason. + let mut terminal = { + use ratatui::backend::TestBackend; + Terminal::new(TestBackend::new(HEADLESS_WIDTH, HEADLESS_HEIGHT))? + }; + + loop { + let mut app = App::new(); + sample_into_app(client, &mut app).await; + let ts_secs = app.snapshot.as_ref().map_or(0, |s| s.ts); + let ts = format_strftime(&fmt, ts_secs); + let mut out = io::stdout().lock(); + writeln!(out, "===== {ts} =====")?; + write_text_frame(&app, &mut terminal, &mut out)?; + writeln!(out)?; + out.flush()?; + drop(out); + + tokio::select! { + biased; + _ = &mut interrupt => break, + () = tokio::time::sleep(interval) => {} + } + } + Ok(()) +} + +/// Execute an approved `pg_cancel_backend` / `pg_terminate_backend` and +/// return a footer-friendly result message. A short `statement_timeout` +/// is applied for the call itself so a hostile lock can't wedge the TUI. +async fn run_kill(client: &Client, req: &KillRequest) -> AdminMessage { + use std::time::Duration; + + let sql = format!("select {}($1)", req.pg_function_for_request()); + // Best-effort guard. Failures here are non-fatal: the kill SQL still + // runs at the regular timeout. + let _ = client.execute("set statement_timeout = '5s'", &[]).await; + let result = client.query_one(&sql, &[&req.pid]).await; + let _ = client.execute("set statement_timeout = 0", &[]).await; + + let ttl = Instant::now() + Duration::from_secs(5); + match result { + Ok(row) => { + let ok: bool = row + .try_get::<_, Option>(0) + .ok() + .flatten() + .unwrap_or(false); + if ok { + AdminMessage { + text: format!("{} pid {}: ok", req.mode.verb_upper(), req.pid), + level: AdminMessageLevel::Ok, + expires_at: ttl, + } + } else { + AdminMessage { + text: format!( + "{} pid {}: returned false (pid not found or not signal-able)", + req.mode.verb_upper(), + req.pid + ), + level: AdminMessageLevel::Err, + expires_at: ttl, + } + } + } + Err(e) => AdminMessage { + text: format!("{} pid {}: {e}", req.mode.verb_upper(), req.pid), + level: AdminMessageLevel::Err, + expires_at: ttl, + }, + } +} + +async fn sample_into_app(client: &Client, app: &mut App) { + match sampler::tick(client, SAMPLE_TIMEOUT_MS).await { + Ok(TickResult::Ok(snap)) => app.set_snapshot(*snap), + Ok(TickResult::Missed) => app.note_stale(), + Err(e) => app.note_error(format!("{e}")), + } +} + +fn write_text_frame( + app: &App, + terminal: &mut Terminal, + mut out: W, +) -> anyhow::Result<()> { + let theme = Theme::for_once(); + terminal.draw(|f| renderer::draw(f, app, &theme))?; + let buf = terminal.backend().buffer(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + out.write_all(buf[(x, y)].symbol().as_bytes())?; + } + out.write_all(b"\n")?; + } + out.flush()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::TopArgs; + + #[test] + fn parse_no_args_keeps_defaults() { + let a = TopArgs::parse(""); + assert!(!a.once); + } + + #[test] + fn parse_once_flag_sets_once() { + assert!(TopArgs::parse("--once").once); + assert!(TopArgs::parse(" --once ").once); + } + + #[test] + fn parse_unknown_flags_are_ignored() { + let a = TopArgs::parse("--banana --once --carrot"); + assert!(a.once); + } + + #[test] + fn parse_refresh_flag_accepts_values_in_range() { + for (input, expected) in [ + ("--refresh 0.5", Some(0.5)), + ("-s 2", Some(2.0)), + ("--refresh 0.1", Some(0.1)), + ("--refresh 60", Some(60.0)), + ] { + let a = TopArgs::parse(input); + assert!( + matches!(a.refresh_secs, x if (x.unwrap_or(-1.0) - expected.unwrap()).abs() < f64::EPSILON), + "{input} → {:?}", + a.refresh_secs + ); + } + } + + #[test] + fn parse_refresh_flag_rejects_out_of_range() { + for input in [ + "--refresh 0.05", + "--refresh 999", + "--refresh xx", + "--refresh -1", + ] { + let a = TopArgs::parse(input); + assert!( + a.refresh_secs.is_none(), + "{input} accepted: {:?}", + a.refresh_secs + ); + } + } + + #[test] + fn parse_refresh_combines_with_once() { + let a = TopArgs::parse("--once --refresh 0.5"); + assert!(a.once); + assert_eq!(a.refresh_secs, Some(0.5)); + } + + #[test] + fn parse_batch_flag_sets_batch() { + assert!(TopArgs::parse("--batch").batch); + assert!(TopArgs::parse("-b").batch); + } + + #[test] + fn parse_ts_format_round_trip() { + let a = TopArgs::parse("--batch --ts-format %Y-%m-%dT%H:%M:%SZ"); + assert!(a.batch); + assert_eq!(a.ts_format.as_deref(), Some("%Y-%m-%dT%H:%M:%SZ")); + } + + #[test] + fn parse_batch_with_refresh_and_ts_format() { + let a = TopArgs::parse("--batch --refresh 5 --ts-format %F-%T"); + assert!(a.batch); + assert_eq!(a.refresh_secs, Some(5.0)); + assert_eq!(a.ts_format.as_deref(), Some("%F-%T")); + } +} diff --git a/src/top/renderer.rs b/src/top/renderer.rs new file mode 100644 index 00000000..ae092232 --- /dev/null +++ b/src/top/renderer.rs @@ -0,0 +1,1186 @@ +//! Top-level draw routine for `/top`. Composes the header bar, tabs strip, +//! body (delegated to a per-view renderer), and footer hint line. + +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Tabs}; +use ratatui::Frame; + +use super::state::{ + AdminMessage, AdminMessageLevel, App, KillRequest, PromptKind, PromptState, Snapshot, View, +}; +use super::theme::Theme; +use super::views::activity::{self, format_secs, scrub_terminal_unsafe, truncate}; + +/// Min terminal size below which we render a "too small" stub instead of the +/// real UI. 24 rows × 80 cols matches the project-wide minimum used by +/// `/ash` (`src/ash/renderer.rs`). +const MIN_ROWS: u16 = 24; +const MIN_COLS: u16 = 80; + +/// Top-level entry. Pulls all rendering parameters off `App`. +pub fn draw(frame: &mut Frame, app: &App, theme: &Theme) { + let area = frame.area(); + if area.width < MIN_COLS || area.height < MIN_ROWS { + render_too_small(frame, area); + return; + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(5), // header (3 inner rows + 2 borders) + Constraint::Length(1), // tabs + Constraint::Min(3), // body (sticky table header) + Constraint::Length(1), // footer + ]) + .split(area); + + render_header(frame, chunks[0], app, theme); + render_tabs(frame, chunks[1], app, theme); + render_body(frame, chunks[2], app, theme); + render_footer(frame, chunks[3], app, theme); + // Key-press overlay rides on top of the body in the upper-right corner; + // drawn last so it always wins the cell. + render_key_overlay(frame, chunks[2], app, theme); +} + +// --------------------------------------------------------------------------- +// Header +// --------------------------------------------------------------------------- + +fn render_header(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme.border) + .title(Line::from(Span::styled(" rpg /top ", theme.title))); + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.height < 3 { + return; + } + let snap = app.snapshot.as_ref(); + // Three inner rows. Each row is split into a left and right paragraph + // so the most-actionable values (clock, connection LED) anchor to the + // right edge instead of leaving wide screens blank. + // + // row 0 db / user / pg / recovery / uptime ……………………… @ HH:MM:SS UTC + // row 1 ● connection counts (active / idle-in-tx / wait / total/max) + // row 2 ops: longest-tx, longest-q, deadlocks, temp-files, av busy/max, + // slots phy active/total, log active/total + let row0 = Rect::new(inner.x, inner.y, inner.width, 1); + let row1 = Rect::new(inner.x, inner.y + 1, inner.width, 1); + let row2 = Rect::new(inner.x, inner.y + 2, inner.width, 1); + + let row0_split = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(20), Constraint::Length(28)]) + .split(row0); + let row1_split = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(20), Constraint::Length(20)]) + .split(row1); + + frame.render_widget( + Paragraph::new(build_summary_line(snap, theme)), + row0_split[0], + ); + frame.render_widget( + Paragraph::new(build_clock_line(snap, theme)).alignment(ratatui::layout::Alignment::Right), + row0_split[1], + ); + frame.render_widget( + Paragraph::new(build_counts_line(snap, theme)), + row1_split[0], + ); + frame.render_widget( + Paragraph::new(build_status_line(snap, app, theme)) + .alignment(ratatui::layout::Alignment::Right), + row1_split[1], + ); + frame.render_widget(Paragraph::new(build_ops_line(snap, theme)), row2); +} + +fn build_ops_line<'a>(snap: Option<&'a Snapshot>, theme: &'a Theme) -> Line<'a> { + if let Some(s) = snap { + Line::from(vec![ + Span::styled("longest-tx ", theme.muted), + Span::raw(format_secs(if s.server.longest_xact_secs > 0.0 { + Some(s.server.longest_xact_secs) + } else { + None + })), + Span::styled(" longest-q ", theme.muted), + Span::raw(format_secs(if s.server.longest_active_query_secs > 0.0 { + Some(s.server.longest_active_query_secs) + } else { + None + })), + Span::styled(" deadlocks ", theme.muted), + Span::raw(s.server.deadlocks_total.to_string()), + Span::styled(" temp-files ", theme.muted), + Span::raw(s.server.temp_files_total.to_string()), + Span::styled(" av ", theme.muted), + Span::raw(format!( + "{}/{}", + s.server.autovacuum_busy, s.server.autovacuum_max + )), + Span::styled(" slots ", theme.muted), + Span::raw(format!( + "{}/{}p {}/{}l", + s.server.phys_slots_active, + s.server.phys_slots, + s.server.log_slots_active, + s.server.log_slots, + )), + ]) + } else { + Line::from(Span::styled( + "longest-tx – longest-q – deadlocks – temp-files – av – slots –", + theme.muted, + )) + } +} + +fn build_clock_line<'a>(snap: Option<&'a Snapshot>, theme: &'a Theme) -> Line<'a> { + if let Some(s) = snap { + Line::from(vec![ + Span::styled("@ ", theme.muted), + Span::raw(format_clock_utc(s.ts)), + Span::styled(" UTC ", theme.muted), + ]) + } else { + Line::from(Span::styled("connecting…", theme.muted)) + } +} + +fn build_status_line<'a>(snap: Option<&'a Snapshot>, app: &'a App, theme: &'a Theme) -> Line<'a> { + let dot = if app.stale_ticks == 0 && snap.is_some() { + Span::styled("●", theme.status_ok) + } else { + Span::styled("●", theme.status_stale) + }; + if app.stale_ticks > 0 && snap.is_some() { + Line::from(vec![ + Span::styled(format!("stale {} ", app.stale_ticks), theme.status_stale), + dot, + Span::raw(" "), + ]) + } else { + Line::from(vec![dot, Span::raw(" ")]) + } +} + +fn build_summary_line<'a>(snap: Option<&'a Snapshot>, theme: &'a Theme) -> Line<'a> { + if let Some(s) = snap { + let recovery = if s.server.in_recovery { + "standby" + } else { + "primary" + }; + Line::from(vec![ + Span::styled("db ", theme.muted), + Span::raw(scrub_terminal_unsafe(&s.server.db_name)), + Span::styled(" user ", theme.muted), + Span::raw(scrub_terminal_unsafe(&s.server.user)), + Span::styled(" pg ", theme.muted), + Span::raw(scrub_terminal_unsafe(&s.server.pg_version)), + Span::styled(" ", theme.muted), + Span::raw(recovery), + Span::styled(" uptime ", theme.muted), + Span::raw(format_uptime(s.server.uptime_secs)), + ]) + } else { + Line::from(Span::styled("connecting…", theme.muted)) + } +} + +fn build_counts_line<'a>(snap: Option<&'a Snapshot>, theme: &'a Theme) -> Line<'a> { + if let Some(s) = snap { + Line::from(vec![ + Span::styled("active ", theme.muted), + Span::raw(s.server.active.to_string()), + Span::styled(" idle-in-tx ", theme.muted), + Span::raw(s.server.idle_in_tx.to_string()), + Span::styled(" wait ", theme.muted), + Span::raw(s.server.waiting.to_string()), + Span::styled(" total ", theme.muted), + Span::raw(format!( + "{}/{}", + s.server.total_backends, s.server.max_connections + )), + ]) + } else { + Line::from(Span::styled( + "active – idle-in-tx – wait – total –", + theme.muted, + )) + } +} + +fn format_uptime(secs: i64) -> String { + let s = secs.max(0); + if s < 60 { + format!("{s}s") + } else if s < 3600 { + format!("{}m", s / 60) + } else if s < 86_400 { + format!("{}h{:02}m", s / 3600, (s % 3600) / 60) + } else { + format!("{}d{:02}h", s / 86_400, (s % 86_400) / 3600) + } +} + +/// Format a Unix timestamp as `HH:MM:SS` UTC clock time. +/// +/// Pre-epoch (negative) values render as `"—"` so the header doesn't +/// surface a misleading time. We avoid pulling in chrono — only modular +/// integer arithmetic is needed. +fn format_clock_utc(ts: i64) -> String { + if ts < 0 { + return "—".to_owned(); + } + let day_secs = ts.rem_euclid(86_400); + let h = day_secs / 3600; + let m = (day_secs % 3600) / 60; + let s = day_secs % 60; + format!("{h:02}:{m:02}:{s:02}") +} + +// --------------------------------------------------------------------------- +// Tabs +// --------------------------------------------------------------------------- + +fn render_tabs(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) { + // S1: only the Activity tab. The data structure is here so S2 can drop + // in `Databases`, `Tables`, … without touching the renderer entry point. + let titles = vec![Line::from(Span::styled( + View::Activity.label(), + theme.title, + ))]; + let selected = match app.view { + View::Activity => 0, + }; + let tabs = Tabs::new(titles) + .select(selected) + .style(theme.muted) + .highlight_style( + theme + .title + .add_modifier(Modifier::REVERSED) + .add_modifier(Modifier::BOLD), + ) + .divider(Span::styled(" │ ", theme.muted)); + frame.render_widget(tabs, area); +} + +// --------------------------------------------------------------------------- +// Body +// --------------------------------------------------------------------------- + +fn render_body(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) { + match app.view { + View::Activity => activity::render(frame, area, app, theme), + } +} + +// --------------------------------------------------------------------------- +// Key-press overlay +// --------------------------------------------------------------------------- + +/// 5-row solid-yellow billboard at the lower-right of the body. +/// Bigger than a 3-row strip so the badge reads as a proper "key +/// pressed" callout in screenshots. Plain ASCII for the label (no +/// Unicode fullwidth — that approach produced inconsistent cell-width +/// handling and a notched right edge in some renderers). Arrow keys +/// swap to chunky `▲▼◀▶` so they don't look anaemic next to +/// multi-letter labels. Bright `Color::Yellow` lets terminals map it +/// to their own theme palette. +const KEY_OVERLAY_MIN_BOX_W: u16 = 8; +const KEY_OVERLAY_PADDING: u16 = 4; +const KEY_OVERLAY_BOX_H: u16 = 3; + +fn render_key_overlay(frame: &mut Frame, body_area: Rect, app: &App, _theme: &Theme) { + let Some(ko) = app.fresh_key_overlay() else { + return; + }; + + let fill = Style::default() + .bg(ratatui::style::Color::Yellow) + .fg(ratatui::style::Color::Black) + .add_modifier(Modifier::BOLD); + + let label = chunky_arrow(&ko.label); + + // Render via the 3×5 figlet font when every character of the label + // has a hand-drawn glyph. That covers single-char keys (`e`, `K`, + // `r`, `<`, …), arrows, *and* multi-char labels like `PgDn`, `Esc`, + // `Home` — they all get the same scale. Anything outside the + // alphabet (extended Unicode, e.g. `↩`/`⌫`) falls back to plain + // centred text. + let glyph_lines = super::keyfont::render_label(&label); + + let (lines, inner_w, box_h) = if let Some(rows) = glyph_lines { + // Glyph rows + 1 cell of padding on each side. The font's + // GLYPH_H rows fill the entire box height — no top / bottom + // padding. + const SIDE_PAD: usize = 1; + let glyph_w = rows.first().map_or(0, |r| r.chars().count()); + let inner_w_usize = glyph_w + 2 * SIDE_PAD; + let pad: String = " ".repeat(SIDE_PAD); + let lines: Vec = rows + .iter() + .map(|row| { + let s = format!("{pad}{row}{pad}"); + Line::from(Span::styled(s, fill)) + }) + .collect(); + let inner_w = u16::try_from(inner_w_usize).unwrap_or(u16::MAX); + let box_h = u16::try_from(rows.len()).unwrap_or(u16::MAX); + (lines, inner_w, box_h) + } else { + // Plain-text fallback for chars outside the figlet font. + // Centre the label on the middle row of a 3-row blank yellow box. + let label_w = u16::try_from(label.chars().count()).unwrap_or(u16::MAX); + let inner_w = (label_w + KEY_OVERLAY_PADDING).max(KEY_OVERLAY_MIN_BOX_W); + let inner_w_usize = inner_w as usize; + let pad_total = inner_w_usize.saturating_sub(label.chars().count()); + let pad_left = pad_total / 2; + let pad_right = pad_total - pad_left; + let middle = format!("{}{}{}", " ".repeat(pad_left), label, " ".repeat(pad_right)); + let blank: String = " ".repeat(inner_w_usize); + let lines = vec![ + Line::from(Span::styled(blank.clone(), fill)), + Line::from(Span::styled(middle, fill)), + Line::from(Span::styled(blank, fill)), + ]; + (lines, inner_w, KEY_OVERLAY_BOX_H) + }; + + if body_area.width <= inner_w + 2 || body_area.height < box_h + 1 { + return; + } + + // Lower-right corner with one cell of padding from the body edges. + let x = body_area + .x + .saturating_add(body_area.width) + .saturating_sub(inner_w + 1); + let y = body_area + .y + .saturating_add(body_area.height) + .saturating_sub(box_h + 1); + let area = Rect::new(x, y, inner_w, box_h); + + frame.render_widget(Paragraph::new(lines), area); +} + +/// Swap the thin Unicode arrow keys for the chunky triangular variants, +/// which read better at any cell size. Everything else passes through +/// unchanged so multi-letter labels (`PgDn`, `Esc`, `Home`, …) stay in +/// plain ASCII. +fn chunky_arrow(s: &str) -> String { + s.chars() + .map(|c| match c { + '↑' => '▲', + '↓' => '▼', + '←' => '◀', + '→' => '▶', + other => other, + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Footer +// --------------------------------------------------------------------------- + +fn render_footer(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) { + // Priority order, top to bottom: + // 1. text-input prompt (s) — user typing + // 2. kill confirmation (k / K) — destructive: must be obvious + // 3. fresh admin message — last action's outcome + // 4. sampler error — connection / SQL surface + // 5. default keymap hint + let line = if let Some(prompt) = app.prompt.as_ref() { + build_prompt_line(prompt, theme) + } else if let Some(req) = app.kill_confirm.as_ref() { + build_kill_confirm_line(req, theme) + } else if let Some(msg) = fresh_admin_message(app) { + build_admin_message_line(msg, theme) + } else if let Some(err) = app.last_error.as_deref() { + Line::from(vec![ + Span::styled(" error ", Style::default().fg(ratatui::style::Color::Red)), + Span::raw(truncate(err, area.width.saturating_sub(8) as usize)), + ]) + } else { + build_default_footer(theme) + }; + frame.render_widget(Paragraph::new(line), area); +} + +fn build_kill_confirm_line<'a>(req: &'a KillRequest, theme: &'a Theme) -> Line<'a> { + let qtime = format_secs(req.qtime_secs); + Line::from(vec![ + Span::styled( + format!(" {} ", req.mode.verb_upper()), + Style::default() + .bg(ratatui::style::Color::Red) + .add_modifier(Modifier::BOLD), + ), + // Scrub each Postgres-supplied field separately. A DB user + // controlling `application_name` / `usename` could otherwise + // smuggle ANSI/BEL bytes into the operator's terminal at the + // moment they press `k` / `K` on that backend. The activity + // table scrubs via `truncate` / `squash_query`; this confirm + // line takes the same precaution. (REV round-11 finding.) + Span::raw(format!( + " pid {} ({}@{}, {} {qtime}, ‹{}›)? ", + req.pid, + scrub_terminal_unsafe(&req.usename), + scrub_terminal_unsafe(&req.datname), + scrub_terminal_unsafe(&req.state), + scrub_terminal_unsafe(&req.query_summary), + )), + Span::styled("[y/N]", theme.title), + ]) +} + +fn fresh_admin_message(app: &App) -> Option<&AdminMessage> { + app.admin_message + .as_ref() + .filter(|m| std::time::Instant::now() < m.expires_at) +} + +fn build_admin_message_line<'a>(msg: &'a AdminMessage, theme: &'a Theme) -> Line<'a> { + let badge_style = match msg.level { + AdminMessageLevel::Ok => Style::default() + .bg(ratatui::style::Color::Green) + .fg(ratatui::style::Color::Black) + .add_modifier(Modifier::BOLD), + AdminMessageLevel::Err => Style::default() + .bg(ratatui::style::Color::Red) + .add_modifier(Modifier::BOLD), + }; + let badge = match msg.level { + AdminMessageLevel::Ok => " OK ", + AdminMessageLevel::Err => " ERR ", + }; + // `msg.text` in the kill error branch is `format!("{e}")` from + // `tokio_postgres::Error`, which can carry server-controlled bytes. + // Scrub before rendering. (REV round-11 finding.) + Line::from(vec![ + Span::styled(badge, badge_style), + Span::raw(" "), + Span::styled(scrub_terminal_unsafe(&msg.text), theme.footer), + ]) +} + +fn build_default_footer(theme: &Theme) -> Line<'_> { + Line::from(vec![ + Span::styled(" q ", theme.title), + Span::styled("quit ", theme.footer), + Span::styled("↑↓ ", theme.title), + Span::styled("move ", theme.footer), + Span::styled("Space ", theme.title), + Span::styled("refresh ", theme.footer), + Span::styled("←→ ", theme.title), + Span::styled("sort ", theme.footer), + Span::styled("r ", theme.title), + Span::styled("reverse ", theme.footer), + Span::styled("k/K ", theme.title), + Span::styled("cancel/term ", theme.footer), + Span::styled("e ", theme.title), + Span::styled("extended ", theme.footer), + Span::styled("s ", theme.title), + Span::styled("set delay", theme.footer), + ]) +} + +fn build_prompt_line<'a>(prompt: &'a PromptState, theme: &'a Theme) -> Line<'a> { + let label = match prompt.kind { + PromptKind::Refresh => prompt.kind.label(), + }; + Line::from(vec![ + Span::styled(format!(" {label}: "), theme.title), + Span::raw(prompt.buffer.as_str()), + Span::styled("█", theme.title), + Span::styled(" [Enter to apply, Esc to cancel]", theme.muted), + ]) +} + +// --------------------------------------------------------------------------- +// Too-small stub +// --------------------------------------------------------------------------- + +fn render_too_small(frame: &mut Frame, area: Rect) { + let p = Paragraph::new(format!( + "rpg /top requires a terminal of at least {MIN_COLS}×{MIN_ROWS}.\nResize and try again." + )) + .alignment(ratatui::layout::Alignment::Center); + frame.render_widget(p, area); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::top::state::{ActivityRow, ServerSummary}; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + fn fixture_snapshot() -> Snapshot { + Snapshot { + ts: 1_700_000_000, + server: ServerSummary { + db_name: "prod".into(), + user: "nik".into(), + pg_version: "16.4".into(), + uptime_secs: 14 * 86_400 + 3 * 3600, + in_recovery: false, + active: 17, + idle_in_tx: 3, + waiting: 2, + total_backends: 22, + max_connections: 100, + longest_xact_secs: 125.0, + longest_active_query_secs: 42.0, + deadlocks_total: 0, + temp_files_total: 5, + autovacuum_busy: 1, + autovacuum_max: 3, + phys_slots: 2, + phys_slots_active: 2, + log_slots: 1, + log_slots_active: 1, + }, + rows: vec![ + ActivityRow { + pid: 12_345, + usename: "app".into(), + datname: "prod".into(), + application_name: "web-1".into(), + client_addr: "10.0.0.5".into(), + backend_type: "client backend".into(), + state: "active".into(), + wait_event_type: "IO".into(), + wait_event: "DataFileRead".into(), + qtime_secs: Some(42.0), + xtime_secs: Some(42.0), + query: "update accounts set balance = balance + 1 where id = 5".into(), + locks_held: 47, // unambiguous: not in any pid or server-stat + }, + ActivityRow { + pid: 12_346, + usename: "etl".into(), + datname: "analytics".into(), + application_name: "etl-runner".into(), + client_addr: "10.0.0.7".into(), + backend_type: "client backend".into(), + state: "active".into(), + wait_event_type: "Lock".into(), + wait_event: "transactionid".into(), + qtime_secs: Some(2.3), + xtime_secs: Some(1020.0), + query: "select count(*) from events where ts > now() - interval '1 day'".into(), + locks_held: 0, // renders as "-" + }, + ActivityRow { + pid: 12_350, + usename: "nik".into(), + datname: "prod".into(), + application_name: "psql".into(), + client_addr: String::new(), + backend_type: "client backend".into(), + state: "idle in transaction".into(), + wait_event_type: "Client".into(), + wait_event: "ClientRead".into(), + qtime_secs: Some(5.0), + xtime_secs: Some(125.0), + query: "begin".into(), + locks_held: 83, // unambiguous: not in any pid or server-stat + }, + ], + } + } + + fn render_into(width: u16, height: u16, app: &App) -> ratatui::buffer::Buffer { + let backend = TestBackend::new(width, height); + let mut term = Terminal::new(backend).expect("create test terminal"); + let theme = Theme::for_tests(); + term.draw(|f| draw(f, app, &theme)).expect("draw"); + term.backend().buffer().clone() + } + + #[test] + fn renders_loading_when_no_snapshot() { + let app = App::new(); + let buf = render_into(120, 30, &app); + let dump = buffer_to_string(&buf); + assert!(dump.contains("rpg /top"), "header missing"); + assert!(dump.contains("Activity"), "tabs missing"); + assert!( + dump.contains("collecting first sample"), + "loading hint missing" + ); + assert!(dump.contains("quit"), "footer hint missing"); + } + + #[test] + fn renders_activity_rows_with_summary() { + let mut app = App::new(); + app.set_snapshot(fixture_snapshot()); + let buf = render_into(120, 30, &app); + let dump = buffer_to_string(&buf); + assert!(dump.contains("db prod"), "summary db missing"); + assert!(dump.contains("active 17"), "active count missing"); + assert!(dump.contains("idle-in-tx 3"), "idle-in-tx count missing"); + assert!(dump.contains("12345"), "first pid missing"); + assert!(dump.contains("update accounts"), "query text missing"); + assert!(dump.contains("(3 rows)"), "row count caption missing"); + assert!(dump.contains("Activity"), "tab label missing"); + } + + #[test] + fn renders_empty_state_when_zero_rows() { + let mut app = App::new(); + app.set_snapshot(Snapshot { + ts: 1, + server: ServerSummary { + db_name: "prod".into(), + user: "nik".into(), + pg_version: "16".into(), + ..Default::default() + }, + rows: vec![], + }); + let buf = render_into(120, 30, &app); + let dump = buffer_to_string(&buf); + assert!(dump.contains("no active backends"), "empty hint missing"); + } + + #[test] + fn renders_too_small_stub_below_min_size() { + let mut app = App::new(); + app.set_snapshot(fixture_snapshot()); + let buf = render_into(60, 10, &app); + let dump = buffer_to_string(&buf); + assert!( + dump.contains("requires a terminal of at least"), + "too-small stub missing: {dump}" + ); + } + + #[test] + fn default_layout_omits_app_and_client_columns() { + let mut app = App::new(); + app.set_snapshot(fixture_snapshot()); + // Even on a wide terminal, app/client/backend are not in the default + // column set — they require `e` to opt into extended mode. + let buf = render_into(140, 30, &app); + let dump = buffer_to_string(&buf); + assert!(dump.contains("12345"), "default layout dropped pid"); + assert!( + !dump.contains("10.0.0.5"), + "default layout must not show the client column: {dump}" + ); + assert!( + !dump.contains("etl-runner"), + "default layout must not show the application_name column: {dump}" + ); + } + + #[test] + fn extended_mode_adds_app_client_backend_columns() { + let mut app = App::new(); + app.set_snapshot(fixture_snapshot()); + app.extended = true; + let buf = render_into(140, 30, &app); + let dump = buffer_to_string(&buf); + assert!( + dump.contains("10.0.0.5"), + "extended mode must show client_addr: {dump}" + ); + assert!( + dump.contains("etl-runner"), + "extended mode must show application_name: {dump}" + ); + // The Activity title gains the [extended] indicator. + assert!( + dump.contains("[extended]"), + "extended-mode badge missing: {dump}" + ); + } + + #[test] + fn header_renders_enriched_postgres_stats() { + let mut app = App::new(); + app.set_snapshot(fixture_snapshot()); + let buf = render_into(160, 30, &app); + let dump = buffer_to_string(&buf); + assert!( + dump.contains("total 22/100"), + "total/max_connections missing: {dump}" + ); + assert!(dump.contains("longest-tx"), "longest-tx missing: {dump}"); + assert!(dump.contains("longest-q"), "longest-q missing: {dump}"); + assert!( + dump.contains("deadlocks 0"), + "deadlocks counter missing: {dump}" + ); + assert!( + dump.contains("temp-files 5"), + "temp-files counter missing: {dump}" + ); + } + + #[test] + fn header_renders_ops_line_with_autovacuum_and_slots() { + let mut app = App::new(); + app.set_snapshot(fixture_snapshot()); + let buf = render_into(180, 30, &app); + let dump = buffer_to_string(&buf); + assert!( + dump.contains("av 1/3"), + "autovacuum busy/max missing: {dump}" + ); + assert!( + dump.contains("slots 2/2p 1/1l"), + "replication slot counts missing: {dump}" + ); + } + + #[test] + fn footer_shows_kill_confirmation_when_pending() { + use crate::top::state::{KillMode, KillRequest}; + + let mut app = App::new(); + app.set_snapshot(fixture_snapshot()); + app.kill_confirm = Some(KillRequest { + mode: KillMode::Terminate, + pid: 12_345, + usename: "app".into(), + datname: "prod".into(), + state: "active".into(), + qtime_secs: Some(42.0), + query_summary: "update accounts set …".into(), + }); + let buf = render_into(180, 30, &app); + let dump = buffer_to_string(&buf); + assert!(dump.contains("TERMINATE"), "verb missing: {dump}"); + assert!(dump.contains("pid 12345"), "pid missing: {dump}"); + assert!(dump.contains("[y/N]"), "confirm hint missing: {dump}"); + } + + #[test] + fn footer_shows_admin_message_briefly() { + use crate::top::state::{AdminMessage, AdminMessageLevel}; + use std::time::Duration; + + let mut app = App::new(); + app.set_snapshot(fixture_snapshot()); + app.admin_message = Some(AdminMessage { + text: "CANCEL pid 12345: ok".into(), + level: AdminMessageLevel::Ok, + expires_at: std::time::Instant::now() + Duration::from_secs(5), + }); + let buf = render_into(160, 30, &app); + let dump = buffer_to_string(&buf); + assert!(dump.contains(" OK "), "OK badge missing: {dump}"); + assert!( + dump.contains("CANCEL pid 12345: ok"), + "admin message missing: {dump}" + ); + } + + #[test] + fn expired_admin_message_falls_back_to_default_footer() { + use crate::top::state::{AdminMessage, AdminMessageLevel}; + + let mut app = App::new(); + app.set_snapshot(fixture_snapshot()); + app.admin_message = Some(AdminMessage { + text: "CANCEL pid 12345: ok".into(), + level: AdminMessageLevel::Ok, + // expired: 1 second in the past + expires_at: std::time::Instant::now() + .checked_sub(std::time::Duration::from_secs(1)) + .unwrap_or_else(std::time::Instant::now), + }); + let buf = render_into(160, 30, &app); + let dump = buffer_to_string(&buf); + assert!( + !dump.contains("CANCEL pid 12345"), + "expired message must not be rendered: {dump}" + ); + // Default footer should be visible instead. + assert!( + dump.contains('q'), + "default footer 'q' hint missing: {dump}" + ); + } + + #[test] + fn footer_shows_refresh_prompt_when_open() { + let mut app = App::new(); + app.set_snapshot(fixture_snapshot()); + app.open_refresh_prompt(); + let buf = render_into(140, 30, &app); + let dump = buffer_to_string(&buf); + assert!( + dump.contains("delay (secs)"), + "prompt label missing: {dump}" + ); + assert!( + dump.contains("[Enter to apply, Esc to cancel]"), + "prompt hint missing: {dump}" + ); + // Default footer hints must be hidden while the prompt is open. + assert!( + !dump.contains(" quit "), + "default footer must be replaced by the prompt: {dump}" + ); + } + + #[test] + fn active_sort_column_renders_with_arrow_indicator() { + use crate::top::state::SortColumn; + + // Default = Qtime descending → expect "qtime▼" in the table header. + let mut app = App::new(); + app.set_snapshot(fixture_snapshot()); + let buf = render_into(140, 30, &app); + assert!( + buffer_to_string(&buf).contains("qtime▼"), + "expected qtime▼ on default sort: {}", + buffer_to_string(&buf) + ); + + // Switch to Pid asc — sort_desc inherits Pid's default (desc), + // so r toggles to asc. + app.sort_column = SortColumn::Pid; + app.sort_desc = false; + let buf = render_into(140, 30, &app); + let dump = buffer_to_string(&buf); + assert!(dump.contains("pid▲"), "expected pid▲ on Pid asc: {dump}"); + // The previously-active column should not still carry an arrow. + assert!( + !dump.contains("qtime▼") && !dump.contains("qtime▲"), + "qtime should not be marked active any more: {dump}" + ); + } + + #[test] + fn key_overlay_appears_after_a_recent_keypress_when_enabled() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + let mut app = App::new(); + app.set_snapshot(fixture_snapshot()); + app.show_keys = true; + app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), 10); + let buf = render_into(140, 30, &app); + let dump = buffer_to_string(&buf); + // Down arrow is upscaled to its chunky variant ▼ inside the + // banner. There must be no keyboard / "⌨" decoration anywhere + // around it (the user explicitly removed it). + assert!(dump.contains('▼'), "expected ▼ label in overlay: {dump}"); + assert!(!dump.contains("⌨"), "no keyboard glyph in overlay: {dump}"); + } + + /// Returns true if any cell in the buffer is filled with the + /// overlay's yellow background. Used as a reliable "is the overlay + /// on screen?" check now that the label glyphs themselves (▼ etc.) + /// can also appear in the body table (sort indicator). + fn buffer_has_overlay_bg(buf: &ratatui::buffer::Buffer) -> bool { + let target = ratatui::style::Color::Yellow; + for y in 0..buf.area.height { + for x in 0..buf.area.width { + if buf[(x, y)].bg == target { + return true; + } + } + } + false + } + + #[test] + fn key_overlay_off_by_default_in_render() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + let mut app = App::new(); + app.set_snapshot(fixture_snapshot()); + // Default: no overlay bg color anywhere in the frame even + // after a keypress. + app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), 10); + let buf = render_into(140, 30, &app); + assert!( + !buffer_has_overlay_bg(&buf), + "overlay must default to off (no overlay-bg cell expected)" + ); + } + + #[test] + fn key_overlay_omits_after_expiry() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + let mut app = App::new(); + app.set_snapshot(fixture_snapshot()); + app.show_keys = true; + app.handle_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), 10); + // Force-expire the overlay. + app.last_key.as_mut().unwrap().expires_at = std::time::Instant::now() + .checked_sub(std::time::Duration::from_millis(1)) + .expect("clock has advanced past 1 ms since boot"); + let buf = render_into(140, 30, &app); + assert!( + !buffer_has_overlay_bg(&buf), + "expired overlay should leave no overlay-bg cell behind" + ); + } + + #[test] + fn locks_column_shows_dash_for_zero_and_number_otherwise() { + let mut app = App::new(); + app.set_snapshot(fixture_snapshot()); + let buf = render_into(140, 30, &app); + let dump = buffer_to_string(&buf); + assert!(dump.contains("locks"), "locks header missing: {dump}"); + // Values chosen to be unambiguous: 47 and 83 do not appear in any + // pid, server-stat field, or other column in the fixture snapshot. + assert!( + dump.contains("47"), + "expected 47-locks count for pid 12345: {dump}" + ); + assert!( + dump.contains("83"), + "expected 83-locks count for pid 12350: {dump}" + ); + // pid 12346 has locks_held=0 which must render as "-" not "0". + // We can't search for bare '-' (appears in wait event), but we can + // confirm '0' does NOT appear in the locks column by proxy: if the + // zero row rendered as "0" it would appear between "47" and "83". + // The dash rendering is covered by format_locks unit tests. + } + + /// End-to-end safety: a malicious `pg_stat_activity` row with an ANSI + /// escape in `application_name`/`query`/`db_name` must not produce any + /// ESC byte in any rendered cell symbol. This protects DBAs whose + /// terminals would otherwise execute the embedded escape. + #[test] + fn renderer_strips_ansi_escapes_from_user_controllable_fields() { + use crate::top::state::{ActivityRow, ServerSummary}; + let mut app = App::new(); + app.set_snapshot(Snapshot { + ts: 1, + server: ServerSummary { + db_name: "evil\x1b[2J".into(), + user: "nik\x1b[31m".into(), + pg_version: "16.4".into(), + ..Default::default() + }, + rows: vec![ActivityRow { + pid: 1, + usename: "u".into(), + datname: "d".into(), + application_name: "psql\x1b[33mPWNED".into(), + state: "active".into(), + query: "select 1\x1b[2J -- pwn\x07".into(), + ..Default::default() + }], + }); + let buf = render_into(140, 30, &app); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + let sym = buf[(x, y)].symbol(); + assert!( + !sym.as_bytes().iter().any(|b| *b == 0x1b || *b == 0x07), + "cell ({x},{y}) contains a control byte: {sym:?}", + ); + } + } + } + + /// Same protection extended to the kill-confirm footer line. A DB + /// user controlling `application_name`/`usename` would otherwise + /// inject ANSI/BEL bytes into the operator's terminal at the moment + /// they press `k`/`K` on that backend. + #[test] + fn kill_confirm_strips_ansi_escapes_from_row_fields() { + use crate::top::state::{ActivityRow, ServerSummary}; + let mut app = App::new(); + app.set_snapshot(Snapshot { + ts: 1, + server: ServerSummary::default(), + rows: vec![ActivityRow { + pid: 4242, + usename: "u\x1b[31m".into(), + datname: "d\x1b[2J".into(), + state: "active\x07".into(), + query: "select 1\x1b[33mPWNED".into(), + ..Default::default() + }], + }); + app.handle_key( + crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('k'), + crossterm::event::KeyModifiers::empty(), + ), + 10, + ); + assert!(app.kill_confirm.is_some(), "k must open the confirm"); + let buf = render_into(140, 30, &app); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + let sym = buf[(x, y)].symbol(); + assert!( + !sym.as_bytes().iter().any(|b| *b == 0x1b || *b == 0x07), + "cell ({x},{y}) leaked a control byte from the kill confirm: {sym:?}", + ); + } + } + } + + /// Same protection extended to the post-kill admin message footer. + /// `run_kill` builds the error text from `format!("{e}")` against a + /// `tokio_postgres::Error`, which can carry server-controlled bytes. + #[test] + fn admin_message_strips_ansi_escapes() { + use crate::top::state::{AdminMessage, AdminMessageLevel}; + let mut app = App::new(); + app.set_snapshot(fixture_snapshot()); + app.admin_message = Some(AdminMessage { + text: "kill failed: server says \x1b[31mPWNED\x07".into(), + level: AdminMessageLevel::Err, + expires_at: std::time::Instant::now() + std::time::Duration::from_secs(60), + }); + let buf = render_into(140, 30, &app); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + let sym = buf[(x, y)].symbol(); + assert!( + !sym.as_bytes().iter().any(|b| *b == 0x1b || *b == 0x07), + "cell ({x},{y}) leaked a control byte from admin message: {sym:?}", + ); + } + } + } + + #[test] + fn footer_shows_error_when_set() { + let mut app = App::new(); + app.set_snapshot(fixture_snapshot()); + app.note_error("connection lost".into()); + let buf = render_into(120, 30, &app); + let dump = buffer_to_string(&buf); + assert!(dump.contains("error"), "error label missing"); + assert!(dump.contains("connection lost"), "error message missing"); + } + + /// Inspect cell-level styling: when the sampler is fresh, the connection + /// dot must use `theme.status_ok`; after a missed tick it must flip to + /// `theme.status_stale`. Without this, a bug that swaps the two styles + /// would pass the dump-substring tests above. + #[test] + fn connection_led_color_reflects_freshness() { + let theme = Theme::default_theme(); // use the colored theme so fg differs + + // Fresh: status_ok + let mut fresh = App::new(); + fresh.set_snapshot(fixture_snapshot()); + let backend = TestBackend::new(140, 30); + let mut term = Terminal::new(backend).expect("terminal"); + term.draw(|f| draw(f, &fresh, &theme)).expect("draw"); + let dot_fg_fresh = find_dot_fg(term.backend().buffer()); + assert_eq!( + dot_fg_fresh, + Some(theme.status_ok.fg.expect("status_ok must define fg")), + "fresh connection dot must use status_ok color", + ); + + // Stale: status_stale + let mut stale = App::new(); + stale.set_snapshot(fixture_snapshot()); + stale.note_stale(); + let backend = TestBackend::new(140, 30); + let mut term = Terminal::new(backend).expect("terminal"); + term.draw(|f| draw(f, &stale, &theme)).expect("draw"); + let dot_fg_stale = find_dot_fg(term.backend().buffer()); + assert_eq!( + dot_fg_stale, + Some(theme.status_stale.fg.expect("status_stale must define fg")), + "stale connection dot must use status_stale color", + ); + } + + fn find_dot_fg(buf: &ratatui::buffer::Buffer) -> Option { + for y in 0..buf.area.height { + for x in 0..buf.area.width { + if buf[(x, y)].symbol() == "●" { + return Some(buf[(x, y)].fg); + } + } + } + None + } + + #[test] + fn header_shows_stale_badge_when_ticks_missed() { + let mut app = App::new(); + app.set_snapshot(fixture_snapshot()); + app.note_stale(); + app.note_stale(); + let buf = render_into(120, 30, &app); + let dump = buffer_to_string(&buf); + assert!(dump.contains("stale 2"), "stale badge missing: {dump}"); + } + + fn buffer_to_string(buf: &ratatui::buffer::Buffer) -> String { + let mut out = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out + } + + #[test] + fn format_clock_utc_handles_epoch_and_anchor() { + assert_eq!(super::format_clock_utc(0), "00:00:00"); + // 1_700_000_000 = 2023-11-14T22:13:20Z (sanity-checked manually). + assert_eq!(super::format_clock_utc(1_700_000_000), "22:13:20"); + // Negative or pre-epoch values fall back to "—". + assert_eq!(super::format_clock_utc(-1), "—"); + } + + #[test] + fn header_renders_human_readable_clock() { + let mut app = App::new(); + app.set_snapshot(fixture_snapshot()); + let buf = render_into(140, 30, &app); + let dump = buffer_to_string(&buf); + assert!( + !dump.contains("T1700000000"), + "raw unix epoch must not be rendered in the header: {dump}" + ); + assert!( + dump.contains("22:13:20 UTC"), + "header must render snapshot ts as HH:MM:SS UTC: {dump}" + ); + } + + #[test] + fn format_uptime_human_units() { + assert_eq!(format_uptime(0), "0s"); + assert_eq!(format_uptime(45), "45s"); + assert_eq!(format_uptime(120), "2m"); + assert_eq!(format_uptime(3661), "1h01m"); + assert_eq!(format_uptime(90_000), "1d01h"); + } +} diff --git a/src/top/sampler.rs b/src/top/sampler.rs new file mode 100644 index 00000000..5efe462c --- /dev/null +++ b/src/top/sampler.rs @@ -0,0 +1,170 @@ +//! Data plane for `/top`. Runs the SQL strings from [`crate::top::sql`] and +//! folds their result sets into a [`Snapshot`]. +//! +//! The sampler mirrors `/ash`'s observer-effect protection: it sets +//! `statement_timeout` for each query and resets it afterwards so a slow +//! `pg_stat_activity` scan fails fast rather than wedging the TUI. When a +//! tick times out the caller is told to mark the snapshot stale rather than +//! propagating the error. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use tokio_postgres::Client; + +use super::sql::{ACTIVITY_SQL, SUMMARY_SQL}; +use super::state::{ActivityRow, ServerSummary, Snapshot}; + +/// Outcome of a single sampler tick. +#[derive(Debug)] +pub enum TickResult { + /// The tick produced a fresh snapshot. + Ok(Box), + /// The tick was cancelled by `statement_timeout` — the TUI shows a + /// "stale" badge and keeps the previous snapshot. + Missed, +} + +/// Run one sampler tick: server summary + active backends. +/// +/// `timeout_ms` — applied per query. Pass `0` to disable. +pub async fn tick(client: &Client, timeout_ms: u64) -> anyhow::Result { + let Some(server) = query_summary(client, timeout_ms).await? else { + return Ok(TickResult::Missed); + }; + let Some(rows) = query_activity(client, timeout_ms).await? else { + return Ok(TickResult::Missed); + }; + + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)); + + let snap = Snapshot { ts, server, rows }; + Ok(TickResult::Ok(Box::new(snap))) +} + +async fn query_summary(client: &Client, timeout_ms: u64) -> anyhow::Result> { + apply_timeout(client, timeout_ms).await; + let row = match client.query_one(SUMMARY_SQL, &[]).await { + Ok(r) => r, + Err(e) => { + reset_timeout(client, timeout_ms).await; + if is_query_canceled(&e) { + return Ok(None); + } + return Err(e.into()); + } + }; + reset_timeout(client, timeout_ms).await; + + Ok(Some(ServerSummary { + db_name: row.get::<_, String>(0), + user: row.get::<_, String>(1), + pg_version: row.try_get::<_, Option>(2)?.unwrap_or_default(), + uptime_secs: row.try_get::<_, Option>(3)?.unwrap_or(0), + in_recovery: row.try_get::<_, Option>(4)?.unwrap_or(false), + #[allow(clippy::cast_sign_loss)] + active: row.get::<_, i32>(5).max(0) as u32, + #[allow(clippy::cast_sign_loss)] + idle_in_tx: row.get::<_, i32>(6).max(0) as u32, + #[allow(clippy::cast_sign_loss)] + waiting: row.get::<_, i32>(7).max(0) as u32, + #[allow(clippy::cast_sign_loss)] + total_backends: row.get::<_, i32>(8).max(0) as u32, + #[allow(clippy::cast_sign_loss)] + max_connections: row.get::<_, i32>(9).max(0) as u32, + longest_xact_secs: row.try_get::<_, Option>(10)?.unwrap_or(0.0).max(0.0), + longest_active_query_secs: row.try_get::<_, Option>(11)?.unwrap_or(0.0).max(0.0), + deadlocks_total: row.try_get::<_, Option>(12)?.unwrap_or(0), + temp_files_total: row.try_get::<_, Option>(13)?.unwrap_or(0), + #[allow(clippy::cast_sign_loss)] + autovacuum_busy: row.get::<_, i32>(14).max(0) as u32, + #[allow(clippy::cast_sign_loss)] + autovacuum_max: row.get::<_, i32>(15).max(0) as u32, + #[allow(clippy::cast_sign_loss)] + phys_slots: row.get::<_, i32>(16).max(0) as u32, + #[allow(clippy::cast_sign_loss)] + phys_slots_active: row.get::<_, i32>(17).max(0) as u32, + #[allow(clippy::cast_sign_loss)] + log_slots: row.get::<_, i32>(18).max(0) as u32, + #[allow(clippy::cast_sign_loss)] + log_slots_active: row.get::<_, i32>(19).max(0) as u32, + })) +} + +async fn query_activity( + client: &Client, + timeout_ms: u64, +) -> anyhow::Result>> { + apply_timeout(client, timeout_ms).await; + let rows = match client.query(ACTIVITY_SQL, &[]).await { + Ok(rs) => rs, + Err(e) => { + reset_timeout(client, timeout_ms).await; + if is_query_canceled(&e) { + return Ok(None); + } + return Err(e.into()); + } + }; + reset_timeout(client, timeout_ms).await; + + let mut out = Vec::with_capacity(rows.len()); + for r in &rows { + out.push(ActivityRow { + pid: r.get::<_, i32>(0), + usename: r.get::<_, String>(1), + datname: r.get::<_, String>(2), + application_name: r.get::<_, String>(3), + client_addr: r.get::<_, String>(4), + backend_type: r.get::<_, String>(5), + state: r.get::<_, String>(6), + wait_event_type: r.get::<_, String>(7), + wait_event: r.get::<_, String>(8), + qtime_secs: r.try_get::<_, Option>(9)?, + xtime_secs: r.try_get::<_, Option>(10)?, + query: r.get::<_, String>(11), + locks_held: r.try_get::<_, Option>(12)?.unwrap_or(0), + }); + } + Ok(Some(out)) +} + +async fn apply_timeout(client: &Client, timeout_ms: u64) { + if timeout_ms > 0 { + let _ = client + .execute(&format!("set statement_timeout = '{timeout_ms}ms'"), &[]) + .await; + } +} + +async fn reset_timeout(client: &Client, timeout_ms: u64) { + if timeout_ms > 0 { + let _ = client.execute("set statement_timeout = 0", &[]).await; + } +} + +fn is_query_canceled(err: &tokio_postgres::Error) -> bool { + err.code() == Some(&tokio_postgres::error::SqlState::QUERY_CANCELED) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tick_result_ok_carries_boxed_snapshot() { + let snap = Snapshot::default(); + let r = TickResult::Ok(Box::new(snap)); + match r { + TickResult::Ok(b) => assert_eq!(b.ts, 0), + TickResult::Missed => panic!("expected Ok"), + } + } + + #[test] + fn tick_result_missed_signals_stale() { + let r = TickResult::Missed; + assert!(matches!(r, TickResult::Missed)); + } +} diff --git a/src/top/sql.rs b/src/top/sql.rs new file mode 100644 index 00000000..3d6c65a7 --- /dev/null +++ b/src/top/sql.rs @@ -0,0 +1,200 @@ +//! SQL strings for `/top`. Co-located so each view's query is easy to find, +//! review, and version-gate. All queries follow the project's lower-case- +//! keyword style (`CLAUDE.md` SQL style guide). +//! +//! S1 ships two queries: a server-summary header read and the Activity +//! view body. Later sprints add one query per additional view; views that +//! need columns gated on PG ≥ 16 should branch at construction time using +//! the server's `server_version_num` once the sampler exposes it. + +/// Server summary used in the header bar. +/// +/// Returns a single row mixing per-cluster facts (uptime, recovery, +/// `max_connections`), per-database aggregates (`deadlocks`, `temp_files` — +/// summed across every database the cluster knows about), and per-session +/// aggregates from `pg_stat_activity` (active / idle-in-tx / wait counts, +/// longest active transaction, longest active query). Excludes the rpg +/// monitor backend itself so the active count is not inflated. +pub const SUMMARY_SQL: &str = r" + select + current_database() as db_name, + current_user as usename, + substring(version() from '[0-9]+\.[0-9]+') as pg_version, + extract( + epoch from (now() - pg_postmaster_start_time()) + )::bigint as uptime_secs, + pg_is_in_recovery() as in_recovery, + coalesce(sum(case when state = 'active' then 1 else 0 end), 0)::int + as active, + coalesce(sum(case + when state in ('idle in transaction', + 'idle in transaction (aborted)') then 1 else 0 end), 0)::int + as idle_in_tx, + coalesce(sum(case + when wait_event is not null and state = 'active' then 1 else 0 end), 0)::int + as waiting, + count(*)::int as total_backends, + current_setting('max_connections')::int as max_connections, + coalesce( + extract(epoch from (now() - min(xact_start)))::float8, + 0::float8 + ) as longest_xact_secs, + coalesce( + extract(epoch from ( + now() - min(query_start) filter (where state = 'active') + ))::float8, + 0::float8 + ) as longest_active_query_secs, + (select coalesce(sum(deadlocks), 0)::int8 from pg_stat_database) + as deadlocks_total, + (select coalesce(sum(temp_files), 0)::int8 from pg_stat_database) + as temp_files_total, + coalesce( + sum(case when backend_type = 'autovacuum worker' then 1 else 0 end), + 0 + )::int as autovacuum_busy, + current_setting('autovacuum_max_workers')::int as autovacuum_max, + (select coalesce(count(*), 0)::int from pg_replication_slots + where slot_type = 'physical') as phys_slots, + (select coalesce(count(*) filter (where active), 0)::int + from pg_replication_slots where slot_type = 'physical') as phys_slots_active, + (select coalesce(count(*), 0)::int from pg_replication_slots + where slot_type = 'logical') as log_slots, + (select coalesce(count(*) filter (where active), 0)::int + from pg_replication_slots where slot_type = 'logical') as log_slots_active + from pg_stat_activity + where pid <> pg_backend_pid() +"; + +/// Activity view body — one row per non-rpg backend, with a count of +/// granted locks held per pid (left-joined from `pg_locks`). Ordered with +/// active backends first and longest-running queries on top. +/// +/// Portable across PG14–PG18: every column used here exists in PG14's +/// `pg_stat_activity`. Columns available in PG14 but excluded to keep S1 +/// minimal: `query_id`, `leader_pid`. They will be added when the +/// drill-down overlay (S4) needs them. +pub const ACTIVITY_SQL: &str = " + with locks as ( + select pid, count(*)::bigint as n + from pg_locks + where granted + group by pid + ) + select + a.pid, + coalesce(a.usename, '') as usename, + coalesce(a.datname, '') as datname, + coalesce(a.application_name, '') as application_name, + coalesce(a.client_addr::text, '') as client_addr, + coalesce(a.backend_type, '') as backend_type, + coalesce(a.state, '') as state, + coalesce(a.wait_event_type, '') as wait_event_type, + coalesce(a.wait_event, '') as wait_event, + case + when a.query_start is null then null + else extract(epoch from (now() - a.query_start))::float8 + end as qtime_secs, + case + when a.xact_start is null then null + else extract(epoch from (now() - a.xact_start))::float8 + end as xtime_secs, + coalesce(left(a.query, 500), '') as query, + coalesce(l.n, 0)::bigint as locks_held + from pg_stat_activity as a + left join locks as l using (pid) + where a.pid <> pg_backend_pid() + order by + case a.state when 'active' then 0 else 1 end, + qtime_secs desc nulls last, + a.pid +"; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn summary_sql_mentions_required_columns() { + for col in [ + "db_name", + "usename", + "pg_version", + "uptime_secs", + "in_recovery", + "active", + "idle_in_tx", + "waiting", + "total_backends", + "max_connections", + "longest_xact_secs", + "longest_active_query_secs", + "deadlocks_total", + "temp_files_total", + "autovacuum_busy", + "autovacuum_max", + "phys_slots", + "phys_slots_active", + "log_slots", + "log_slots_active", + ] { + assert!( + SUMMARY_SQL.contains(col), + "summary SQL is missing column {col}" + ); + } + } + + #[test] + fn summary_sql_excludes_self_backend() { + assert!( + SUMMARY_SQL.contains("pid <> pg_backend_pid()"), + "summary SQL must exclude the rpg monitor backend itself", + ); + } + + #[test] + fn activity_sql_excludes_self_backend() { + assert!( + ACTIVITY_SQL.contains("pid <> pg_backend_pid()"), + "activity SQL must exclude the rpg monitor backend itself" + ); + } + + #[test] + fn activity_sql_orders_active_first_then_qtime_desc() { + assert!(ACTIVITY_SQL.contains("case a.state when 'active' then 0 else 1 end")); + assert!(ACTIVITY_SQL.contains("qtime_secs desc nulls last")); + } + + #[test] + fn activity_sql_includes_locks_held_column() { + assert!( + ACTIVITY_SQL.contains("as locks_held"), + "activity SQL must include the locks_held column", + ); + assert!( + ACTIVITY_SQL.contains("from pg_locks"), + "locks_held must come from pg_locks", + ); + assert!( + ACTIVITY_SQL.contains("where granted"), + "only granted locks should be counted", + ); + } + + /// Regression test for the deserialization bug surfaced during S1 + /// manual testing: tokio-postgres cannot decode Postgres `numeric` + /// directly into Rust `f64`; the cast must be `::float8`. + #[test] + fn elapsed_time_columns_cast_to_float8() { + assert!( + ACTIVITY_SQL.contains("(now() - a.query_start))::float8"), + "qtime_secs must be cast to float8" + ); + assert!( + ACTIVITY_SQL.contains("(now() - a.xact_start))::float8"), + "xtime_secs must be cast to float8" + ); + } +} diff --git a/src/top/state.rs b/src/top/state.rs new file mode 100644 index 00000000..a30183c2 --- /dev/null +++ b/src/top/state.rs @@ -0,0 +1,1482 @@ +//! `/top` UI state — pure data, no I/O. +//! +//! All UI rendering reads from this struct; it is the single source of +//! truth. Later sprints will extend `View`, add filter/sort, and grow the +//! ring buffer for pause-and-rewind. +//! +//! S1 keys handled by [`App::handle_key`]: +//! - `q`, `Esc`, `Ctrl-C` — exit (Esc cancels an open prompt first) +//! - `Up` / `Down` — move row cursor (sticky-header scroll) +//! - `PageUp` / `PageDown` — jump cursor by page +//! - `Home` / `End` — first / last row +//! - `Space` — force an immediate sampler tick +//! - `←` / `→` (also `<` / `>`) — cycle active sort column +//! - `r` — reverse sort direction +//! - `e` — toggle extended columns (app/client/backend) +//! - `s` — set refresh delay (prompt 0.1–60 s) +//! - `k` / `K` — cancel / terminate selected backend (footer y/N confirm) +//! +//! When a prompt is open, every other key feeds it (digits, `.`, Backspace, +//! Enter to apply, Esc to cancel). When the kill confirm is open, `y`/`Y` +//! fires; anything else cancels. + +use std::time::{Duration, Instant}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// A single backend row drawn in the Activity view. +/// +/// Field types mirror what the sampler decodes from `pg_stat_activity`. We +/// keep numbers as native widths so renderer and tests can reason about them +/// without re-parsing. +#[derive(Debug, Clone, Default)] +pub struct ActivityRow { + pub pid: i32, + pub usename: String, + pub datname: String, + pub application_name: String, + pub client_addr: String, + pub backend_type: String, + pub state: String, + /// `wait_event_type` from `pg_stat_activity` (e.g. `"IO"`, `"Lock"`, + /// `"LWLock"`). Empty when the backend is on CPU. + pub wait_event_type: String, + pub wait_event: String, + /// Seconds elapsed since `query_start`. `None` when the backend is idle. + pub qtime_secs: Option, + /// Seconds elapsed since `xact_start`. `None` when the backend is idle. + pub xtime_secs: Option, + /// Trimmed query text (already truncated by the sampler to keep snapshots + /// small). The renderer truncates further to the available column width. + pub query: String, + /// Number of granted locks held by this backend (count from `pg_locks`). + /// Includes virtualxid / transactionid locks every active backend has. + pub locks_held: i64, +} + +/// Server-wide summary drawn in the header bar. +#[derive(Debug, Clone, Default)] +pub struct ServerSummary { + pub db_name: String, + pub user: String, + pub pg_version: String, + /// Uptime in seconds since postmaster start. + pub uptime_secs: i64, + pub in_recovery: bool, + pub active: u32, + pub idle_in_tx: u32, + pub waiting: u32, + pub total_backends: u32, + pub max_connections: u32, + /// Age of the longest-running open transaction (any state). 0 when no + /// backend is in a transaction. + pub longest_xact_secs: f64, + /// Age of the longest-running *active* query (excludes idle backends). + /// 0 when no backend is currently running a query. + pub longest_active_query_secs: f64, + /// Cumulative deadlocks across every database in the cluster (sum of + /// `pg_stat_database.deadlocks`). + pub deadlocks_total: i64, + /// Cumulative temp file count across every database in the cluster. + pub temp_files_total: i64, + /// Number of backends with `backend_type = 'autovacuum worker'`. + pub autovacuum_busy: u32, + /// `current_setting('autovacuum_max_workers')` — slot ceiling. + pub autovacuum_max: u32, + /// Physical replication slot counts (active out of total). + pub phys_slots: u32, + pub phys_slots_active: u32, + /// Logical replication slot counts. + pub log_slots: u32, + pub log_slots_active: u32, +} + +/// One sample tick of data; what the sampler produces and the renderer reads. +#[derive(Debug, Clone, Default)] +pub struct Snapshot { + /// Unix timestamp (seconds) when the sample was taken. + pub ts: i64, + pub server: ServerSummary, + pub rows: Vec, +} + +/// Top-level view selector. S1 ships only `Activity`; later sprints add the +/// remaining pgcenter-style views (databases, tables, indexes, statements, +/// replication, progress, wal, functions, blocking). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum View { + #[default] + Activity, +} + +impl View { + pub const fn label(self) -> &'static str { + match self { + Self::Activity => "Activity", + } + } +} + +/// Footer prompt state. `s` opens the refresh-delay prompt; later sprints +/// will reuse this struct for the filter (`/`) and sort (`o`) prompts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PromptState { + pub kind: PromptKind, + pub buffer: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PromptKind { + /// Set refresh interval in seconds. + Refresh, +} + +impl PromptKind { + pub const fn label(self) -> &'static str { + match self { + Self::Refresh => "delay (secs)", + } + } +} + +/// Action requested via `k` (cancel) or `K` (terminate). Cancel sends +/// `pg_cancel_backend` (signals the running query); Terminate sends +/// `pg_terminate_backend` (closes the connection — heavier weapon). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KillMode { + Cancel, + Terminate, +} + +impl KillMode { + pub const fn verb_upper(self) -> &'static str { + match self { + Self::Cancel => "CANCEL", + Self::Terminate => "TERMINATE", + } + } + + pub const fn pg_function(self) -> &'static str { + match self { + Self::Cancel => "pg_cancel_backend", + Self::Terminate => "pg_terminate_backend", + } + } +} + +/// Snapshot of an [`ActivityRow`] taken at the moment `k`/`K` was pressed. +/// Carrying the row data through the confirmation cycle means the prompt +/// describes the exact backend the user clicked on, even if the table +/// re-sorts under them between the keystroke and the `y` confirmation. +#[derive(Debug, Clone)] +pub struct KillRequest { + pub mode: KillMode, + pub pid: i32, + pub usename: String, + pub datname: String, + pub state: String, + pub qtime_secs: Option, + pub query_summary: String, +} + +impl KillRequest { + /// Convenience: forward the mode's PG function name so callers + /// can build the SQL without re-matching on `KillMode`. + pub const fn pg_function_for_request(&self) -> &'static str { + self.mode.pg_function() + } + + fn from_row(mode: KillMode, row: &ActivityRow) -> Self { + let mut summary: String = row.query.split_whitespace().collect::>().join(" "); + if summary.chars().count() > 60 { + summary = summary.chars().take(59).collect::(); + summary.push('…'); + } + Self { + mode, + pid: row.pid, + usename: row.usename.clone(), + datname: row.datname.clone(), + state: row.state.clone(), + qtime_secs: row.qtime_secs, + query_summary: summary, + } + } +} + +/// Default refresh interval in seconds when neither the CLI flag nor the +/// runtime prompt overrides it. +pub const DEFAULT_REFRESH_SECS: f64 = 1.0; + +/// Lower / upper bounds for the refresh prompt. 100 ms keeps the sampler +/// from monopolising the connection; 60 s avoids the user accidentally +/// disabling refresh entirely. +pub const MIN_REFRESH_SECS: f64 = 0.1; +pub const MAX_REFRESH_SECS: f64 = 60.0; + +/// How long a key-press overlay stays visible after the keystroke. Long +/// enough that a viewer of a recorded demo can read the label; short +/// enough to feel ephemeral during interactive use. +pub const KEY_OVERLAY_TTL: Duration = Duration::from_millis(1_200); + +/// Sortable columns in the Activity view. Cycled left/right with `<` / +/// `>`; direction toggled with `r`. Default = `Qtime` descending (matches +/// the SQL `order by` and what an operator usually wants in an incident). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SortColumn { + Pid, + User, + Db, + State, + Wait, + Qtime, + Xtime, + Locks, + Query, +} + +impl SortColumn { + /// Stable canonical ordering used by the `<` / `>` cyclers. + pub const ALL: &'static [Self] = &[ + Self::Pid, + Self::User, + Self::Db, + Self::State, + Self::Wait, + Self::Qtime, + Self::Xtime, + Self::Locks, + Self::Query, + ]; + + /// Lower-case header label that appears in the table. + pub const fn header_label(self) -> &'static str { + match self { + Self::Pid => "pid", + Self::User => "user", + Self::Db => "db", + Self::State => "state", + Self::Wait => "wait", + Self::Qtime => "qtime", + Self::Xtime => "xtime", + Self::Locks => "locks", + Self::Query => "query", + } + } + + /// Default sort direction when this column is first selected. Most + /// numeric / time columns are most useful in descending order + /// (longest first); textual columns are most useful ascending (A→Z). + pub const fn default_desc(self) -> bool { + matches!(self, Self::Qtime | Self::Xtime | Self::Locks | Self::Pid) + } + + fn position(self) -> usize { + Self::ALL + .iter() + .position(|s| *s == self) + .expect("ALL contains every SortColumn variant") + } + + /// Step `delta` positions through `Self::ALL` (wrapping). `delta` is + /// signed so callers can pass `-1` (`<`) or `+1` (`>`); we lift the + /// modular arithmetic into `usize` after offsetting by `len` to avoid + /// any signed-cast lossiness clippy might flag. + fn step(self, delta: isize) -> Self { + let len = Self::ALL.len(); + let pos = self.position(); + let stepped = if delta >= 0 { + #[allow(clippy::cast_sign_loss)] + let d = delta as usize % len; + (pos + d) % len + } else { + #[allow(clippy::cast_sign_loss)] + let d = (-delta) as usize % len; + (pos + len - d) % len + }; + Self::ALL[stepped] + } +} + +/// Ephemeral on-screen overlay displaying the most recent keystroke. +/// Surfaces in the corner of the body area for [`KEY_OVERLAY_TTL`] so a +/// viewer (especially of a recorded demo) can tell what was pressed. +#[derive(Debug, Clone)] +pub struct KeyOverlay { + pub label: String, + pub expires_at: Instant, +} + +/// UI state for `/top`. Pure data — no I/O, no side effects. +/// +/// The `#[allow]` covers the half-dozen independent UI flags (`extended`, +/// `sort_desc`, `force_refresh`, `should_exit`, …). They are orthogonal +/// one-shot signals, not states of a state machine, so collapsing them +/// into an enum would cost clarity for no benefit. +#[derive(Debug)] +#[allow(clippy::struct_excessive_bools)] +pub struct App { + pub view: View, + pub snapshot: Option, + /// Index into `snapshot.rows`; clamped on every render to remain valid + /// when row counts shrink between ticks. + pub selected_row: usize, + /// Last sampler error message, surfaced in the footer until the next + /// successful tick clears it. Kept short — multi-line errors are + /// truncated by the renderer. + pub last_error: Option, + /// Number of consecutive sampler ticks that timed out. Surfaces a brief + /// "stale Ns" badge in the header until a fresh tick lands. + pub stale_ticks: u32, + /// Set by [`App::handle_key`] when the user requests exit. + pub should_exit: bool, + /// Sampler refresh interval in seconds. + pub refresh_secs: f64, + /// When `true`, the activity table renders `app`, `client`, and + /// `backend` columns in addition to the default set. Toggled by `e`. + pub extended: bool, + /// Footer prompt state. `Some` when the user is typing into a prompt. + pub prompt: Option, + /// Currently active sort column; `<` / `>` cycle, `r` toggles. + pub sort_column: SortColumn, + /// `true` when sorting descending (largest first); toggled by `r`. + pub sort_desc: bool, + /// When `true`, every keystroke seeds [`App::last_key`] so the + /// renderer can draw a temporary key-press overlay. Off by default — + /// it's a recording aid (used by `demos/top-demo.tape` via + /// `--show-keys`), not something an interactive user wants flashing + /// in their face. + pub show_keys: bool, + /// Most recent keystroke and its overlay expiration time. Populated + /// only when `show_keys` is on. + pub last_key: Option, + /// Set by `Space` to ask the event loop to break out of its poll + /// window and run a sampler tick immediately (matches `top`'s + /// space-bar behaviour). Cleared by the loop after the forced tick. + pub force_refresh: bool, + /// Pending `k`/`K` confirmation. While `Some`, the footer shows a + /// y/n prompt instead of the default hint line. + pub kill_confirm: Option, + /// Approved kill that the event loop will execute on its next pass. + /// Cleared after the SQL is dispatched. Separate from `kill_confirm` + /// so `handle_key` (sync, no I/O) can hand the action off to the loop. + pub kill_pending: Option, + /// Last admin-action result, surfaced briefly in the footer until + /// the next sampler tick (or 5 s, whichever is sooner). + pub admin_message: Option, +} + +/// Result of a kill action, surfaced in the footer. +#[derive(Debug, Clone)] +pub struct AdminMessage { + pub text: String, + pub level: AdminMessageLevel, + pub expires_at: Instant, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AdminMessageLevel { + Ok, + Err, +} + +impl Default for App { + fn default() -> Self { + Self { + view: View::default(), + snapshot: None, + selected_row: 0, + last_error: None, + stale_ticks: 0, + should_exit: false, + refresh_secs: DEFAULT_REFRESH_SECS, + extended: false, + prompt: None, + sort_column: SortColumn::Qtime, + sort_desc: true, + show_keys: false, + last_key: None, + force_refresh: false, + kill_confirm: None, + kill_pending: None, + admin_message: None, + } + } +} + +impl App { + pub fn new() -> Self { + Self::default() + } + + /// Number of visible rows in the active view. + pub fn row_count(&self) -> usize { + self.snapshot.as_ref().map_or(0, |snap| snap.rows.len()) + } + + /// Move the cursor up by `n`, saturating at 0. + pub fn cursor_up(&mut self, n: usize) { + self.selected_row = self.selected_row.saturating_sub(n); + } + + /// Move the cursor down by `n`, clamped to `row_count() - 1`. + pub fn cursor_down(&mut self, n: usize) { + let max = self.row_count().saturating_sub(1); + self.selected_row = self.selected_row.saturating_add(n).min(max); + } + + pub fn cursor_home(&mut self) { + self.selected_row = 0; + } + + pub fn cursor_end(&mut self) { + self.selected_row = self.row_count().saturating_sub(1); + } + + /// Clamp `selected_row` to be a valid index for the current row count. + /// Called after every snapshot replace so the renderer never reads OOB. + pub fn clamp_cursor(&mut self) { + let max = self.row_count().saturating_sub(1); + if self.selected_row > max { + self.selected_row = max; + } + } + + /// Replace the current snapshot and reset stale-tick counter. + pub fn set_snapshot(&mut self, snap: Snapshot) { + self.snapshot = Some(snap); + self.stale_ticks = 0; + self.last_error = None; + self.clamp_cursor(); + } + + pub fn note_stale(&mut self) { + self.stale_ticks = self.stale_ticks.saturating_add(1); + } + + pub fn note_error(&mut self, msg: String) { + self.last_error = Some(msg); + } + + /// Open the refresh-delay prompt seeded with the current value. + pub fn open_refresh_prompt(&mut self) { + self.prompt = Some(PromptState { + kind: PromptKind::Refresh, + buffer: format_refresh_seed(self.refresh_secs), + }); + } + + /// Apply the currently open prompt, parsing its buffer and updating the + /// corresponding setting. Out-of-range or unparseable input is ignored + /// (the prompt closes either way). + pub fn apply_prompt(&mut self) { + if let Some(prompt) = self.prompt.take() { + match prompt.kind { + PromptKind::Refresh => { + if let Ok(n) = prompt.buffer.parse::() { + if (MIN_REFRESH_SECS..=MAX_REFRESH_SECS).contains(&n) { + self.refresh_secs = n; + } + } + } + } + } + } + + /// Process a key event. Returns `true` when the caller should exit the + /// event loop. The exit signal is also stored in `should_exit` so + /// renderers/tests can observe it. + pub fn handle_key(&mut self, key: KeyEvent, page_size: usize) -> bool { + // Record the keystroke for the corner overlay before any branch + // returns. Off unless --show-keys is set (recording aid only). + // Suppressed inside the prompt so each typed digit does not + // flash in the overlay — the prompt buffer is the indicator. + if self.show_keys && self.prompt.is_none() { + self.note_key(&key); + } + + // Active prompt swallows almost every key. + if self.prompt.is_some() { + self.handle_prompt_key(key); + return self.should_exit; + } + + // Kill confirmation: only y / Y fire, anything else cancels. + if self.kill_confirm.is_some() { + self.handle_kill_confirm_key(key); + return self.should_exit; + } + + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Char('q') | KeyCode::Esc => { + self.should_exit = true; + } + KeyCode::Char('c') if ctrl => { + self.should_exit = true; + } + KeyCode::Up => self.cursor_up(1), + KeyCode::Down => self.cursor_down(1), + KeyCode::PageUp => self.cursor_up(page_size.max(1)), + KeyCode::PageDown => self.cursor_down(page_size.max(1)), + KeyCode::Home => self.cursor_home(), + KeyCode::End => self.cursor_end(), + // Vim-style cursor; intentionally no `j` because `k` is the + // pg_cancel_backend trigger and a Vim user would expect both + // letters bound together. Down arrow + j-disabled is the + // safer call. + KeyCode::Char('s') => self.open_refresh_prompt(), + KeyCode::Char('e') => self.extended = !self.extended, + KeyCode::Char('<' | ',') | KeyCode::Left => self.cycle_sort(-1), + KeyCode::Char('>' | '.') | KeyCode::Right => self.cycle_sort(1), + KeyCode::Char('r') => self.sort_desc = !self.sort_desc, + KeyCode::Char(' ') => self.force_refresh = true, + KeyCode::Char('k') => self.request_kill(KillMode::Cancel), + KeyCode::Char('K') => self.request_kill(KillMode::Terminate), + _ => {} + } + self.should_exit + } + + fn handle_kill_confirm_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('y' | 'Y') => { + self.kill_pending = self.kill_confirm.take(); + } + // n / N / Esc / anything else → cancel without firing. + _ => self.kill_confirm = None, + } + } + + fn request_kill(&mut self, mode: KillMode) { + let Some(snap) = self.snapshot.as_ref() else { + return; + }; + // The renderer sorts a fresh slice via `sort_rows` and the + // selection cursor indexes the *sorted* view, so the kill + // target has to come from the same sorted slice — otherwise + // pressing `k`/`K` confirms a different pid than the one + // highlighted (REV round-11 blocking finding). + let mut sorted: Vec<&ActivityRow> = snap.rows.iter().collect(); + crate::top::views::activity::sort_rows(&mut sorted, self.sort_column, self.sort_desc); + let Some(&row) = sorted.get(self.selected_row) else { + return; + }; + // Don't try to kill background workers / non-client backends. + if row.pid <= 0 { + return; + } + self.kill_confirm = Some(KillRequest::from_row(mode, row)); + } + + /// Cycle the active sort column by `delta` positions (left = -1, + /// right = +1). Direction resets to the column's default each time + /// the column itself changes; `r` toggles direction without moving. + pub fn cycle_sort(&mut self, delta: isize) { + let next = self.sort_column.step(delta); + if next != self.sort_column { + self.sort_column = next; + self.sort_desc = next.default_desc(); + } + } + + fn note_key(&mut self, key: &KeyEvent) { + let label = format_key_label(key); + if label.is_empty() { + return; + } + self.last_key = Some(KeyOverlay { + label, + expires_at: Instant::now() + KEY_OVERLAY_TTL, + }); + } + + /// Read the key overlay if it is still fresh. Renderers call this + /// instead of touching `last_key` directly so the staleness check + /// stays in one place. + pub fn fresh_key_overlay(&self) -> Option<&KeyOverlay> { + self.last_key + .as_ref() + .filter(|ko| Instant::now() < ko.expires_at) + } + + fn handle_prompt_key(&mut self, key: KeyEvent) { + let Some(prompt) = self.prompt.as_mut() else { + return; + }; + match key.code { + KeyCode::Esc => { + self.prompt = None; + } + KeyCode::Enter => self.apply_prompt(), + KeyCode::Backspace => { + prompt.buffer.pop(); + } + // Reasonable upper bound on prompt length keeps the buffer bounded. + KeyCode::Char(c) if (c.is_ascii_digit() || c == '.') && prompt.buffer.len() < 10 => { + prompt.buffer.push(c); + } + _ => {} + } + } +} + +fn format_refresh_seed(secs: f64) -> String { + if (secs - secs.round()).abs() < 0.0005 { + format!("{secs:.0}") + } else { + format!("{secs:.2}") + } +} + +/// Default `--ts-format` for `--batch` output. Plain ISO 8601 UTC, +/// chosen to be unambiguous for log files and grep-friendly. +pub const DEFAULT_TS_FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ"; + +/// Calendar date / time-of-day broken out of a Unix timestamp (UTC). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CalDate { + pub year: i64, + pub month: u8, + pub day: u8, + pub hour: u8, + pub minute: u8, + pub second: u8, +} + +/// Convert Unix `secs` (UTC) into a calendar date. Uses Howard Hinnant's +/// `civil_from_days` algorithm so we avoid pulling chrono in for `--batch` +/// timestamp formatting. +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +pub fn cal_date_utc(unix_secs: i64) -> CalDate { + let day_secs = unix_secs.rem_euclid(86_400); + let days = unix_secs.div_euclid(86_400); + + let hour = (day_secs / 3600) as u8; + let minute = ((day_secs % 3600) / 60) as u8; + let second = (day_secs % 60) as u8; + + // Howard Hinnant — http://howardhinnant.github.io/date_algorithms.html + let z = days + 719_468; + let era = if z >= 0 { + z / 146_097 + } else { + (z - 146_096) / 146_097 + }; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let mut year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = (doy - (153 * mp + 2) / 5 + 1) as u8; + let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u8; + if month <= 2 { + year += 1; + } + + CalDate { + year, + month, + day, + hour, + minute, + second, + } +} + +/// Format a Unix timestamp using a tiny strftime subset: +/// +/// | Token | Expansion | +/// |-------|------------------------| +/// | `%Y` | 4-digit year | +/// | `%m` | 2-digit month | +/// | `%d` | 2-digit day | +/// | `%H` | 2-digit hour (24h) | +/// | `%M` | 2-digit minute | +/// | `%S` | 2-digit second | +/// | `%T` | `HH:MM:SS` | +/// | `%F` | `YYYY-MM-DD` | +/// | `%s` | unix seconds since epoch | +/// | `%z` | `+0000` (always UTC) | +/// | `%Z` | `UTC` | +/// | `%%` | literal `%` | +/// +/// Unknown specifiers pass through verbatim (`%X` → `%X`) so a typo is +/// visible in the output rather than silently swallowed. +pub fn format_strftime(fmt: &str, unix_secs: i64) -> String { + use std::fmt::Write; + + let cal = cal_date_utc(unix_secs); + let mut out = String::with_capacity(fmt.len()); + let mut chars = fmt.chars(); + while let Some(c) = chars.next() { + if c != '%' { + out.push(c); + continue; + } + match chars.next() { + Some('Y') => write!(out, "{:04}", cal.year).expect("write to String"), + Some('m') => write!(out, "{:02}", cal.month).expect("write to String"), + Some('d') => write!(out, "{:02}", cal.day).expect("write to String"), + Some('H') => write!(out, "{:02}", cal.hour).expect("write to String"), + Some('M') => write!(out, "{:02}", cal.minute).expect("write to String"), + Some('S') => write!(out, "{:02}", cal.second).expect("write to String"), + Some('T') => write!(out, "{:02}:{:02}:{:02}", cal.hour, cal.minute, cal.second) + .expect("write to String"), + Some('F') => write!(out, "{:04}-{:02}-{:02}", cal.year, cal.month, cal.day) + .expect("write to String"), + Some('s') => write!(out, "{unix_secs}").expect("write to String"), + Some('z') => out.push_str("+0000"), + Some('Z') => out.push_str("UTC"), + // A literal `%%` and a trailing bare `%` both render as one `%`. + Some('%') | None => out.push('%'), + Some(other) => { + out.push('%'); + out.push(other); + } + } + } + out +} + +/// Compact human label for a key press, used by the corner overlay. +/// +/// Modifier handling: +/// - `Shift` is mostly implicit: typing 'K' already arrives as +/// `Char('K')` with the SHIFT modifier set, so we don't add a +/// visible "⇧" prefix for the common case. Exception: `BackTab` +/// (Shift-Tab) gets an explicit "⇧Tab" label since the key code +/// itself encodes the modifier. +/// - `Ctrl` / `Alt` / `Super` (Meta on macOS / Windows key) are +/// prepended in `top`-style abbreviations: `C-`, `M-`, `S-`. They +/// stack: `Ctrl-Alt-X` renders as `C-M-X`. +/// - Crossterm exposes the modifier on most terminals; macOS +/// terminals routinely strip Alt/Super, so those badges won't +/// fire there — that's a terminal limitation, not ours. +pub fn format_key_label(key: &KeyEvent) -> String { + let mods = key.modifiers; + let mut prefix = String::new(); + if mods.contains(KeyModifiers::CONTROL) { + prefix.push_str("C-"); + } + if mods.contains(KeyModifiers::ALT) { + prefix.push_str("M-"); + } + if mods.contains(KeyModifiers::SUPER) { + prefix.push_str("S-"); + } + + let body = match key.code { + KeyCode::Up => "↑".to_owned(), + KeyCode::Down => "↓".to_owned(), + KeyCode::Left => "←".to_owned(), + KeyCode::Right => "→".to_owned(), + KeyCode::PageUp => "PgUp".to_owned(), + KeyCode::PageDown => "PgDn".to_owned(), + KeyCode::Home => "Home".to_owned(), + KeyCode::End => "End".to_owned(), + KeyCode::Tab => "Tab".to_owned(), + KeyCode::BackTab => "⇧Tab".to_owned(), + KeyCode::Enter => "↩".to_owned(), + KeyCode::Esc => "Esc".to_owned(), + KeyCode::Backspace => "⌫".to_owned(), + KeyCode::Delete => "⌦".to_owned(), + KeyCode::Char(c) => c.to_string(), + _ => return String::new(), + }; + format!("{prefix}{body}") +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn snap_with(n: usize) -> Snapshot { + Snapshot { + ts: 1_700_000_000, + server: ServerSummary { + db_name: "prod".into(), + user: "nik".into(), + pg_version: "16.4".into(), + ..Default::default() + }, + rows: (0..n) + .map(|i| ActivityRow { + #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] + pid: 10_000 + i as i32, + usename: "app".into(), + datname: "prod".into(), + state: "active".into(), + query: format!("select {i}"), + ..Default::default() + }) + .collect(), + } + } + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn ctrl(c: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL) + } + + #[test] + fn new_app_has_empty_snapshot_and_zero_cursor() { + let app = App::new(); + assert!(app.snapshot.is_none()); + assert_eq!(app.selected_row, 0); + assert_eq!(app.row_count(), 0); + assert!(!app.should_exit); + assert_eq!(app.view, View::Activity); + assert!((app.refresh_secs - DEFAULT_REFRESH_SECS).abs() < f64::EPSILON); + assert!(!app.extended); + assert!(app.prompt.is_none()); + } + + #[test] + fn q_esc_and_ctrl_c_request_exit() { + for k in [key(KeyCode::Char('q')), key(KeyCode::Esc), ctrl('c')] { + let mut app = App::new(); + assert!(app.handle_key(k, 10)); + assert!(app.should_exit); + } + } + + #[test] + fn cursor_navigation_clamps_to_bounds() { + let mut app = App::new(); + app.set_snapshot(snap_with(5)); + + app.handle_key(key(KeyCode::Down), 10); + assert_eq!(app.selected_row, 1); + for _ in 0..50 { + app.handle_key(key(KeyCode::Down), 10); + } + assert_eq!(app.selected_row, 4); + for _ in 0..50 { + app.handle_key(key(KeyCode::Up), 10); + } + assert_eq!(app.selected_row, 0); + + app.handle_key(key(KeyCode::End), 10); + assert_eq!(app.selected_row, 4); + app.handle_key(key(KeyCode::Home), 10); + assert_eq!(app.selected_row, 0); + } + + #[test] + fn page_up_down_uses_provided_page_size() { + let mut app = App::new(); + app.set_snapshot(snap_with(20)); + app.handle_key(key(KeyCode::PageDown), 7); + assert_eq!(app.selected_row, 7); + app.handle_key(key(KeyCode::PageDown), 7); + assert_eq!(app.selected_row, 14); + app.handle_key(key(KeyCode::PageUp), 5); + assert_eq!(app.selected_row, 9); + } + + #[test] + fn k_no_longer_aliases_cursor_up() { + // `k` is now the cancel-backend trigger (matches top), no longer + // a vim-style cursor movement. Make sure the binding swap really + // happened so future refactors don't quietly bring vim-`k` back + // and then surprise an operator with an accidental cancellation. + let mut app = App::new(); + app.set_snapshot(snap_with(3)); + app.handle_key(key(KeyCode::Down), 10); + app.handle_key(key(KeyCode::Down), 10); + assert_eq!(app.selected_row, 2); + app.handle_key(key(KeyCode::Char('k')), 10); + // Cursor unchanged; `k` opened a confirm prompt instead. + assert_eq!(app.selected_row, 2); + assert!(app.kill_confirm.is_some()); + } + + #[test] + fn snapshot_replace_clamps_cursor() { + let mut app = App::new(); + app.set_snapshot(snap_with(10)); + app.handle_key(key(KeyCode::End), 10); + assert_eq!(app.selected_row, 9); + + app.set_snapshot(snap_with(3)); + assert_eq!(app.selected_row, 2); + + app.set_snapshot(snap_with(0)); + assert_eq!(app.selected_row, 0); + } + + #[test] + fn note_stale_increments_and_set_snapshot_clears() { + let mut app = App::new(); + app.note_stale(); + app.note_stale(); + assert_eq!(app.stale_ticks, 2); + app.set_snapshot(snap_with(1)); + assert_eq!(app.stale_ticks, 0); + assert!(app.last_error.is_none()); + } + + #[test] + fn note_error_round_trip() { + let mut app = App::new(); + app.note_error("boom".into()); + assert_eq!(app.last_error.as_deref(), Some("boom")); + } + + #[test] + fn unknown_keys_are_no_ops() { + let mut app = App::new(); + app.set_snapshot(snap_with(3)); + let exit = app.handle_key(key(KeyCode::Char('z')), 10); + assert!(!exit); + assert_eq!(app.selected_row, 0); + } + + #[test] + fn view_label_is_user_visible_text() { + assert_eq!(View::Activity.label(), "Activity"); + } + + #[test] + fn e_toggles_extended_columns() { + let mut app = App::new(); + assert!(!app.extended); + app.handle_key(key(KeyCode::Char('e')), 10); + assert!(app.extended); + app.handle_key(key(KeyCode::Char('e')), 10); + assert!(!app.extended); + } + + #[test] + fn s_opens_refresh_prompt_seeded_with_current_value() { + let mut app = App::new(); + app.refresh_secs = 1.0; + app.handle_key(key(KeyCode::Char('s')), 10); + let prompt = app.prompt.as_ref().expect("prompt opened"); + assert_eq!(prompt.kind, PromptKind::Refresh); + assert_eq!(prompt.buffer, "1"); + + // The fractional seed format keeps two decimals. + app.prompt = None; + app.refresh_secs = 0.5; + app.handle_key(key(KeyCode::Char('s')), 10); + assert_eq!(app.prompt.as_ref().unwrap().buffer, "0.50"); + } + + #[test] + fn refresh_prompt_accepts_digits_and_dot() { + let mut app = App::new(); + app.open_refresh_prompt(); + // Seed is "1"; clear it then type 0.25 + for _ in 0..3 { + app.handle_key(key(KeyCode::Backspace), 10); + } + for c in "0.25".chars() { + app.handle_key(key(KeyCode::Char(c)), 10); + } + assert_eq!(app.prompt.as_ref().unwrap().buffer, "0.25"); + + // Letters are rejected. + app.handle_key(key(KeyCode::Char('x')), 10); + assert_eq!(app.prompt.as_ref().unwrap().buffer, "0.25"); + + app.handle_key(key(KeyCode::Enter), 10); + assert!(app.prompt.is_none()); + assert!((app.refresh_secs - 0.25).abs() < f64::EPSILON); + } + + #[test] + fn refresh_prompt_clamps_out_of_range_input() { + let mut app = App::new(); + let original = app.refresh_secs; + app.open_refresh_prompt(); + for _ in 0..app.prompt.as_ref().unwrap().buffer.len() { + app.handle_key(key(KeyCode::Backspace), 10); + } + for c in "999".chars() { + app.handle_key(key(KeyCode::Char(c)), 10); + } + app.handle_key(key(KeyCode::Enter), 10); + // 999 > MAX → ignored. + assert!((app.refresh_secs - original).abs() < f64::EPSILON); + } + + #[test] + fn esc_cancels_prompt_without_changing_setting() { + let mut app = App::new(); + let original = app.refresh_secs; + app.open_refresh_prompt(); + for c in "0.5".chars() { + app.handle_key(key(KeyCode::Char(c)), 10); + } + // Esc closes the prompt without applying. + assert!(!app.handle_key(key(KeyCode::Esc), 10)); + assert!(app.prompt.is_none()); + assert!((app.refresh_secs - original).abs() < f64::EPSILON); + // First Esc closed the prompt; second Esc exits. + assert!(app.handle_key(key(KeyCode::Esc), 10)); + } + + #[test] + fn exit_keys_swallowed_while_prompt_open() { + // q while the prompt is open is treated as a typed character (rejected + // because it is not a digit or dot), not as an exit signal. + let mut app = App::new(); + app.open_refresh_prompt(); + let exit = app.handle_key(key(KeyCode::Char('q')), 10); + assert!(!exit); + assert!(app.prompt.is_some()); + } + + // -- Sort cycling --------------------------------------------------------- + + #[test] + fn default_sort_is_qtime_descending() { + let app = App::new(); + assert_eq!(app.sort_column, SortColumn::Qtime); + assert!(app.sort_desc); + } + + #[test] + fn gt_advances_sort_column_lt_rewinds() { + let mut app = App::new(); + // Default = Qtime; > goes to Xtime. + app.handle_key(key(KeyCode::Char('>')), 10); + assert_eq!(app.sort_column, SortColumn::Xtime); + app.handle_key(key(KeyCode::Char('>')), 10); + assert_eq!(app.sort_column, SortColumn::Locks); + app.handle_key(key(KeyCode::Char('<')), 10); + assert_eq!(app.sort_column, SortColumn::Xtime); + } + + #[test] + fn sort_cycler_wraps_around() { + let mut app = App::new(); + // From Qtime, two `<` lands on Wait then State (relative). + // From Pid, one `<` wraps to Query (the last column). + app.sort_column = SortColumn::Pid; + app.handle_key(key(KeyCode::Char('<')), 10); + assert_eq!(app.sort_column, SortColumn::Query); + app.handle_key(key(KeyCode::Char('>')), 10); + assert_eq!(app.sort_column, SortColumn::Pid); + } + + #[test] + fn r_toggles_direction_only() { + let mut app = App::new(); + let col = app.sort_column; + let was_desc = app.sort_desc; + app.handle_key(key(KeyCode::Char('r')), 10); + assert_eq!(app.sort_column, col); + assert_eq!(app.sort_desc, !was_desc); + } + + // -- Kill confirmation --------------------------------------------------- + + fn snap_with_pid(pid: i32, query: &str) -> Snapshot { + Snapshot { + ts: 1, + server: ServerSummary::default(), + rows: vec![ActivityRow { + pid, + usename: "nik".into(), + datname: "prod".into(), + state: "active".into(), + query: query.into(), + ..Default::default() + }], + } + } + + #[test] + fn k_opens_cancel_confirmation_with_selected_row_data() { + let mut app = App::new(); + app.set_snapshot(snap_with_pid(1234, "select pg_sleep(60)")); + app.handle_key(key(KeyCode::Char('k')), 10); + let req = app.kill_confirm.as_ref().expect("k opens confirm"); + assert_eq!(req.mode, KillMode::Cancel); + assert_eq!(req.pid, 1234); + assert_eq!(req.usename, "nik"); + assert!(req.query_summary.starts_with("select pg_sleep")); + } + + #[test] + fn shift_k_opens_terminate_confirmation() { + let mut app = App::new(); + app.set_snapshot(snap_with_pid(1234, "select 1")); + app.handle_key(key(KeyCode::Char('K')), 10); + let req = app.kill_confirm.as_ref().expect("K opens confirm"); + assert_eq!(req.mode, KillMode::Terminate); + } + + #[test] + fn k_is_a_no_op_with_no_rows() { + let mut app = App::new(); + app.handle_key(key(KeyCode::Char('k')), 10); + assert!(app.kill_confirm.is_none()); + } + + #[test] + fn k_is_a_no_op_for_zero_pid_rows() { + // Background workers (checkpointer, autovacuum launcher, …) come + // back from pg_stat_activity with pid 0 in our fixture model. The + // confirm prompt must reject them rather than firing pg_cancel + // on pid 0. + let mut app = App::new(); + let mut snap = snap_with_pid(0, "(no query)"); + snap.rows[0].state.clear(); + app.set_snapshot(snap); + app.handle_key(key(KeyCode::Char('k')), 10); + assert!(app.kill_confirm.is_none()); + } + + #[test] + fn k_is_a_no_op_for_negative_pid_rows() { + // Walsender slots can surface with pid < 0. Same guard applies. + let mut app = App::new(); + app.set_snapshot(snap_with_pid(-1, "(no query)")); + app.handle_key(key(KeyCode::Char('k')), 10); + assert!(app.kill_confirm.is_none()); + } + + #[test] + fn y_promotes_confirm_to_pending() { + let mut app = App::new(); + app.set_snapshot(snap_with_pid(1234, "select 1")); + app.handle_key(key(KeyCode::Char('k')), 10); + assert!(app.kill_confirm.is_some()); + + app.handle_key(key(KeyCode::Char('y')), 10); + assert!(app.kill_confirm.is_none(), "confirm consumed"); + let pending = app.kill_pending.as_ref().expect("pending set"); + assert_eq!(pending.pid, 1234); + assert_eq!(pending.mode, KillMode::Cancel); + } + + #[test] + fn n_and_esc_cancel_kill_confirmation_without_firing() { + for cancel_key in [KeyCode::Char('n'), KeyCode::Char('N'), KeyCode::Esc] { + let mut app = App::new(); + app.set_snapshot(snap_with_pid(1234, "select 1")); + app.handle_key(key(KeyCode::Char('K')), 10); + assert!(app.kill_confirm.is_some()); + app.handle_key(key(cancel_key), 10); + assert!(app.kill_confirm.is_none()); + assert!(app.kill_pending.is_none()); + assert!( + !app.should_exit, + "Esc must not exit while kill prompt is open" + ); + } + } + + #[test] + fn k_targets_the_sorted_row_not_the_sql_row() { + // Two rows that disagree on SQL order vs `SortColumn::Pid`-asc + // order. The renderer sorts the slice in `views::activity` and + // the cursor indexes the *sorted* slice. `request_kill` must + // do the same — otherwise pressing `k` on the highlighted row + // confirms a kill against a different backend. + let mut app = App::new(); + let snap = Snapshot { + ts: 1, + server: ServerSummary::default(), + rows: vec![ + ActivityRow { + pid: 7777, + usename: "nik".into(), + datname: "prod".into(), + state: "active".into(), + query: "select pg_sleep(60)".into(), + ..Default::default() + }, + ActivityRow { + pid: 1111, + usename: "nik".into(), + datname: "prod".into(), + state: "active".into(), + query: "select 1".into(), + ..Default::default() + }, + ], + }; + app.set_snapshot(snap); + // Sort by Pid ascending. Sorted slice is now [1111, 7777]; + // SQL slice is still [7777, 1111]. + app.sort_column = SortColumn::Pid; + app.sort_desc = false; + // Cursor on the second row of the sorted slice → pid 7777. + app.selected_row = 1; + app.handle_key(key(KeyCode::Char('k')), 10); + let req = app + .kill_confirm + .as_ref() + .expect("k must open the confirm prompt"); + assert_eq!( + req.pid, 7777, + "kill confirm must target the sorted-slice row at selected_row, \ + not snap.rows[selected_row]" + ); + } + + #[test] + fn k_targets_sorted_row_under_qtime_sort() { + // Same invariant as k_targets_the_sorted_row_not_the_sql_row but + // under Qtime-descending. Row with higher qtime must be targeted. + let mut app = App::new(); + let snap = Snapshot { + ts: 1, + server: ServerSummary::default(), + rows: vec![ + ActivityRow { + pid: 100, + usename: "a".into(), + datname: "d".into(), + state: "active".into(), + qtime_secs: Some(1.0), + ..Default::default() + }, + ActivityRow { + pid: 999, + usename: "b".into(), + datname: "d".into(), + state: "active".into(), + qtime_secs: Some(60.0), + ..Default::default() + }, + ], + }; + app.set_snapshot(snap); + app.sort_column = SortColumn::Qtime; + app.sort_desc = true; // sorted: [999 (60s), 100 (1s)] + app.selected_row = 0; // cursor on the 60s row → pid 999 + app.handle_key(key(KeyCode::Char('k')), 10); + let req = app.kill_confirm.as_ref().expect("k must open confirm"); + assert_eq!(req.pid, 999, "cursor must target Qtime-sorted row"); + } + + #[test] + fn page_down_saturates_at_last_row() { + // PageDown with page_size > row_count must clamp at the last row, + // not wrap or panic. + let mut app = App::new(); + app.set_snapshot(snap_with(3)); // 3 rows + app.handle_key(key(KeyCode::PageDown), 10); // page_size 10 > 3 rows + assert_eq!( + app.selected_row, 2, + "PageDown past end must saturate at last row" + ); + } + + #[test] + fn sort_right_edge_wraps_from_query_to_pid() { + let mut app = App::new(); + app.sort_column = SortColumn::Query; // last column + app.handle_key(key(KeyCode::Char('>')), 10); + assert_eq!( + app.sort_column, + SortColumn::Pid, + "> from last column must wrap to first" + ); + } + + #[test] + fn set_snapshot_clears_last_error() { + let mut app = App::new(); + app.note_error("connection lost".into()); + assert!(app.last_error.is_some()); + app.set_snapshot(snap_with(1)); + assert!( + app.last_error.is_none(), + "set_snapshot must clear last_error" + ); + } + + #[test] + fn left_right_arrows_cycle_sort_column() { + let mut app = App::new(); + let start = app.sort_column; + app.handle_key(key(KeyCode::Right), 10); + assert_ne!(app.sort_column, start); + let after_right = app.sort_column; + app.handle_key(key(KeyCode::Left), 10); + assert_eq!(app.sort_column, start, "Left should rewind Right"); + app.handle_key(key(KeyCode::Left), 10); + assert_ne!(app.sort_column, after_right); + } + + #[test] + fn space_sets_force_refresh_flag() { + let mut app = App::new(); + assert!(!app.force_refresh); + app.handle_key(key(KeyCode::Char(' ')), 10); + assert!(app.force_refresh); + } + + #[test] + fn changing_column_resets_to_default_direction() { + let mut app = App::new(); + // Start at Qtime desc, reverse via r → Qtime asc. + app.handle_key(key(KeyCode::Char('r')), 10); + assert!(!app.sort_desc); + // Cycle to Xtime — desc by default. + app.handle_key(key(KeyCode::Char('>')), 10); + assert_eq!(app.sort_column, SortColumn::Xtime); + assert!(app.sort_desc); + // Cycle into User (textual default → asc). + for _ in 0..5 { + app.handle_key(key(KeyCode::Char('<')), 10); + } + assert_eq!(app.sort_column, SortColumn::User); + assert!(!app.sort_desc); + } + + // -- Key overlay ---------------------------------------------------------- + + #[test] + fn pressing_a_key_seeds_the_overlay_with_its_label() { + let mut app = App::new(); + app.show_keys = true; + app.handle_key(key(KeyCode::Down), 10); + let ko = app + .fresh_key_overlay() + .expect("overlay should be set after a keypress"); + assert_eq!(ko.label, "↓"); + + app.handle_key(key(KeyCode::Char('e')), 10); + assert_eq!(app.fresh_key_overlay().unwrap().label, "e"); + + // Comma is mapped to "<" only when shifted; the bare "," still + // acts as a sort-cycler but renders as "," in the overlay. + app.handle_key(key(KeyCode::Char('<')), 10); + assert_eq!(app.fresh_key_overlay().unwrap().label, "<"); + } + + #[test] + fn key_overlay_is_suppressed_inside_prompt() { + let mut app = App::new(); + app.show_keys = true; + app.open_refresh_prompt(); + // open_refresh_prompt itself does not touch the overlay; subsequent + // typed chars should not surface in the corner. + app.handle_key(key(KeyCode::Char('5')), 10); + assert!(app.fresh_key_overlay().is_none()); + } + + #[test] + fn modifier_keys_get_compact_prefixes() { + // C- (Ctrl), M- (Alt / Meta), S- (Super) — top-style. + let make = |code: KeyCode, mods: KeyModifiers| KeyEvent::new(code, mods); + + assert_eq!( + format_key_label(&make(KeyCode::Char('c'), KeyModifiers::CONTROL)), + "C-c" + ); + assert_eq!( + format_key_label(&make(KeyCode::Char('x'), KeyModifiers::ALT)), + "M-x" + ); + assert_eq!( + format_key_label(&make( + KeyCode::Char('k'), + KeyModifiers::CONTROL | KeyModifiers::ALT + )), + "C-M-k" + ); + // Shift on a printable arrives as the uppercased char itself — + // we don't add an explicit shift prefix in that case. + assert_eq!( + format_key_label(&make(KeyCode::Char('K'), KeyModifiers::SHIFT)), + "K" + ); + // BackTab is the canonical Shift-Tab and gets an explicit ⇧Tab. + assert_eq!( + format_key_label(&make(KeyCode::BackTab, KeyModifiers::SHIFT)), + "⇧Tab" + ); + } + + #[test] + fn key_overlay_off_by_default() { + let mut app = App::new(); + // No show_keys = no overlay even after a key press. + app.handle_key(key(KeyCode::Char('e')), 10); + assert!( + app.fresh_key_overlay().is_none(), + "key overlay must default to off" + ); + } + + // -- strftime helper ----------------------------------------------------- + + #[test] + fn cal_date_utc_anchor_points() { + // 1970-01-01T00:00:00Z + let z = cal_date_utc(0); + assert_eq!( + (z.year, z.month, z.day, z.hour, z.minute, z.second), + (1970, 1, 1, 0, 0, 0) + ); + // 2023-11-14T22:13:20Z (1_700_000_000) + let n = cal_date_utc(1_700_000_000); + assert_eq!( + (n.year, n.month, n.day, n.hour, n.minute, n.second), + (2023, 11, 14, 22, 13, 20) + ); + // Leap-year guard: 2020-02-29 + let leap = cal_date_utc(1_582_934_400); + assert_eq!((leap.year, leap.month, leap.day), (2020, 2, 29)); + } + + #[test] + fn format_strftime_default_iso8601() { + let s = format_strftime(DEFAULT_TS_FORMAT, 1_700_000_000); + assert_eq!(s, "2023-11-14T22:13:20Z"); + } + + #[test] + fn format_strftime_subset_tokens() { + let t = 1_700_000_000; + assert_eq!(format_strftime("%F %T", t), "2023-11-14 22:13:20"); + assert_eq!(format_strftime("%H:%M:%S", t), "22:13:20"); + assert_eq!(format_strftime("%Y%m%d-%H%M%S", t), "20231114-221320"); + assert_eq!(format_strftime("%s", t), "1700000000"); + assert_eq!(format_strftime("%z %Z", t), "+0000 UTC"); + assert_eq!(format_strftime("100%%", t), "100%"); + } + + #[test] + fn format_strftime_unknown_token_passes_through() { + assert_eq!( + format_strftime("[%Q]", 0), + "[%Q]", + "unknown specifier should not be swallowed" + ); + // A trailing bare % survives as a literal (no panic). + assert_eq!(format_strftime("ends with %", 0), "ends with %"); + } + + #[test] + fn fresh_key_overlay_expires_after_ttl() { + let mut app = App::new(); + app.show_keys = true; + app.handle_key(key(KeyCode::Char('e')), 10); + assert!(app.fresh_key_overlay().is_some()); + + // Stamp it as already-expired by hand — we cannot freeze time, so + // the test exercises the staleness check, not the wall-clock TTL. + let ko = app.last_key.as_mut().unwrap(); + ko.expires_at = std::time::Instant::now() + .checked_sub(std::time::Duration::from_millis(1)) + .expect("clock has advanced past 1 ms since boot"); + assert!(app.fresh_key_overlay().is_none()); + } +} diff --git a/src/top/theme.rs b/src/top/theme.rs new file mode 100644 index 00000000..438afc51 --- /dev/null +++ b/src/top/theme.rs @@ -0,0 +1,198 @@ +//! Color palette for `/top`. The wait-event palette mirrors +//! [`pg_ash`](https://github.com/NikolayS/pg_ash) color scheme +//! (see `docs/COLOR_SCHEME.md` in the `pg_ash` repo) so muscle memory +//! transfers between the rpg terminal, `pg_ash` output, and the +//! `PostgresAI` Grafana dashboards. +//! +//! When the terminal advertises 24-bit truecolor (`COLORTERM=truecolor`) +//! we emit the exact `pg_ash` hex values; otherwise we fall back to +//! `ratatui`'s named colors. Palette source: `pg_ash` `COLOR_SCHEME.md`, +//! Dashboard 4 (Wait Sampling). + +use ratatui::style::{Color, Modifier, Style}; + +#[derive(Debug, Clone, Copy)] +pub struct Theme { + pub border: Style, + pub title: Style, + pub muted: Style, + pub header: Style, + pub header_row: Style, + pub selected: Style, + pub footer: Style, + pub status_ok: Style, + pub status_stale: Style, + + /// State column coloring — visually distinct from wait-event coloring. + pub state_active: Style, + pub state_idle_in_tx: Style, + + /// Wait-event-type coloring. Matches `pg_ash`'s Dashboard 4 palette. + pub wait_cpu: Style, + pub wait_idle_tx: Style, + pub wait_io: Style, + pub wait_lock: Style, + pub wait_lwlock: Style, + pub wait_ipc: Style, + pub wait_client: Style, + pub wait_timeout: Style, + pub wait_buffer_pin: Style, + pub wait_activity: Style, + pub wait_extension: Style, + pub wait_other: Style, + + /// qtime warn/crit coloring (project-specific thresholds, not `pg_ash`). + pub qtime_warn: Style, + pub qtime_crit: Style, +} + +impl Theme { + pub fn default_theme() -> Self { + let truecolor = terminal_has_truecolor(); + let cpu_green = rgb_or(truecolor, 0x50, 0xFA, 0x7B, Color::Green); + let idle_tx_yellow = rgb_or(truecolor, 0xF1, 0xFA, 0x8C, Color::LightYellow); + let io_blue = rgb_or(truecolor, 0x1E, 0x64, 0xFF, Color::Blue); + let lock_red = rgb_or(truecolor, 0xFF, 0x55, 0x55, Color::Red); + let lwlock_pink = rgb_or(truecolor, 0xFF, 0x79, 0xC6, Color::Magenta); + let ipc_cyan = rgb_or(truecolor, 0x00, 0xC8, 0xFF, Color::Cyan); + let client_yellow = rgb_or(truecolor, 0xFF, 0xDC, 0x64, Color::Yellow); + let timeout_orange = rgb_or(truecolor, 0xFF, 0xA5, 0x00, Color::LightRed); + let buffer_pin_teal = rgb_or(truecolor, 0x00, 0xD2, 0xB4, Color::Cyan); + let activity_purple = rgb_or(truecolor, 0x96, 0x64, 0xFF, Color::Magenta); + let extension_purple = rgb_or(truecolor, 0xBE, 0x96, 0xFF, Color::LightMagenta); + let other_gray = rgb_or(truecolor, 0xB4, 0xB4, 0xB4, Color::Gray); + + // Chrome (border, muted labels, footer) stays subtle but visible + // on dark terminals. `DarkGray` was hard to see on many themes so + // we lift it to a brighter gray; truecolor terminals get an exact + // RGB tuned for low contrast without disappearing. + let chrome_subtle = rgb_or(truecolor, 0x9b, 0x9d, 0xa6, Color::Gray); + let chrome_dim = rgb_or(truecolor, 0x6e, 0x71, 0x80, Color::Gray); + + Self { + border: Style::default().fg(chrome_subtle), + title: Style::default().add_modifier(Modifier::BOLD), + muted: Style::default().fg(chrome_dim), + header: Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + header_row: Style::default().bg(Color::Black), + selected: Style::default().add_modifier(Modifier::REVERSED), + footer: Style::default().fg(chrome_subtle), + status_ok: Style::default().fg(Color::Green), + status_stale: Style::default().fg(Color::Yellow), + + // State column: keep distinct from wait coloring. + // Active = CPU green; idle-in-tx = pg_ash IdleTx light yellow. + state_active: Style::default().fg(cpu_green), + state_idle_in_tx: Style::default().fg(idle_tx_yellow), + + // Wait-event-type colors per pg_ash COLOR_SCHEME.md. + wait_cpu: Style::default().fg(cpu_green), + wait_idle_tx: Style::default().fg(idle_tx_yellow), + wait_io: Style::default().fg(io_blue), + wait_lock: Style::default().fg(lock_red), + wait_lwlock: Style::default().fg(lwlock_pink), + wait_ipc: Style::default().fg(ipc_cyan), + wait_client: Style::default().fg(client_yellow), + wait_timeout: Style::default().fg(timeout_orange), + wait_buffer_pin: Style::default().fg(buffer_pin_teal), + wait_activity: Style::default().fg(activity_purple), + wait_extension: Style::default().fg(extension_purple), + wait_other: Style::default().fg(other_gray), + + // qtime warn/crit (rpg-specific, distinct from wait colors). + qtime_warn: Style::default().fg(client_yellow), + qtime_crit: Style::default().fg(lock_red), + } + } + + /// Plain-style theme used by `/top --once`. Strips colors so that piping + /// to a file or grep produces clean text. Implementation reuses the + /// `for_tests` constructor. + pub fn for_once() -> Self { + Self::plain() + } + + /// Test-only constructor that ignores terminal capabilities. Snapshot + /// tests use this so output is identical across machines. + #[cfg(test)] + pub fn for_tests() -> Self { + Self::plain() + } + + fn plain() -> Self { + let s = Style::default(); + Self { + border: s, + title: s, + muted: s, + header: s, + header_row: s, + selected: s.add_modifier(Modifier::REVERSED), + footer: s, + status_ok: s, + status_stale: s, + state_active: s, + state_idle_in_tx: s, + wait_cpu: s, + wait_idle_tx: s, + wait_io: s, + wait_lock: s, + wait_lwlock: s, + wait_ipc: s, + wait_client: s, + wait_timeout: s, + wait_buffer_pin: s, + wait_activity: s, + wait_extension: s, + wait_other: s, + qtime_warn: s, + qtime_crit: s, + } + } +} + +/// Pick truecolor RGB when the terminal supports it, otherwise the +/// nearest ratatui named color. +fn rgb_or(truecolor: bool, r: u8, g: u8, b: u8, fallback: Color) -> Color { + if truecolor { + Color::Rgb(r, g, b) + } else { + fallback + } +} + +/// Truecolor detection — duplicates the small helper in +/// `src/ash/renderer.rs` so `/top` does not depend on `/ash` internals. +fn terminal_has_truecolor() -> bool { + std::env::var("COLORTERM") + .is_ok_and(|v| v.eq_ignore_ascii_case("truecolor") || v.eq_ignore_ascii_case("24bit")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_theme_constructs_without_panic() { + let t = Theme::default_theme(); + assert!(t.selected.add_modifier.contains(Modifier::REVERSED)); + } + + #[test] + fn for_tests_is_deterministic() { + let a = Theme::for_tests(); + let b = Theme::for_tests(); + assert_eq!(format!("{a:?}"), format!("{b:?}")); + } + + #[test] + fn pg_ash_palette_uses_truecolor_when_advertised() { + // We can't easily set COLORTERM mid-process safely; instead probe the + // helper directly. + let truecolor = Color::Rgb(0x50, 0xFA, 0x7B); + assert_eq!(rgb_or(true, 0x50, 0xFA, 0x7B, Color::Green), truecolor); + assert_eq!(rgb_or(false, 0x50, 0xFA, 0x7B, Color::Green), Color::Green); + } +} diff --git a/src/top/views/activity.rs b/src/top/views/activity.rs new file mode 100644 index 00000000..aca2ecc2 --- /dev/null +++ b/src/top/views/activity.rs @@ -0,0 +1,553 @@ +//! Activity view — one row per non-rpg backend from `pg_stat_activity`. +//! +//! Default columns: pid, user, db, state, wait, qtime, xtime, locks, query. +//! "Extended" columns (toggled by `e`): pid, user, db, **app, client, +//! backend**, state, wait, qtime, xtime, locks, query. Extended adds +//! `application_name` / `client_addr` / `backend_type` so the user can +//! spot which app or worker class is producing each row. The default +//! keeps the table readable at 80 cols; extended needs ≥120. +//! +//! Wait labels render as `Type:Event` (matching the `pg_ash` convention). +//! Wait coloring follows the `pg_ash` color scheme — see +//! [`pg_ash`](https://github.com/NikolayS/pg_ash) `docs/COLOR_SCHEME.md`. + +use std::cmp::Ordering; + +use ratatui::layout::{Constraint, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Cell, Row, Table, TableState}; +use ratatui::Frame; + +use crate::top::state::{ActivityRow, App, Snapshot, SortColumn}; +use crate::top::theme::Theme; + +/// Render the activity table into `area`. The caller is responsible for +/// passing only the body rectangle (header/tabs/footer are drawn outside). +pub fn render(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) { + // Body title: caption only — the view name lives in the tabs strip + // above, no point repeating "Activity" twice. Extended-mode badge + // still hangs off the body since it's a per-view setting. + let mut title_spans = vec![ + Span::raw(" "), + Span::styled(row_count_caption(app.snapshot.as_ref()), theme.muted), + ]; + if app.extended { + title_spans.push(Span::styled(" [extended] ", theme.title)); + } + let block = Block::default() + .borders(Borders::TOP | Borders::BOTTOM) + .border_style(theme.border) + .title(Line::from(title_spans)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + if let Some(snap) = app.snapshot.as_ref() { + if snap.rows.is_empty() { + render_empty(frame, inner, theme); + } else { + render_table(frame, inner, snap, app, theme); + } + } else { + render_loading(frame, inner, theme); + } +} + +fn row_count_caption(snap: Option<&Snapshot>) -> String { + snap.map_or_else( + || String::from("(loading)"), + |s| format!("({} rows)", s.rows.len()), + ) +} + +fn render_loading(frame: &mut Frame, area: Rect, theme: &Theme) { + let p = ratatui::widgets::Paragraph::new(Line::from(Span::styled( + " collecting first sample…", + theme.muted, + ))); + frame.render_widget(p, area); +} + +fn render_empty(frame: &mut Frame, area: Rect, theme: &Theme) { + let p = ratatui::widgets::Paragraph::new(Line::from(Span::styled( + " no active backends", + theme.muted, + ))); + frame.render_widget(p, area); +} + +fn render_table(frame: &mut Frame, area: Rect, snap: &Snapshot, app: &App, theme: &Theme) { + // Extended mode is opt-in (`e`) and requires a wide-enough terminal. + let extended = app.extended && area.width >= 120; + + let header_cells: Vec<&str> = if extended { + vec![ + "pid", "user", "db", "app", "client", "backend", "state", "wait", "qtime", "xtime", + "locks", "query", + ] + } else { + vec![ + "pid", "user", "db", "state", "wait", "qtime", "xtime", "locks", "query", + ] + }; + + // Sort the rows in Rust according to the App's current sort column / + // direction. SQL only provides the initial qtime-desc ordering; the + // user-facing sort is applied here so that `<` / `>` / `r` take effect + // immediately without re-sampling. + let mut sorted: Vec<&ActivityRow> = snap.rows.iter().collect(); + sort_rows(&mut sorted, app.sort_column, app.sort_desc); + + let widths: Vec = if extended { + vec![ + Constraint::Length(7), // pid + Constraint::Length(10), // user + Constraint::Length(12), // db + Constraint::Length(12), // app + Constraint::Length(15), // client + Constraint::Length(10), // backend_type (compact) + Constraint::Length(8), // state + Constraint::Length(22), // wait Type:Event + Constraint::Length(7), // qtime + Constraint::Length(7), // xtime + Constraint::Length(5), // locks + Constraint::Min(20), // query (flex) + ] + } else { + vec![ + Constraint::Length(7), // pid + Constraint::Length(10), // user + Constraint::Length(12), // db + Constraint::Length(8), // state + Constraint::Length(22), // wait Type:Event + Constraint::Length(7), // qtime + Constraint::Length(7), // xtime + Constraint::Length(5), // locks + Constraint::Min(25), // query (flex) + ] + }; + + let arrow = if app.sort_desc { "▼" } else { "▲" }; + let header = Row::new( + header_cells + .into_iter() + .map(|h| build_header_cell(h, app.sort_column, arrow, theme)) + .collect::>(), + ) + .height(1) + .style(theme.header_row); + + let rows: Vec = sorted + .iter() + .map(|r| build_row(r, extended, theme)) + .collect(); + + // Stateful Table → ratatui keeps the header sticky and auto-scrolls + // the body so the selected row stays in view. + let table = Table::new(rows, widths) + .header(header) + .column_spacing(1) + .row_highlight_style(theme.selected); + + let mut state = TableState::default().with_selected(Some(app.selected_row)); + frame.render_stateful_widget(table, area, &mut state); +} + +fn build_row<'a>(r: &'a ActivityRow, extended: bool, theme: &'a Theme) -> Row<'a> { + let state_style = state_color(&r.state, theme); + let wait_style = wait_color_for_row(r, theme); + let qtime_style = qtime_warn_color(r.qtime_secs, theme); + let wait = format_wait_label(r); + + let cells: Vec = if extended { + vec![ + Cell::from(r.pid.to_string()), + Cell::from(truncate(&r.usename, 10)), + Cell::from(truncate(&r.datname, 12)), + Cell::from(truncate(&r.application_name, 12)), + Cell::from(truncate(&r.client_addr, 15)), + Cell::from(truncate(&short_backend_type(&r.backend_type), 10)), + Cell::from(Span::styled(truncate(&r.state, 8), state_style)), + Cell::from(Span::styled(truncate(&wait, 22), wait_style)), + Cell::from(Span::styled(format_secs(r.qtime_secs), qtime_style)), + Cell::from(format_secs(r.xtime_secs)), + Cell::from(format_locks(r.locks_held)), + Cell::from(squash_query(&r.query)), + ] + } else { + vec![ + Cell::from(r.pid.to_string()), + Cell::from(truncate(&r.usename, 10)), + Cell::from(truncate(&r.datname, 12)), + Cell::from(Span::styled(truncate(&r.state, 8), state_style)), + Cell::from(Span::styled(truncate(&wait, 22), wait_style)), + Cell::from(Span::styled(format_secs(r.qtime_secs), qtime_style)), + Cell::from(format_secs(r.xtime_secs)), + Cell::from(format_locks(r.locks_held)), + Cell::from(squash_query(&r.query)), + ] + }; + + Row::new(cells) +} + +/// Header cell renderer that highlights the column corresponding to the +/// active sort. The active column shows ` ▼ ` (or ` ▲ ` for asc) in +/// the title style; inactive columns render in the muted header style. +fn build_header_cell<'a>( + label: &'a str, + sort: SortColumn, + arrow: &'a str, + theme: &'a Theme, +) -> Cell<'a> { + if label == sort.header_label() { + Cell::from(Line::from(vec![ + Span::styled(label, theme.header.add_modifier(Modifier::UNDERLINED)), + Span::raw(arrow), + ])) + } else { + Cell::from(Span::styled(label, theme.header)) + } +} + +/// Sort rows in-place according to the active column / direction. Stable +/// sort is preferred so equal-key rows preserve the SQL-side ordering. +pub(in crate::top) fn sort_rows(rows: &mut [&ActivityRow], col: SortColumn, desc: bool) { + rows.sort_by(|a, b| { + let ord = match col { + SortColumn::Pid => a.pid.cmp(&b.pid), + SortColumn::User => a.usename.cmp(&b.usename), + SortColumn::Db => a.datname.cmp(&b.datname), + SortColumn::State => a.state.cmp(&b.state), + SortColumn::Wait => format_wait_label(a).cmp(&format_wait_label(b)), + SortColumn::Qtime => cmp_opt_f64(a.qtime_secs, b.qtime_secs), + SortColumn::Xtime => cmp_opt_f64(a.xtime_secs, b.xtime_secs), + SortColumn::Locks => a.locks_held.cmp(&b.locks_held), + SortColumn::Query => a.query.cmp(&b.query), + }; + if desc { + ord.reverse() + } else { + ord + } + }); +} + +fn cmp_opt_f64(a: Option, b: Option) -> Ordering { + match (a, b) { + (Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal), + // Treat None as "less than" any concrete value so descending sort + // pushes idle backends below active ones. + (Some(_), None) => Ordering::Greater, + (None, Some(_)) => Ordering::Less, + (None, None) => Ordering::Equal, + } +} + +/// Render the `wait` column label, matching `pg_ash`'s `Type:Event` format. +/// Empty when the backend is idle and not on CPU; `"CPU*"` when active with +/// no `wait_event` recorded; `Type` alone when `wait_event` is empty; +/// `Type:Event` otherwise. +fn format_wait_label(r: &ActivityRow) -> String { + if r.wait_event_type.is_empty() { + if r.state == "active" { + "CPU*".to_owned() + } else { + String::new() + } + } else if r.wait_event.is_empty() { + r.wait_event_type.clone() + } else { + format!("{}:{}", r.wait_event_type, r.wait_event) + } +} + +/// Strip every control character (incl. ESC, BEL, BS, DEL, the C1 range) +/// from a string before letting it reach ratatui. Any field originating +/// from `pg_stat_activity` — `application_name`, `query`, `usename`, +/// `datname`, `client_addr`, `state`, `wait_event_type`, `wait_event`, +/// `backend_type` — is settable by any connected Postgres client and could +/// otherwise embed ANSI escape sequences that the DBA's terminal would +/// execute. +/// +/// Whitespace controls (`\t`, `\n`, `\r`) collapse to a single space so +/// multi-line queries still read naturally; other control bytes are +/// dropped entirely. Printable Unicode is preserved. +pub(in crate::top) fn scrub_terminal_unsafe(s: &str) -> String { + s.chars() + .map(|c| { + if c == '\t' || c == '\n' || c == '\r' { + ' ' + } else if c.is_control() { + '\u{FFFD}' + } else { + c + } + }) + .filter(|c| *c != '\u{FFFD}') + .collect() +} + +pub(in crate::top) fn truncate(s: &str, max: usize) -> String { + let s = scrub_terminal_unsafe(s); + if s.chars().count() <= max { + s + } else { + let mut out: String = s.chars().take(max.saturating_sub(1)).collect(); + out.push('…'); + out + } +} + +fn squash_query(s: &str) -> String { + let collapsed = scrub_terminal_unsafe(s); + collapsed.split_whitespace().collect::>().join(" ") +} + +pub(in crate::top) fn format_secs(v: Option) -> String { + match v { + None => "-".to_owned(), + Some(s) if s < 0.0 => "-".to_owned(), + Some(s) if s < 1.0 => format!("{:.0}ms", s * 1000.0), + Some(s) if s < 60.0 => format!("{s:.1}s"), + Some(s) if s < 3600.0 => format!("{:.0}m", s / 60.0), + Some(s) if s < 86_400.0 => format!("{:.0}h", s / 3600.0), + Some(s) => format!("{:.0}d", s / 86_400.0), + } +} + +fn format_locks(n: i64) -> String { + if n <= 0 { + "-".to_owned() + } else { + n.to_string() + } +} + +/// Compact form of `pg_stat_activity.backend_type`. Most users only care +/// whether a row is a regular client backend or a parallel/maintenance +/// helper, so we strip the trailing " backend" suffix and shorten +/// "background" to keep the column narrow. +fn short_backend_type(s: &str) -> String { + s.replace(" backend", "").replace("background ", "bg ") +} + +fn state_color(state: &str, theme: &Theme) -> Style { + match state { + "active" => theme.state_active, + "idle in transaction" | "idle in transaction (aborted)" => theme.state_idle_in_tx, + "idle" => theme.muted, + _ => Style::default(), + } +} + +/// `pg_ash` color scheme (`COLOR_SCHEME.md`): +/// idle-in-tx → light-yellow, no `wait_event_type` on an active backend → +/// CPU\* green, then `wait_event_type` → palette below. +fn wait_color_for_row(row: &ActivityRow, theme: &Theme) -> Style { + if row.state.starts_with("idle in transaction") { + return theme.wait_idle_tx; + } + if row.wait_event_type.is_empty() { + return if row.state == "active" { + theme.wait_cpu + } else { + Style::default() + }; + } + match row.wait_event_type.as_str() { + "Lock" => theme.wait_lock, + "LWLock" => theme.wait_lwlock, + "IO" => theme.wait_io, + "IPC" => theme.wait_ipc, + "Client" => theme.wait_client, + "Timeout" => theme.wait_timeout, + "BufferPin" => theme.wait_buffer_pin, + "Activity" => theme.wait_activity, + "Extension" => theme.wait_extension, + _ => theme.wait_other, + } +} + +/// Threshold-based coloring for elapsed query time. +/// Hard-coded thresholds for S1; later sprints read from `[top.thresholds]`. +fn qtime_warn_color(secs: Option, theme: &Theme) -> Style { + match secs { + Some(s) if s >= 30.0 => theme.qtime_crit, + Some(s) if s >= 1.0 => theme.qtime_warn, + _ => Style::default(), + } +} + +/// Public helper — used by the kill-confirm modal in later sprints. +#[allow(dead_code)] +pub(in crate::top) const SELECTED_HIGHLIGHT: Style = Style::new() + .add_modifier(Modifier::REVERSED) + .fg(Color::White); + +#[cfg(test)] +mod tests { + use super::*; + use crate::top::state::ActivityRow; + + #[test] + fn truncate_short_string_unchanged() { + assert_eq!(truncate("abc", 10), "abc"); + } + + #[test] + fn truncate_long_string_appends_ellipsis() { + let out = truncate("abcdefghij", 5); + assert_eq!(out.chars().count(), 5); + assert!(out.ends_with('…')); + } + + #[test] + fn truncate_handles_unicode_correctly() { + let out = truncate("αβγδεζ", 4); + assert_eq!(out.chars().count(), 4); + assert!(out.ends_with('…')); + } + + #[test] + fn format_secs_uses_human_units() { + assert_eq!(format_secs(None), "-"); + assert_eq!(format_secs(Some(0.045)), "45ms"); + assert_eq!(format_secs(Some(2.5)), "2.5s"); + assert_eq!(format_secs(Some(125.0)), "2m"); + assert_eq!(format_secs(Some(7200.0)), "2h"); + assert_eq!(format_secs(Some(172_800.0)), "2d"); + } + + #[test] + fn format_locks_dash_when_zero() { + assert_eq!(format_locks(0), "-"); + assert_eq!(format_locks(1), "1"); + assert_eq!(format_locks(42), "42"); + } + + #[test] + fn squash_query_collapses_whitespace() { + let q = "select\n *\nfrom\tt\n where x = 1"; + assert_eq!(squash_query(q), "select * from t where x = 1"); + } + + #[test] + fn row_count_caption_handles_no_snapshot() { + assert_eq!(row_count_caption(None), "(loading)"); + let s = Snapshot::default(); + assert_eq!(row_count_caption(Some(&s)), "(0 rows)"); + } + + /// Regression: terminal-escape injection via unsanitized fields. + #[test] + fn user_controllable_strings_strip_control_characters() { + let raw = "victim\x1b[2J\x1b[31mPWNED\x07\x08\rback\nnewline\ttab"; + let scrubbed = scrub_terminal_unsafe(raw); + for ch in ['\x1b', '\x07', '\x08', '\r', '\n', '\t'] { + assert!( + !scrubbed.contains(ch), + "{ch:?} must be stripped: {scrubbed:?}" + ); + } + assert!(scrubbed.contains("victim")); + assert!(scrubbed.contains("PWNED")); + } + + #[test] + fn squash_query_strips_ansi_escapes() { + let q = "select 1\x1b[2J\x1b[31m -- malicious"; + let out = squash_query(q); + assert!(!out.contains('\x1b')); + assert!(out.contains("select 1")); + } + + #[test] + fn truncate_strips_ansi_escapes() { + let out = truncate("user\x1b[2J", 10); + assert!(!out.contains('\x1b')); + } + + /// Wait label uses ':' not '.' as delimiter (matches `pg_ash` convention). + #[test] + fn wait_label_uses_colon_delimiter() { + let row = ActivityRow { + wait_event_type: "IO".into(), + wait_event: "DataFileRead".into(), + state: "active".into(), + ..Default::default() + }; + assert_eq!(format_wait_label(&row), "IO:DataFileRead"); + } + + #[test] + fn wait_label_empty_when_idle_and_no_event() { + let row = ActivityRow { + state: "idle".into(), + ..Default::default() + }; + assert_eq!(format_wait_label(&row), ""); + } + + #[test] + fn wait_label_cpu_star_when_active_and_no_event() { + let row = ActivityRow { + state: "active".into(), + ..Default::default() + }; + assert_eq!(format_wait_label(&row), "CPU*"); + } + + #[test] + fn wait_label_type_only_when_event_blank() { + let row = ActivityRow { + wait_event_type: "Lock".into(), + ..Default::default() + }; + assert_eq!(format_wait_label(&row), "Lock"); + } + + #[test] + fn wait_color_routes_pg_ash_palette() { + let theme = Theme::default_theme(); + let mk = |state: &str, wtype: &str| ActivityRow { + state: state.into(), + wait_event_type: wtype.into(), + ..Default::default() + }; + // Wait-event-type → distinctive pg_ash colors. We compare style fg. + let cases = [ + ("active", "Lock", theme.wait_lock), + ("active", "LWLock", theme.wait_lwlock), + ("active", "IO", theme.wait_io), + ("active", "IPC", theme.wait_ipc), + ("active", "Client", theme.wait_client), + ("active", "Timeout", theme.wait_timeout), + ("active", "BufferPin", theme.wait_buffer_pin), + ("active", "Activity", theme.wait_activity), + ("active", "Extension", theme.wait_extension), + ("active", "", theme.wait_cpu), + ]; + for (state, wtype, expected) in cases { + let row = mk(state, wtype); + assert_eq!( + wait_color_for_row(&row, &theme).fg, + expected.fg, + "state={state} wtype={wtype}" + ); + } + + // Idle in transaction always uses the IdleTx color. + let r = ActivityRow { + state: "idle in transaction".into(), + wait_event_type: "Client".into(), + ..Default::default() + }; + assert_eq!( + wait_color_for_row(&r, &theme).fg, + theme.wait_idle_tx.fg, + "idle-in-tx must use the IdleTx color, not the Client wait color" + ); + } +} diff --git a/src/top/views/mod.rs b/src/top/views/mod.rs new file mode 100644 index 00000000..7f9f4992 --- /dev/null +++ b/src/top/views/mod.rs @@ -0,0 +1,4 @@ +//! Per-view renderers. S1 ships only `activity`; later sprints add one file +//! per pgcenter-style view (databases, tables, indexes, statements, …). + +pub mod activity; diff --git a/tests/top_smoke.rs b/tests/top_smoke.rs new file mode 100644 index 00000000..12f931e4 --- /dev/null +++ b/tests/top_smoke.rs @@ -0,0 +1,191 @@ +//! Integration smoke tests for `/top --once` against a real Postgres +//! cluster. +//! +//! These tests spawn the `rpg` binary and exercise the `--once` headless +//! path end-to-end. They are the only place the actual SQL strings, the +//! Postgres-side row decoding, and the renderer-to-stdout pipe are +//! exercised together — unit tests cover each piece in isolation. +//! +//! Gated by the `integration` feature; run with: +//! +//! ```sh +//! cargo test --features integration --test top_smoke +//! ``` +//! +//! Connection defaults match `tests/docker-compose.test.yml` and are +//! overridable via `TEST_PGHOST` / `TEST_PGPORT` / `TEST_PGUSER` / +//! `TEST_PGPASSWORD` / `TEST_PGDATABASE`. CI's `Integration Tests` job +//! sets these for the PG14–PG18 matrix. + +#![cfg(feature = "integration")] + +mod common; + +use common::TestDb; +use serial_test::serial; + +/// Run the `rpg` binary against the test cluster with `--command "/top +/// --once"` and return `(stdout, stderr, exit_code)`. +fn run_top_once(extra: &[&str]) -> (String, String, i32) { + let host = std::env::var("TEST_PGHOST").unwrap_or_else(|_| "localhost".to_owned()); + let port = std::env::var("TEST_PGPORT").unwrap_or_else(|_| "15432".to_owned()); + let user = std::env::var("TEST_PGUSER").unwrap_or_else(|_| "testuser".to_owned()); + let password = std::env::var("TEST_PGPASSWORD").unwrap_or_else(|_| "testpass".to_owned()); + let dbname = std::env::var("TEST_PGDATABASE").unwrap_or_else(|_| "testdb".to_owned()); + + let bin = env!("CARGO_BIN_EXE_rpg"); + let mut args: Vec<&str> = vec![ + "-h", + &host, + "-p", + &port, + "-U", + &user, + "-d", + &dbname, + "--command", + "/top --once", + ]; + args.extend_from_slice(extra); + + let output = std::process::Command::new(bin) + .args(&args) + .env("PGPASSWORD", &password) + .output() + .expect("failed to spawn rpg binary"); + + ( + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + output.status.code().unwrap_or(-1), + ) +} + +macro_rules! connect_or_skip { + () => { + match TestDb::connect().await { + Ok(db) => db, + Err(e) => { + if std::env::var("CI").is_ok() { + panic!("database unreachable in CI — this should not happen: {e}"); + } + eprintln!( + "skipping integration test — cannot connect to test DB: {e}\n\ + Start Postgres with: \ + docker compose -f tests/docker-compose.test.yml up -d --wait" + ); + return; + } + } + }; +} + +/// `/top --once` against an idle test cluster: must exit 0 and render the +/// expected chrome (header, Activity tab, footer hints). +/// +/// Regression coverage for the `extract(epoch …)::float8` deserialization +/// bug fixed during PR #837 manual testing — without the cast the run +/// emits "error deserializing column 9" and never gets to the table. +#[tokio::test] +#[serial] +async fn top_once_renders_against_real_pg() { + let _db = connect_or_skip!(); + + let (stdout, stderr, code) = run_top_once(&[]); + + assert_eq!( + code, 0, + "/top --once must exit 0; stdout=\n{stdout}\nstderr=\n{stderr}" + ); + // Header chrome + assert!( + stdout.contains("rpg /top"), + "missing header title: {stdout}" + ); + assert!( + stdout.contains("primary") || stdout.contains("standby"), + "missing recovery indicator: {stdout}" + ); + // Tab + view title + assert!( + stdout.contains("Activity"), + "missing Activity tab: {stdout}" + ); + // Footer hints (drawn even on an empty cluster) + assert!(stdout.contains("quit"), "missing footer keymap: {stdout}"); + // Error guard: catch the deserialization regression specifically. + assert!( + !stdout.contains("error deserializing"), + "deserialization error leaked into output: {stdout}" + ); + assert!( + !stderr.contains("error deserializing"), + "deserialization error printed to stderr: {stderr}" + ); +} + +/// With at least one extra backend running, the activity table must show +/// the row count > 0 and not collapse to "no active backends". +#[tokio::test] +#[serial] +async fn top_once_shows_active_backend_count() { + let _db = connect_or_skip!(); + + // Spawn a victim session that sleeps for the duration of this test so + // the snapshot captures it. Drop the connection at end-of-scope. + let victim = TestDb::connect().await.expect("second connection"); + let _ = victim.query("select pg_sleep(0.5)").await; // brief overlap is fine + + let (stdout, _stderr, code) = run_top_once(&[]); + assert_eq!(code, 0, "/top --once exit code: {code}\n{stdout}"); + + // The (N rows) caption is rendered by the activity view header; total + // backends count is rendered in the header. + let has_row_caption = stdout.contains("(0 rows)") || stdout.contains("rows)"); + assert!(has_row_caption, "missing row-count caption: {stdout}"); +} + +/// `/top --typo` (or any unrecognized `/`-command) is dispatched, prints +/// "Unknown command:" via the dispatcher, and rpg exits cleanly. This is +/// the regression guard for the new `is_slash_extension_command` routing +/// in `exec_command`: previously the typo would fall through to SQL +/// execution and raise a "syntax error at or near /" instead. +#[tokio::test] +#[serial] +async fn unknown_slash_command_is_recognised_not_treated_as_sql() { + let _db = connect_or_skip!(); + + let host = std::env::var("TEST_PGHOST").unwrap_or_else(|_| "localhost".to_owned()); + let port = std::env::var("TEST_PGPORT").unwrap_or_else(|_| "15432".to_owned()); + let user = std::env::var("TEST_PGUSER").unwrap_or_else(|_| "testuser".to_owned()); + let password = std::env::var("TEST_PGPASSWORD").unwrap_or_else(|_| "testpass".to_owned()); + let dbname = std::env::var("TEST_PGDATABASE").unwrap_or_else(|_| "testdb".to_owned()); + + let bin = env!("CARGO_BIN_EXE_rpg"); + let output = std::process::Command::new(bin) + .args([ + "-h", + &host, + "-p", + &port, + "-U", + &user, + "-d", + &dbname, + "--command", + "/definitely-not-a-command", + ]) + .env("PGPASSWORD", &password) + .output() + .expect("spawn rpg"); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("Unknown command") || stderr.contains("unknown command"), + "expected dispatcher's unknown-command message; got stderr: {stderr}" + ); + assert!( + !stderr.contains("syntax error at or near"), + "/-prefixed input must not fall through to SQL execution: {stderr}" + ); +}