pg_reports 0.8.0 → 0.8.2

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: 70975bd7aa15cac037bc8ac3f658c7cabe1e7492a3c37a1db588c92bce3d0f3d
4
- data.tar.gz: 587f0a5474d427303c483603467155c4e0602ff604d5628c39e45d7137d1354d
3
+ metadata.gz: 2800846cf0c02bdf3a2605151d0bfe7b7775a1eda3a1a920eed26bc3f731748d
4
+ data.tar.gz: 6f07d07fcd9e396d6c9dd885f9e9c2e0c9d10595f5b8817eba59a2c8c734981a
5
5
  SHA512:
6
- metadata.gz: 3174ba9362c38d77da9a41653453de23e6c462fac8be71ceacf82b645fffb68343eb09421df8cfbab9f1100d1ca70fbca6234bc3a4450e83e5be9c35079653da
7
- data.tar.gz: 70f91b168cc0890414741f75de6aea545400d738ef2a00e5a664a7555aac9bcfd923ba53ce169ca2ffa610efe9dbd95ff26902c77dcbfcf79b872421cf63e644
6
+ metadata.gz: 2c5165e3eaff390afb0f9369aa6a9a2351dbe410cf64fd26a5df0be6cd3492513d0510d84758886dfa42d04b378e524d8483490f991ab63f3cef25ed8594b088
7
+ data.tar.gz: 312d69fcb4a15c0216a1381ec04d9db6bf4811b326b89e70ecd5ce5ed43974eddc38ab6ed3c59e7358eb4ce2e1f0fd7ffc054be0da747f3d903f40b808e205c1
data/CHANGELOG.md CHANGED
@@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.2] - 2026-07-10
11
+
12
+ ### Added
13
+
14
+ - **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.
15
+ - **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`.
16
+ - **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.
17
+
18
+ ### Changed
19
+
20
+ - **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.
21
+
22
+ ## [0.8.1] - 2026-07-03
23
+
24
+ ### Added
25
+
26
+ - **Standalone mode — run the dashboard without a host Rails app.** A new `pg_reports server` executable (and `rake pg_reports:server` task) boots a minimal Rails application that mounts the engine at `/` and serves it on port **4000**, straight from the gem's root folder. The connection is resolved from `--database-url`, then `DATABASE_URL`, then libpq-style `PG*` env vars; flags cover `--port`, `--host`, `--mount`, and `--server`. All existing multi-database / multi-cluster switching works unchanged, since the connection registry auto-registers the standalone connection as the `:primary` target.
27
+ - **No new runtime dependencies.** `rack` and `rackup` already ship transitively via `actionpack`/`railties`; the web server (Puma, WEBrick, …) is resolved at run time and is not a hard dependency — the runner uses whichever is installed and prints a clear message if none is.
28
+ - New `PgReports::Standalone` module encapsulating app construction, connection resolution, and server boot.
29
+ - `./bin/pg_reports` runs from a checkout without `bundle exec` via a soft bundler shim (activated only when a Gemfile sits next to the executable; skipped for the installed gem).
30
+ - **[docs/standalone.md](docs/standalone.md).**
31
+ - **Dashboard footer** with links to the project on GitHub and a contact address.
32
+ - **ESC closes any open modal.** A single global handler triggers the modal's own close button, so per-modal cleanup still runs.
33
+
34
+ ### Changed
35
+
36
+ - **Redesigned the pg_stat_statements status badge.** The header badge now reports four clearly distinguished states with plain-language labels instead of a raw identifier glued to an adjective (`pg_stat_statements готовий`):
37
+ - 🟢 **Active** — monitoring works.
38
+ - 🟡 **Preload required** — the extension exists but isn't in `shared_preload_libraries`; clicking the badge opens the setup instructions.
39
+ - 🟡 **Extension required** — the library is loaded but the extension isn't created; clicking opens a modal with a one-click **Create extension** button.
40
+ - 🔴 **No connection** — the database itself is unreachable.
41
+ Warning/error badges are now clickable and self-explanatory. The redundant `?` info button and the header **Create extension** button were removed in favor of the badge-driven modals. Labels no longer use a negative "Not …" framing. Translations updated for `en` / `uk` / `ru`.
42
+ - **pg_stat_statements status detection no longer reads `shared_preload_libraries`.** That setting requires the `pg_read_all_settings` role and is unreadable by a typical monitoring user, which made the "preloaded but extension missing" state unreachable. State is now derived entirely from signals every role can observe: connectivity (`SELECT 1`), extension presence in `pg_extension`, and whether the `pg_stat_statements` view is queryable. `pg_stat_statements_status` gains a `connected:` key, and `PgReports.system.connected?` is a new public helper.
43
+ - **Primary buttons toned down.** `btn-primary` (Start monitoring, Run report, Create extension, …) switched from a solid accent fill to a subtle tinted style, so it reads as the accent action without dominating the page.
44
+ - **Live-metrics "long queries" threshold lowered from 60s to 5s** — the default the top-of-dashboard tile counts against.
45
+ - **Header/layout polish** — the status badge, settings button, and Reset button are unified to the same height; the settings button is now square; the pg_stat_statements category warning banner no longer overhangs its card; and the "scope: host application" note in the query monitor uses a real styled tooltip (hover + keyboard focus) instead of the unreliable native `title`.
46
+ - **README trimmed**: standalone, Telegram, and Grafana/Prometheus details moved to dedicated docs ([docs/standalone.md](docs/standalone.md), [docs/telegram.md](docs/telegram.md), [docs/grafana.md](docs/grafana.md)).
47
+
48
+ ### Fixed
49
+
50
+ - **Query Monitor no longer records pg_reports' own queries.** Internal queries (live-metrics polling, status checks, database listing) run through `Executor`/`Target` are now tagged with a `"PgReports"` statement name, so `QueryMonitor#should_skip?` filters them by name regardless of backtrace depth. Previously the deep `ActiveSupport::Notifications` stack could push the pg_reports frames past the 30-frame backtrace scan, leaking these queries into the monitor history.
51
+ - **Dashboard requests broke when the engine is mounted at the root path** (e.g. standalone). The client base path resolved to `/`, so `fetch` URLs became `//live_metrics` — a protocol-relative URL the browser sent to a bogus host, breaking live metrics and report runs. The base now strips a trailing slash.
52
+
10
53
  ## [0.8.0] - 2026-05-01
