pg_reports 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2ff4489af5016c4bfbe14f7187daa5ca2ea279b15887bab928c8effefc4b2dad
4
- data.tar.gz: 26815859090bbf8a84000acb3545c1eecb982887f8d0f07d9ab0e582f2380c58
3
+ metadata.gz: 8a307810bc4ac9d34917eddd4d3a231a4a9fd0010f2b8bd1d1f56a654336028b
4
+ data.tar.gz: 175e0568589ca06b81a7482550d6eb1e3bfe05089ea1fda74b47fc33901aa4d0
5
5
  SHA512:
6
- metadata.gz: f94d1f175001f3a8140d8ef9b826d353bb20963d3b75487aace882fde54186466be500840252bc9e87268e55244bb96746f59fd82b6ba648271a828b596b47d4
7
- data.tar.gz: f89ff6622a18f3daf33fabf898a8736da510e2ecf84f4455e5720a8a07f3a219ed1336a1f1a286437a23da505560e724679bb27957b74b501567db2cfaee9f17
6
+ metadata.gz: 2f817ec173d59b77e7a70ec69b15292bd9a7eb9e074d7c5edd1cd82003f76bd382efdecc754313a453e5f851d3c1bd5c447cf6bd29723afae49110f49e95631e
7
+ data.tar.gz: 7188d4caa875624d210c4b00fbd4d3e3046b0df7bce877b5484d3ebad919d8a19df908e6c923fe2f48ac62e0595cfa5a48699accfccfc5c4cb4bc603e9dba5db
data/CHANGELOG.md CHANGED
@@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.0] - 2026-09-16
11
+
12
+ ### Changed
13
+ - **The CI matrix now resolves against committed lockfiles.** `gemfiles/*.gemfile.lock` were gitignored, so every matrix job re-resolved dependencies from scratch and drifted with whatever was newest on the day — which is how the Rails 6.1 job silently moved onto a json 3.x that ActiveSupport 6.1 cannot use, turning green builds red with no code change. Each lockfile is generated with that entry's own Ruby and pinned to the runner's `x86_64-linux` platform. The Rails 6.1 lockfile deliberately pins json 3.0.2, so the incompatibility that started this stays covered rather than being pinned away.
14
+ - **Updated `json`, `loofah` and `rails-html-sanitizer`** in the root lockfile to clear open advisories (`bundler-audit`).
15
+ - **Dashboard visual overhaul — a single design system instead of three drifting stylesheets.** The three `<style>` blocks (layout, `index`, `_show_styles`) had grown independent, conflicting copies of the same components: `.modal-close` was 28px in one and 32px in another, `.modal-small` was 360px and 420px, `.toast` and the whole `.btn` family were defined twice with different colours, and `.explain-stats` / `.explain-result` were each declared twice within one file. All shared components now live once in the layout, and the per-page blocks only hold what is genuinely page-specific. `_show_styles.html.erb` shrank from 1678 to ~1500 lines with no loss of coverage.
16
+ - **Design tokens.** New `:root` tokens for radii (`--radius-sm`/`--radius`/`--radius-lg`), control height, shadows, fonts, and an accent-tint scale (`--amber-soft`/`--amber-line`, …) derived from the actual `--accent-*` values. Every hard-coded `rgba()`/hex tint was replaced — many were leftover Tailwind-palette values (`#ef4444`, `rgba(59,130,246,…)`) that no longer matched the accent they bordered.
17
+ - **Fixed two undeclared variables.** `--text-tertiary` and `--accent-red` were referenced but never defined, so those rules silently fell back to inherited colour; both are now declared. Removed the unused `--accent-indigo`, `--gradient-start` and `--gradient-end`.
18
+ - **Buttons are solid, classic controls.** `.btn-primary` was a translucent tinted outline (`rgba(124,138,246,.14)` fill, accent text, accent border); it is now a filled button with white text, plus proper `:active` and `:focus-visible` states. `.btn-secondary`/`.btn-danger`/`.btn-muted`/`.btn-ghost`/`.btn-icon` share one height, radius and transition, and every variant is declared in exactly one place.
19
+ - **Status is no longer signalled by coloured left stripes.** The live metric cards carried a 3px green/amber/rose `border-left` — "healthy" was as decorated as "critical". Status now reads from the corner dot, the value colour and the sparkline, with the card's own border tinting only for warning and critical. The same treatment replaced the coloured rails and `linear-gradient` washes on EXPLAIN summary cards, problem cards and stat tiles.
20
+ - Table problem rows used `border-left` on `<tr>`, which does not render under `border-collapse: collapse`; they now use an inset shadow on the first cell, so the marker is actually visible.
21
+ - **Sparkline colour encodes state, not identity.** Each metric drew its trend in its own hue (blue/green/purple/amber/rose), which read as decoration. All five now draw in a muted neutral, switching to amber or rose only when that metric crosses its threshold.
22
+ - **Emoji replaced with an inline SVG icon sprite.** Emoji were used as UI icons throughout — the header logo tile, category and metric icons, the settings gear, lock, play/pause, and as prefixes baked into ~48 locale strings per language (`"📋 Copy"`, `"⬇ Export"`, `"🔒 Requires pg_stat_statements"`). They rendered differently on every OS and could not be coloured. All are now stroke-drawn `<symbol>`s defined once in the layout and referenced with `<use>`, inheriting colour and size from their context. Locale strings carry text only.
23
+ - **Removed the header logo tile.** The wordmark and version carry the branding.
24
+ - **Dropped the decorative motion.** Hover lifts (`translateY(-1px/-2px)`, `translateX(4px)` plus shadow), the glassmorphism `backdrop-filter: blur()` on modals, and the pulsing animation on the static status-badge dot are gone. Non-interactive panels no longer light up in accent purple on hover.
25
+ - **Typography.** Plus Jakarta Sans → Inter, body line-height 1.6 → 1.5, and a consistent type scale for a dense dashboard. Numeric surfaces (result tables, live metric values, EXPLAIN stats) use `tabular-nums` so digits stop reflowing as values refresh.
26
+ - **Accessibility.** Added `:focus-visible` rings on buttons, inputs and textareas; icon-only buttons carry `aria-label`; decorative icons are `aria-hidden`.
27
+ - **Source locations in `.rake`, `.jbuilder`, `config.ru` and extensionless files were not recognised at all.** The client-side parser matched paths against a hardcoded extension allow-list (`rb|erb|js|ts|py|go|java`); anything outside it fell through unparsed, which silently cost it the root-stripping, the start-clipping *and* its IDE link — a `/lib/tasks/setup.rake:17` rendered as the raw absolute path, home directory included. The allow-list is gone: the matcher now takes any path followed by `:<line>`, which also fixes Windows paths (`C:\app\models\post.rb:12`) that the old `[^:]+` broke on at the drive letter. Sources that still don't parse (`PostsController#index`) are clipped from the start too, instead of having their tail cut off.
28
+ - **Source locations are shown short, and never truncated where it matters.** The badge used to print the path verbatim and clip it with `text-overflow: ellipsis` — which cuts the *end*, throwing away the file name and line and leaving only a long shared prefix. It now strips the host application's root (when the engine is mounted in one), collapses a bundled gem's install prefix to the gem's own directory (`…/lib/ruby/gems/3.4.0/gems/activerecord-8.1.3/lib/…` → `activerecord-8.1.3/lib/…`), and clips what remains from the *start*, so the line number is the last thing to go: the directory is given up first and almost entirely, and only once there is no directory left does the file name itself start losing leading characters. The badge is capped at its cell's width and clips its own overflow, so a single long unbreakable file name can no longer push the badge past the column. The directory is dimmed, the file and line are not. Standalone mode has no host-app root to strip, so it gets the gem collapse and the start-clipping. The full path is unchanged in the tooltip, the expanded row and the exports, and IDE links are still built from it.
29
+ - **"Load History (50)" is now "Load History", and is disabled when there is nothing to load.** The number was the request's page size, not a count of anything the user had; read as "50 entries are waiting". The button is also enabled only when history is actually retrievable — it reads from `config.query_monitor_log_file`, so with no log file configured (or none written yet) it could only ever no-op. `QueryMonitor#status` now reports `history_available`, and the disabled button explains why in its tooltip.
30
+ - **The report page's "Back" button moved into the breadcrumb.** It sat at the far right of the action row — away from the navigation context, and a second control doing what the "Dashboard" crumb already did. The first crumb is now the back action, with a left-arrow icon making the affordance explicit.
31
+ - **One vertical rhythm for text and code blocks.** The global `* { margin: 0 }` reset leaves prose containers with no spacing of their own, and the per-element margins that filled the gap had drifted into asymmetry — a `<pre>` inherited 1rem above (from the preceding `<p>`'s `margin-bottom`) and 0 below, so snippets floated away from their intro line and collided with the line under them. Spacing is now owned by one `> * + *` rule per container, driven by a `--flow-gap` token, with the ad-hoc `margin-bottom`/`margin-top` on each block removed.
32
+ - **One code-block definition.** The same "here is how to enable this feature" panel existed in five hand-spaced copies (`<br><br>` for paragraph breaks, `&nbsp;&nbsp;` for indentation, inline `style=` for the box) across the SQL Console, EXPLAIN, Execute Query and migration flows. All five now render through a shared `pgReportsDisabledNotice()` helper using a real `<pre class="code-block">`, so indentation is actual whitespace and every snippet in the UI is styled identically.
33
+ - **One field-label definition.** `.explain-label`, `.explain-stat-label`, `.row-detail-label`, `.problem-field-label`, `.saved-record-field-name` and `.saved-record-detail-label` were six variants of "small-caps label above a value", spread across 0.65–0.8rem, three letter-spacings and two colours. Collapsed into a single rule.
34
+
35
+ ### Fixed
36
+ - **The dashboard broke out of the viewport on a phone.** The `@media (max-width: 768px)` block covered the container, the header and the report actions, and nothing else, so everything added since was laid out at desktop proportions: the breadcrumb squeezed the database selector down to a few characters, the results header packed its title and three meta items into one unwrappable row, filter labels fought their inputs for the same line, and the `.btn { flex: 1 }` in that block stretched *every* button on the page — including "Clear All", which then overran the "Saved for Comparison" title next to it. Two things pushed the page itself sideways: the Query Monitor's scope tooltip (a 320px absolutely-positioned `::after` anchored mid-row, which widened the layout viewport and dragged the fixed toast with it), and the toast's own `max-width: 26rem`, wider than the screen and pinned by `right`. The expanded-row detail was the worst of it — a two-column grid inside the horizontally scrolling results table, with its second column parked off-screen. It is now one column, pinned to the viewport with `position: sticky` so it stays readable while the table scrolls sideways under it; the generic cell rule's `overflow: hidden`, which made the cell its own scroll container and killed the sticky, is lifted for that one cell. The tooltip becomes a bottom sheet, the toast spans the screen width, and the button stretch is scoped to the page's actual action bars. Verified at 320/360/390/414/768px across the dashboard and five report pages: no page-level horizontal overflow anywhere, with only the results table scrolling sideways as intended.
37
+ - **The proxy scrollbar above a wide results table was dead space on touch.** It renders a 12px strip whose only purpose is to be dragged, and touch devices draw no scrollbar in it; it is now skipped when the pointer is coarse, where the table is dragged directly anyway.
38
+ - **Query-monitor file logging silently wrote nothing on Rails <= 7.0 with json >= 3.** The writer serialized with `#to_json`, which ActiveSupport overrides and (up to Rails 7.0) calls with json's `quirks_mode` option — removed in json 3, so it raises `ArgumentError: unknown keyword: quirks_mode`. The writer's `rescue` swallowed that by design ("don't break monitoring if the file write fails"), so the log file just stayed empty with no visible error. Serialization now goes through `JSON.generate`, json's own generator, which takes no such option. Covered by a regression spec.
39
+
40
+
41
+ - **Migration-disabled toast was hardcoded Russian** in an otherwise fully translated UI. Now goes through `errors.migration_disabled_toast` (added to all three locales).
42
+ - **Migration-disabled panel title was hardcoded English.** Now goes through `modals.migration_disabled_title` (added to all three locales).
43
+ - **Switching to a database without pg_stat_statements leaked `PG::UndefinedTable` into the UI.** The extension is created per-database, but the availability gate lived only in the dashboard template (`category_key == :queries && !@pg_stat_status[:ready]`). `show`, `run`, `download` and `send_to_telegram` never consulted it, so opening a Queries report and then switching the database ran the report anyway and rendered the driver's `relation "pg_stat_statements" does not exist LINE 11:` in the error banner. The requirement is now declared on the category (`requires: :pg_stat_statements`) and enforced in `#category_disabled_reason`, which every entry point already calls — the dashboard template now reads that same value instead of repeating the rule. The message distinguishes the two remedies: an extension that was never created here, versus one that exists but is missing from `shared_preload_libraries`. An unreachable database is left to the existing connection-error banner rather than being mislabelled as a missing extension.
44
+ - **Redirects out of an unavailable report were silent.** `show` has always redirected to the dashboard with `alert:` when a category is unavailable, but no view rendered `flash`, so the user was bounced with no explanation. The layout now renders `flash[:alert]` / `flash[:notice]`.
45
+ - **`ErrorTranslator` handles missing relations and functions** (`42P01`, `42883`). When the missing relation is one an extension provides, it names the extension and hints the `CREATE EXTENSION` instead of echoing the raw driver message. The three report endpoints route failures through the translator; the raw-SQL endpoints (SQL Console, Execute Query, EXPLAIN) still return PostgreSQL's own wording, which is what you want for a query you typed yourself.
46
+ - `ExplainAnalyzer` no longer emits a `status_icon` emoji from the analysis layer; the dashboard picks the icon from `status`.
47
+
48
+ ## [0.8.2] - 2026-07-10
49
+
50
+ ### Added
51
+
52
+ - **SQL Console — free-form SQL editor.** A new **SQL Console** button in the dashboard header opens a large modal with a SQL textarea (⌘/Ctrl+Enter to run) and a results table with row count and execution time. Renamed from the initial "Run Query" label, which read too similarly to the existing **▶ Run Report** button. Unlike *Execute Query* (which only ever runs server-generated, hash-cached queries — see 0.5.1 below), this accepts client-typed SQL directly, so the same SELECT-only/no-semicolon/keyword-denylist validation is applied straight to the submitted text via a new shared `enforce_select_only!` check. Gated by the existing `config.allow_raw_query_execution` flag; new `POST /run_query` endpoint.
53
+ - **Bounded statement timeout for raw query execution.** `Execute Query`, `EXPLAIN ANALYZE`, and `SQL Console` now run inside a transaction with `SET LOCAL statement_timeout`, configurable via `config.raw_query_statement_timeout_ms` (default 5000ms, `PG_REPORTS_RAW_QUERY_STATEMENT_TIMEOUT_MS`, `0` disables). Prevents a runaway or accidentally expensive query from holding a connection open indefinitely; a cancelled query now surfaces a clear "query timed out" error instead of an opaque `PG::QueryCanceled`.
54
+ - **Rate limiting for privileged dashboard endpoints.** `explain_analyze`, `execute_query`, `run_query`, and `create_migration` are now throttled per client IP via `config.raw_query_rate_limit` (default 30 requests, `PG_REPORTS_RAW_QUERY_RATE_LIMIT`) within `config.raw_query_rate_limit_window_seconds` (default 60s, `PG_REPORTS_RAW_QUERY_RATE_LIMIT_WINDOW_SECONDS`). Backed by `Rails.cache`; best-effort (not a hardened distributed limiter) and fails open if the cache is unavailable. Set `raw_query_rate_limit = nil` to disable.
55
+
56
+ ### Changed
57
+
58
+ - **SQL Query Monitor is no longer shown in standalone mode.** It works by subscribing to `ActiveSupport::Notifications` in a host application's process — standalone mode has no separate host app, so the panel had nothing meaningful to monitor. The dashboard panel is now hidden and the `/query_monitor/*` endpoints return `403 Forbidden` when `PgReports.config.standalone` is true.
59
+
10
60
  ## [0.8.1] - 2026-07-03
11
61
 
12
62
  ### Added
data/README.md CHANGED
@@ -7,10 +7,14 @@
7
7
 
8
8
  A comprehensive PostgreSQL monitoring and analysis library for Rails applications. Get insights into query performance, index usage, table statistics, connection health, and more — across **every database on the cluster**, switchable from the dashboard with no extra configuration. Includes a beautiful web dashboard, a Grafana / Prometheus exporter, and Telegram delivery.
9
9
 
10
+ > [!NOTE]
11
+ > **It now runs standalone, too** — launch the dashboard against any PostgreSQL database without a host Rails app, straight from the gem with a single command (`pg_reports server`). It still needs a Ruby runtime installed. Docker images (no Ruby required) are planned for the near future. See **[Standalone mode →](docs/standalone.md)**.
12
+
10
13
  ![Dashboard Screenshot](docs/dashboard.png)
11
14
 
12
15
  ## Features
13
16
 
17
+ - 🚀 **Standalone or mounted** - Run inside your Rails app, or launch the dashboard on its own with `pg_reports server` (requires Ruby; Docker images coming soon).
14
18
  - 🗄️ **Multi-database** - Auto-discovers every database on the cluster and lets you switch from a dropdown in the dashboard. No configuration required.
15
19
  - 📊 **Query Analysis** - Identify slow, heavy, and expensive queries using `pg_stat_statements`
16
20
  - 📇 **Index Analysis** - Find unused, duplicate, invalid, and missing indexes
@@ -24,7 +28,8 @@ A comprehensive PostgreSQL monitoring and analysis library for Rails application
24
28
  - 🔗 **IDE Integration** - Open source locations in VS Code, Cursor, RubyMine, or IntelliJ (with WSL support)
25
29
  - 📌 **Comparison Mode** - Save records to compare before/after optimization
26
30
  - 📊 **EXPLAIN ANALYZE** - Advanced query plan analyzer with problem detection and recommendations
27
- - 🔍 **SQL Query Monitoring** - Real-time monitoring of all executed SQL queries with source location tracking
31
+ - 🖥️ **SQL Console** - Free-form SQL editor in a modal, run SELECT queries and view results directly from the dashboard
32
+ - 🔍 **SQL Query Monitoring** - Real-time monitoring of all executed SQL queries with source location tracking (not available in standalone mode)
28
33
  - 🔌 **Connection Pool Analytics** - Monitor pool usage, wait times, saturation warnings, and connection churn
29
34
  - 🤖 **AI Prompt Export** - Copy a ready-to-paste prompt for Claude Code, Cursor, or Codex with problem context and report data
30
35
  - 🗑️ **Migration Generator** - Generate Rails migrations to drop unused indexes
@@ -68,9 +73,10 @@ You can also run the dashboard on its own, straight from the gem's root folder
68
73
  ```bash
69
74
  ./bin/pg_reports server # from a checkout; no `bundle exec` needed
70
75
  DATABASE_URL=postgres://user:pass@localhost/myapp bundle exec pg_reports server
76
+ ./bin/pg_reports server --allow-raw-query-execution # opt into the Run SQL panel
71
77
  ```
72
78
 
73
- Adds no runtime dependencies to the gem. **[Standalone guide → docs/standalone.md](docs/standalone.md)**
79
+ Settings come from `PG_REPORTS_*` env vars, an auto-detected `./pg_reports.rb` config file (full `PgReports.configure` access), or CLI flags — in that order of precedence. Adds no runtime dependencies to the gem. **[Standalone guide → docs/standalone.md](docs/standalone.md)**
74
80
 
75
81
  ## Usage
76
82
 
@@ -178,6 +184,19 @@ Requires `config.allow_raw_query_execution = true`.
178
184
 
179
185
  </details>
180
186
 
187
+ <details>
188
+ <summary><strong>SQL Console — free-form SQL editor</strong></summary>
189
+
190
+ Click **SQL Console** in the header to open a large modal with a SQL editor. Type or paste a query, run it (⌘/Ctrl+Enter also works), and see the results in a table with row count and execution time.
191
+
192
+ Only `SELECT` statements are allowed — the same denylist validation used for the query-hash based **Execute Query** panel (single statement, no `INSERT`/`UPDATE`/`DELETE`/`DROP`/`ALTER`/`CREATE`/`TRUNCATE`/`GRANT`/`REVOKE`) applies here, since this is client-typed SQL rather than a server-generated query. See [Security model](docs/configuration.md#security-model) for the full threat model and residual risks (this is a denylist, not a sandbox).
193
+
194
+ Every query (here and in Execute Query / EXPLAIN ANALYZE) runs under a bounded `statement_timeout` (`config.raw_query_statement_timeout_ms`, default 5s) and these endpoints are rate-limited per client IP (`config.raw_query_rate_limit`, default 30/min) — see [Raw query execution](docs/configuration.md#raw-query-execution-explain-analyze--execute-query--sql-console).
195
+
196
+ Requires `config.allow_raw_query_execution = true`.
197
+
198
+ </details>
199
+
181
200
  <details>
182
201
  <summary><strong>SQL Query Monitor — real-time query capture</strong></summary>
183
202
 
@@ -200,6 +219,8 @@ end
200
219
 
201
220
  Use cases: debugging N+1, identifying slow queries during feature development, tracking down unexpected queries, teaching ActiveRecord behavior.
202
221
 
222
+ Not available in [standalone mode](docs/standalone.md) — there is no host application process to subscribe to, so the panel and its API are both disabled.
223
+
203
224
  </details>
204
225
 
205
226
  <details>
@@ -15,11 +15,18 @@ module PgReports
15
15
  before_action :set_categories
16
16
  before_action :resolve_database_selection
17
17
  around_action :within_selected_database
18
+ before_action :block_query_monitor_in_standalone, only: %i[
19
+ start_query_monitoring stop_query_monitoring query_monitor_status
20
+ query_monitor_feed load_query_history download_query_monitor
21
+ ]
22
+ before_action :enforce_rate_limit!, only: %i[
23
+ explain_analyze execute_query run_query create_migration
24
+ ]
18
25
 
19
26
  helper_method :category_disabled_reason, :category_disabled?
20
27
 
21
28
  def index
22
- @pg_stat_status = PgReports.pg_stat_statements_status
29
+ @pg_stat_status = pg_stat_status
23
30
  @current_database = PgReports.system.current_database
24
31
  end
25
32
 
@@ -133,7 +140,7 @@ module PgReports
133
140
 
134
141
  @report = execute_report(@category, @report_key)
135
142
  rescue => e
136
- @error = e.message
143
+ @error = report_error_message(e)
137
144
  @report = nil
138
145
  end
139
146
 
@@ -174,7 +181,7 @@ module PgReports
174
181
  problem_explanations: problem_explanations
175
182
  }
176
183
  rescue => e
177
- render json: {success: false, error: e.message}, status: :unprocessable_entity
184
+ render json: {success: false, error: report_error_message(e)}, status: :unprocessable_entity
178
185
  end
179
186
 
180
187
  def send_to_telegram
@@ -191,7 +198,7 @@ module PgReports
191
198
 
192
199
  render json: {success: true, message: I18n.t("pg_reports.ui.success.telegram_sent")}
193
200
  rescue => e
194
- render json: {success: false, error: e.message}, status: :unprocessable_entity
201
+ render json: {success: false, error: report_error_message(e)}, status: :unprocessable_entity
195
202
  end
196
203
 
197
204
  def download
@@ -220,7 +227,7 @@ module PgReports
220
227
  disposition: "attachment"
221
228
  end
222
229
  rescue => e
223
- render json: {success: false, error: e.message}, status: :unprocessable_entity
230
+ render json: {success: false, error: report_error_message(e)}, status: :unprocessable_entity
224
231
  end
225
232
 
226
233
  def explain_analyze
@@ -275,8 +282,11 @@ module PgReports
275
282
  return
276
283
  end
277
284
 
278
- result = ActiveRecord::Base.connection.execute("EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) #{final_query}")
279
- explain_output = result.map { |r| r["QUERY PLAN"] }.join("\n")
285
+ explain_output = nil
286
+ with_statement_timeout do
287
+ result = ActiveRecord::Base.connection.execute("EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) #{final_query}")
288
+ explain_output = result.map { |r| r["QUERY PLAN"] }.join("\n")
289
+ end
280
290
 
281
291
  # Analyze the EXPLAIN output
282
292
  analyzer = ExplainAnalyzer.new(explain_output)
@@ -290,6 +300,8 @@ module PgReports
290
300
  problems: analysis[:problems],
291
301
  summary: analysis[:summary]
292
302
  }
303
+ rescue ActiveRecord::QueryCanceled
304
+ render json: {success: false, error: query_timed_out_message}, status: :unprocessable_entity
293
305
  rescue => e
294
306
  render json: {success: false, error: e.message}, status: :unprocessable_entity
295
307
  end
@@ -340,23 +352,95 @@ module PgReports
340
352
  # Execute with LIMIT to prevent huge result sets
341
353
  limited_query = add_limit_if_missing(final_query, 100)
342
354
 
343
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
344
- result = ActiveRecord::Base.connection.execute(limited_query)
345
- end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
346
- execution_time = ((end_time - start_time) * 1000).round(2)
355
+ rows = columns = nil
356
+ total_count = 0
357
+ truncated = false
358
+ execution_time = nil
359
+
360
+ with_statement_timeout do
361
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
362
+ result = ActiveRecord::Base.connection.execute(limited_query)
363
+ end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
364
+ execution_time = ((end_time - start_time) * 1000).round(2)
365
+
366
+ rows = result.to_a
367
+ columns = rows.first&.keys || []
368
+ total_count = rows.size
369
+
370
+ # Check if we need to get total count
371
+ if rows.size >= 100
372
+ count_result = ActiveRecord::Base.connection.execute("SELECT COUNT(*) FROM (#{final_query}) AS count_query")
373
+ total_count = count_result.first["count"].to_i
374
+ truncated = total_count > 100
375
+ end
376
+ end
347
377
 
348
- rows = result.to_a
349
- columns = rows.first&.keys || []
378
+ render json: {
379
+ success: true,
380
+ columns: columns,
381
+ rows: rows,
382
+ count: rows.size,
383
+ total_count: total_count,
384
+ truncated: truncated,
385
+ execution_time: execution_time
386
+ }
387
+ rescue ActiveRecord::QueryCanceled
388
+ render json: {success: false, error: query_timed_out_message}, status: :unprocessable_entity
389
+ rescue => e
390
+ render json: {success: false, error: e.message}, status: :unprocessable_entity
391
+ end
350
392
 
351
- # Check if we need to get total count
352
- total_count = rows.size
353
- truncated = false
393
+ # POST /run_query
394
+ # Free-text SQL runner backing the "Run Query" modal. Unlike #execute_query
395
+ # (which only ever runs queries the server itself generated and cached by
396
+ # hash — see CHANGELOG 0.5.1), this endpoint accepts client-typed SQL
397
+ # directly, so it applies the same SELECT-only/denylist validation that
398
+ # normally happens on cache retrieval directly to the submitted text.
399
+ def run_query
400
+ raw_query = params[:query].to_s
401
+
402
+ if raw_query.blank?
403
+ render json: {success: false, error: I18n.t("pg_reports.ui.errors.query_required")}, status: :unprocessable_entity
404
+ return
405
+ end
406
+
407
+ unless PgReports.config.allow_raw_query_execution
408
+ render json: {
409
+ success: false,
410
+ error: I18n.t("pg_reports.ui.errors.query_execution_disabled")
411
+ }, status: :forbidden
412
+ return
413
+ end
414
+
415
+ begin
416
+ enforce_select_only!(raw_query)
417
+ rescue SecurityError => e
418
+ render json: {success: false, error: "#{I18n.t("pg_reports.ui.errors.security_violation_prefix")} #{e.message}"}, status: :forbidden
419
+ return
420
+ end
421
+
422
+ limited_query = add_limit_if_missing(raw_query, 100)
354
423
 
355
- if rows.size >= 100
356
- # Check if there are more rows
357
- count_result = ActiveRecord::Base.connection.execute("SELECT COUNT(*) FROM (#{final_query}) AS count_query")
358
- total_count = count_result.first["count"].to_i
359
- truncated = total_count > 100
424
+ rows = columns = nil
425
+ total_count = 0
426
+ truncated = false
427
+ execution_time = nil
428
+
429
+ with_statement_timeout do
430
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
431
+ result = ActiveRecord::Base.connection.execute(limited_query)
432
+ end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
433
+ execution_time = ((end_time - start_time) * 1000).round(2)
434
+
435
+ rows = result.to_a
436
+ columns = rows.first&.keys || []
437
+ total_count = rows.size
438
+
439
+ if rows.size >= 100
440
+ count_result = ActiveRecord::Base.connection.execute("SELECT COUNT(*) FROM (#{raw_query}) AS count_query")
441
+ total_count = count_result.first["count"].to_i
442
+ truncated = total_count > 100
443
+ end
360
444
  end
361
445
 
362
446
  render json: {
@@ -368,6 +452,8 @@ module PgReports
368
452
  truncated: truncated,
369
453
  execution_time: execution_time
370
454
  }
455
+ rescue ActiveRecord::QueryCanceled
456
+ render json: {success: false, error: query_timed_out_message}, status: :unprocessable_entity
371
457
  rescue => e
372
458
  render json: {success: false, error: e.message}, status: :unprocessable_entity
373
459
  end
@@ -450,7 +536,8 @@ module PgReports
450
536
  success: true,
451
537
  enabled: status[:enabled],
452
538
  session_id: status[:session_id],
453
- query_count: status[:query_count]
539
+ query_count: status[:query_count],
540
+ history_available: status[:history_available]
454
541
  }