11
54
 
12
55
  ### 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
@@ -61,6 +66,18 @@ Visit `http://localhost:3000/pg_reports`.
61
66
 
62
67
  For query analysis, also enable `pg_stat_statements` — see [setup instructions in docs/configuration.md](docs/configuration.md#pg_stat_statements-setup).
63
68
 
69
+ ## Standalone (no host app)
70
+
71
+ You can also run the dashboard on its own, straight from the gem's root folder — no Rails app to mount it in. It serves at `/` on port **4000** and connects via `DATABASE_URL` or libpq env vars:
72
+
73
+ ```bash
74
+ ./bin/pg_reports server # from a checkout; no `bundle exec` needed
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
77
+ ```
78
+
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)**
80
+
64
81
  ## Usage
65
82
 
66
83
  ```ruby
@@ -73,12 +90,9 @@ report = PgReports.expensive_queries
73
90
  report.to_text
74
91
  report.to_csv
75
92
  report.to_a
76
-
77
- # Telegram
78
- PgReports.slow_queries.send_to_telegram
79
93
  ```
80
94
 
81
- **[Full list of reports →](docs/reports.md)**
95
+ **[Full list of reports →](docs/reports.md)**  ·  **[Send reports to Telegram →](docs/telegram.md)**
82
96
 
83
97
  ## Multi-database
84
98
 