455
542
  rescue => e
456
543
  render json: {success: false, error: e.message}, status: :unprocessable_entity
@@ -666,8 +753,31 @@ module PgReports
666
753
  # otherwise a localized string explaining why it is disabled. Exposed to
667
754
  # views via helper_method.
668
755
  def category_disabled_reason(category)
756
+ # pg_stat_statements is created per-database. A category that reads it is
757
+ # available on one database in the cluster and missing on the next, so the
758
+ # check belongs here — where every entry point (show, run, download,
759
+ # telegram) already asks — rather than in the dashboard template alone.
760
+ # Only meaningful once we know the connection itself works: an unreachable
761
+ # database has its own banner and must not be reported as a missing
762
+ # extension.
763
+ if Dashboard::ReportsRegistry.requires(category) == :pg_stat_statements &&
764
+ pg_stat_status[:connected] && !pg_stat_status[:ready]
765
+ return pg_stat_unavailable_reason
766
+ end
767
+
669
768
  constraint = Dashboard::ReportsRegistry.target_constraint(category)
670
769
  return nil unless constraint == :primary_default_database_only
770
+
771
+ # In standalone mode there is no host application, so its ActiveRecord
772
+ # models don't exist — these reports would inspect nothing. Disable the
773
+ # whole category with an explanation instead of returning empty results.
774
+ if PgReports.config.standalone
775
+ return I18n.t("pg_reports.ui.categories.standalone_no_host_app_reason",
776
+ default: "🔒 This category isn't available in standalone mode — it " \
777
+ "inspects the host application's models, and there is no host app " \
778
+ "when running the dashboard on its own.")
779
+ end
780
+
671
781
  return nil if on_primary_default_database?