@@ -100,8 +114,6 @@ PgReports works out of the box once mounted. Common options:
100
114
  ```ruby
101
115
  # config/initializers/pg_reports.rb
102
116
  PgReports.configure do |config|
103
- config.telegram_bot_token = ENV["PG_REPORTS_TELEGRAM_TOKEN"]
104
- config.telegram_chat_id = ENV["PG_REPORTS_TELEGRAM_CHAT_ID"]
105
117
  config.slow_query_threshold_ms = 100
106
118
  config.unused_index_threshold_scans = 50
107
119
  config.bloat_threshold_percent = 20
@@ -115,8 +127,8 @@ PgReports.configure do |config|
115
127
  end
116
128
  ```
117
129
 
118
- **Multi-database, thresholds, query monitor, Grafana, raw query execution, source tracking, locale, Telegram —**
119
- **[full reference in docs/configuration.md →](docs/configuration.md)**
130
+ **Multi-database, thresholds, query monitor, raw query execution, source tracking, locale —**
131
+ **[full reference in docs/configuration.md →](docs/configuration.md)**  ·  **[Telegram](docs/telegram.md)**  ·  **[Grafana / Prometheus](docs/grafana.md)**
120
132
 
121
133
  ## Report object
122
134
 
@@ -172,6 +184,19 @@ Requires `config.allow_raw_query_execution = true`.
172
184
 
173
185
  </details>
174
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
+
175
200
  <details>
176
201
  <summary><strong>SQL Query Monitor — real-time query capture</strong></summary>
177
202
 
@@ -194,6 +219,8 @@ end
194
219
 
195
220
  Use cases: debugging N+1, identifying slow queries during feature development, tracking down unexpected queries, teaching ActiveRecord behavior.
196
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
+
197
224
  </details>
198
225
 
199
226
  <details>
@@ -240,66 +267,9 @@ The Export dropdown includes **Copy Prompt** (visible on actionable reports). It
240
267
  <details>
241
268
  <summary><strong>Grafana / Prometheus exporter</strong></summary>
242
269
 