672
782
 
673
783
  I18n.t("pg_reports.ui.categories.primary_only_reason",
@@ -680,6 +790,89 @@ module PgReports
680
790
  category_disabled_reason(category).present?
681
791
  end
682
792
 
793
+ # A report failing because the selected database lacks an extension-provided
794
+ # relation is the common case here (extensions are per-database, the
795
+ # dashboard's database picker is not), and "PG::UndefinedTable: ERROR:
796
+ # relation ... does not exist LINE 11:" is not something to show a user.
797
+ def report_error_message(error)
798
+ info = Connection::ErrorTranslator.translate(error)
799
+ [info[:detail], info[:hint]].reject { |part| part.to_s.strip.empty? }.join(" ")
800
+ rescue
801
+ error.message
802
+ end
803
+
804
+ # Scoped to the request's selected database by the surrounding
805
+ # #within_selected_database, and memoized because the dashboard asks once
806
+ # per category while rendering the grid.
807
+ def pg_stat_status
808
+ @pg_stat_status ||= PgReports.pg_stat_statements_status
809
+ end
810
+
811
+ # The two failure modes need different remedies: an extension that was never
812
+ # created here can be created, one that isn't preloaded needs a restart.
813
+ def pg_stat_unavailable_reason
814
+ database = @selected_database.presence || PgReports.system.current_database
815
+
816
+ if pg_stat_status[:extension_installed]
817
+ I18n.t("pg_reports.ui.categories.pg_stat_not_preloaded_reason",
818
+ database: database,
819
+ default: "Requires pg_stat_statements. The extension exists on " \
820
+ "\"%{database}\" but is not in shared_preload_libraries, so it " \
821
+ "returns no data until PostgreSQL is restarted with it preloaded.")
822
+ else
823
+ I18n.t("pg_reports.ui.categories.pg_stat_missing_reason",
824
+ database: database,
825
+ default: "Requires pg_stat_statements, which is not installed on " \
826
+ "\"%{database}\". Create the extension there, or switch to a " \
827
+ "database that has it.")
828
+ end
829
+ end
830
+
831
+ # SQL Query Monitor taps ActiveSupport::Notifications in the host
832
+ # application's process. In standalone mode there is no host app — pg_reports
833
+ # is the only process running, so there's nothing meaningful to observe.
834
+ # Blocks the API even if a client calls it directly (the UI panel is also
835
+ # hidden in standalone, see dashboard/index view).
836
+ def block_query_monitor_in_standalone
837
+ return unless PgReports.config.standalone
838
+
839
+ render json: {
840
+ success: false,
841
+ error: I18n.t("pg_reports.ui.errors.query_monitor_unavailable_standalone")
842
+ }, status: :forbidden
843
+ end
844
+
845
+ # Soft per-IP throttle for the dashboard's privileged raw-query and
846
+ # migration endpoints. This is not a hardened distributed rate limiter —
847
+ # just a best-effort guard against a single client hammering these
848
+ # expensive/privileged actions, backed by Rails.cache (works with or
849
+ # without a shared cache backend across processes). Fails open if the
850
+ # cache is unavailable, consistent with the rest of the dashboard (see
851
+ # #resolve_database_selection, #retrieve_query_by_hash).
852
+ def enforce_rate_limit!
853
+ limit = PgReports.config.raw_query_rate_limit
854
+ return if limit.nil?
855
+
856
+ window = PgReports.config.raw_query_rate_limit_window_seconds
857
+ key = "pg_reports:rate_limit:#{request.remote_ip}:#{params[:action]}"
858
+
859
+ count = begin
860
+ current = (Rails.cache.read(key) || 0) + 1
861
+ Rails.cache.write(key, current, expires_in: window)
862
+ current
863
+ rescue => e
864
+ Rails.logger.warn("PgReports: Rate limit cache unavailable: #{e.message}") if defined?(Rails.logger)
865
+ return
866
+ end
867
+
868
+ return if count <= limit
869
+
870
+ render json: {
871
+ success: false,
872
+ error: I18n.t("pg_reports.ui.errors.rate_limit_exceeded")
873
+ }, status: :too_many_requests
874
+ end
875
+
683
876
  def on_primary_default_database?
684
877
  return true if @target_default_database.nil?
685
878
 
@@ -768,7 +961,15 @@ module PgReports
768
961
  return nil
769
962
  end
770
963
 
771
- # Strict validation: must be a SELECT query only
964
+ enforce_select_only!(query)
965
+
966
+ query
967
+ end
968
+
969
+ # Strict validation: must be a single SELECT statement, no dangerous
970
+ # keywords. This is a denylist, not a sandbox (see docs/configuration.md)
971
+ # — shared by #retrieve_query_by_hash and the free-text #run_query action.
972
+ def enforce_select_only!(query)
772
973
  normalized = query.strip.gsub(/\s+/, " ").downcase
773
974
 
774
975
  # Check for semicolons (prevents multiple statements)
@@ -788,8 +989,24 @@ module PgReports
788
989
  raise SecurityError, "Dangerous keyword detected: #{keyword.upcase}"
789
990
  end
790
991
  end
992
+ end
791
993
 
792
- query
994
+ # Bounds how long a single raw-query execution (Execute Query / EXPLAIN
995
+ # ANALYZE / SQL Console) can run, so a runaway query can't hang the
996
+ # connection indefinitely. SET LOCAL only takes effect inside a
997
+ # transaction and reverts automatically at its end (commit or rollback) —
998
+ # safe here since every caller only ever runs SELECTs.
999
+ def with_statement_timeout
1000
+ ActiveRecord::Base.transaction do
1001
+ timeout_ms = PgReports.config.raw_query_statement_timeout_ms.to_i
1002
+ ActiveRecord::Base.connection.execute("SET LOCAL statement_timeout = #{timeout_ms}") if timeout_ms.positive?
1003
+ yield
1004
+ end
1005
+ end
1006
+
1007
+ def query_timed_out_message
1008
+ I18n.t("pg_reports.ui.errors.query_timed_out",
1009
+ ms: PgReports.config.raw_query_statement_timeout_ms.to_i)
793
1010
  end
794
1011
 
795
1012
  def generate_query_monitor_csv(queries)