243
- Expose selected reports at `<mount_point>/metrics` in Prometheus exposition format. The default mount is `/pg_reports`, so the endpoint is typically `/pg_reports/metrics` but it follows whatever path you used in `mount PgReports::Engine, at: "..."`. Severity (`ok` / `warning` / `critical`) is derived automatically from the thresholds defined in [`Dashboard::ReportsRegistry::REPORT_CONFIG`](lib/pg_reports/dashboard/reports_registry.rb).
244
-
245
- ```ruby
246
- PgReports.configure do |config|
247
- config.grafana_favorites = [
248
- :slow_queries,
249
- :unused_indexes,
250
- :bloated_tables,
251
- :missing_validations,
252
- :polymorphic_without_index
253
- ]
254
- config.grafana_metrics_token = ENV["PG_REPORTS_METRICS_TOKEN"] # optional bearer token
255
- config.grafana_cache_ttl = 60 # seconds
256
- end
257
- ```
258
-
259
- Scrape with Prometheus:
260
-
261
- ```yaml
262
- scrape_configs:
263
- - job_name: pg_reports
264
- metrics_path: /pg_reports/metrics # adjust to your Engine mount point
265
- scrape_interval: 60s
266
- authorization: { credentials: "${PG_REPORTS_METRICS_TOKEN}" }
267
- static_configs:
268
- - targets: ["app.internal:3000"]
269
- ```
270
-
271
- > [!WARNING]
272
- > Reports are cached via `Rails.cache` for `grafana_cache_ttl` so frequent scrapes don't hammer the database. Without it, Prometheus' default 15s scrape interval against heavy reports like `missing_validations` will DDoS your own DB. Always set a TTL ≥ scrape interval, and consider a longer per-report TTL for expensive reports.
273
-
274
- The exporter also emits a `pg_reports_row` series per report row (each column becomes a Prometheus label), so the auto-generated dashboard can show a **table panel** with the actual rows that need fixing — not just an aggregate count.
275
-
276
- Generate a matching Grafana dashboard from the same favorites:
277
-
278
- ```bash
279
- bundle exec rake pg_reports:grafana:dashboard
280
- # writes pg_reports.json in pwd; then Dashboards → Import in Grafana
281
- ```
282
-
283
- **[Full Grafana integration guide →](docs/grafana.md)** &nbsp;·&nbsp; **[Local Prometheus + Grafana without Docker →](docs/grafana-local-setup.md)**
284
-
285
- </details>
286
-
287
- <details>
288
- <summary><strong>Telegram delivery</strong></summary>
289
-
290
- Get a bot token from [@BotFather](https://t.me/BotFather) and your chat ID from [@userinfobot](https://t.me/userinfobot), then:
291
-
292
- ```ruby
293
- PgReports.configure do |config|
294
- config.telegram_bot_token = "123456:ABC-DEF..."
295
- config.telegram_chat_id = "-1001234567890"
296
- end
297
-
298
- PgReports.slow_queries.send_to_telegram
299
- PgReports.health_report.send_to_telegram_as_file
300
- ```
270
+ Expose selected reports at `<mount_point>/metrics` in Prometheus exposition format, with severity (`ok` / `warning` / `critical`) derived automatically from each report's thresholds. Reports are cached per a configurable TTL so frequent scrapes don't hammer the database, and a matching Grafana dashboard can be generated from the same favorites (`rake pg_reports:grafana:dashboard`).
301
271
 
302
- Reports under ~50 rows go as a message; larger ones are sent as a file attachment.
272
+ **[Grafana / Prometheus integration guide →](docs/grafana.md)** &nbsp;·&nbsp; **[Local Prometheus + Grafana without Docker →](docs/grafana-local-setup.md)**
303
273
 
304
274
  </details>
305
275
 
@@ -323,8 +293,8 @@ bundle exec rubocop
323
293
 
324
294
  ## License
325
295
 
326
- MIT. See [LICENSE.txt](LICENSE.txt).
296
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
327
297
 
328
298
  ## Acknowledgments
329
299
 
330
- Inspired by [rails-pg-extras](https://github.com/pawurb/rails-pg-extras). UI built with [Claude](https://www.anthropic.com/claude) by Anthropic.
300
+ Inspired by [rails-pg-extras](https://github.com/pawurb/rails-pg-extras) and built with ❤️ for the Rails community.
@@ -15,6 +15,13 @@ 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
 
@@ -70,7 +77,7 @@ module PgReports
70
77
  end
71
78
 
72
79
  def live_metrics
73
- threshold = params[:long_query_threshold]&.to_i || 60
80
+ threshold = params[:long_query_threshold]&.to_i || 5
74
81
 
75
82
  # Check if we have access to required statistics
76
83
  begin
@@ -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
377
+
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
392
+
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
347
401
 
348
- rows = result.to_a
349
- columns = rows.first&.keys || []
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
350
406
 
351
- # Check if we need to get total count
352
- total_count = rows.size
353
- truncated = false
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
354
421
 
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
422
+ limited_query = add_limit_if_missing(raw_query, 100)
423
+
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
@@ -668,6 +754,17 @@ module PgReports
668
754
  def category_disabled_reason(category)
669
755
  constraint = Dashboard::ReportsRegistry.target_constraint(category)
670
756
  return nil unless constraint == :primary_default_database_only
757
+
758
+ # In standalone mode there is no host application, so its ActiveRecord
759
+ # models don't exist — these reports would inspect nothing. Disable the
760
+ # whole category with an explanation instead of returning empty results.
761
+ if PgReports.config.standalone
762
+ return I18n.t("pg_reports.ui.categories.standalone_no_host_app_reason",
763
+ default: "🔒 This category isn't available in standalone mode — it " \
764
+ "inspects the host application's models, and there is no host app " \
765
+ "when running the dashboard on its own.")
766
+ end
767
+
671
768
  return nil if on_primary_default_database?
672
769
 
673
770
  I18n.t("pg_reports.ui.categories.primary_only_reason",
@@ -680,6 +777,51 @@ module PgReports
680
777
  category_disabled_reason(category).present?
681
778
  end
682
779
 
780
+ # SQL Query Monitor taps ActiveSupport::Notifications in the host
781
+ # application's process. In standalone mode there is no host app — pg_reports
782
+ # is the only process running, so there's nothing meaningful to observe.
783
+ # Blocks the API even if a client calls it directly (the UI panel is also
784
+ # hidden in standalone, see dashboard/index view).
785
+ def block_query_monitor_in_standalone
786
+ return unless PgReports.config.standalone
787
+
788
+ render json: {
789
+ success: false,
790
+ error: I18n.t("pg_reports.ui.errors.query_monitor_unavailable_standalone")
791
+ }, status: :forbidden
792
+ end
793
+
794
+ # Soft per-IP throttle for the dashboard's privileged raw-query and
795
+ # migration endpoints. This is not a hardened distributed rate limiter —
796
+ # just a best-effort guard against a single client hammering these
797
+ # expensive/privileged actions, backed by Rails.cache (works with or
798
+ # without a shared cache backend across processes). Fails open if the
799
+ # cache is unavailable, consistent with the rest of the dashboard (see
800
+ # #resolve_database_selection, #retrieve_query_by_hash).
801
+ def enforce_rate_limit!
802
+ limit = PgReports.config.raw_query_rate_limit
803
+ return if limit.nil?
804
+
805
+ window = PgReports.config.raw_query_rate_limit_window_seconds
806
+ key = "pg_reports:rate_limit:#{request.remote_ip}:#{params[:action]}"
807
+
808
+ count = begin
809
+ current = (Rails.cache.read(key) || 0) + 1
810
+ Rails.cache.write(key, current, expires_in: window)
811
+ current
812
+ rescue => e
813
+ Rails.logger.warn("PgReports: Rate limit cache unavailable: #{e.message}") if defined?(Rails.logger)
814
+ return
815
+ end
816
+
817
+ return if count <= limit
818
+
819
+ render json: {
820
+ success: false,
821
+ error: I18n.t("pg_reports.ui.errors.rate_limit_exceeded")
822
+ }, status: :too_many_requests
823
+ end
824
+
683
825
  def on_primary_default_database?
684
826
  return true if @target_default_database.nil?
685
827
 
@@ -768,7 +910,15 @@ module PgReports
768
910
  return nil
769
911
  end
770
912
 
771
- # Strict validation: must be a SELECT query only
913
+ enforce_select_only!(query)
914
+
915
+ query
916
+ end
917
+
918
+ # Strict validation: must be a single SELECT statement, no dangerous
919
+ # keywords. This is a denylist, not a sandbox (see docs/configuration.md)
920
+ # — shared by #retrieve_query_by_hash and the free-text #run_query action.
921
+ def enforce_select_only!(query)
772
922
  normalized = query.strip.gsub(/\s+/, " ").downcase
773
923
 
774
924
  # Check for semicolons (prevents multiple statements)
@@ -788,8 +938,24 @@ module PgReports
788
938
  raise SecurityError, "Dangerous keyword detected: #{keyword.upcase}"
789
939
  end
790
940
  end
941
+ end
791
942
 
792
- query
943
+ # Bounds how long a single raw-query execution (Execute Query / EXPLAIN
944
+ # ANALYZE / SQL Console) can run, so a runaway query can't hang the
945
+ # connection indefinitely. SET LOCAL only takes effect inside a
946
+ # transaction and reverts automatically at its end (commit or rollback) —
947
+ # safe here since every caller only ever runs SELECTs.
948
+ def with_statement_timeout
949
+ ActiveRecord::Base.transaction do
950
+ timeout_ms = PgReports.config.raw_query_statement_timeout_ms.to_i
951
+ ActiveRecord::Base.connection.execute("SET LOCAL statement_timeout = #{timeout_ms}") if timeout_ms.positive?
952
+ yield
953
+ end
954
+ end
955
+
956
+ def query_timed_out_message
957
+ I18n.t("pg_reports.ui.errors.query_timed_out",
958
+ ms: PgReports.config.raw_query_statement_timeout_ms.to_i)
793
959
  end
794
960
 
795
961
  def generate_query_monitor_csv(queries)