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 +4 -4
- data/CHANGELOG.md +43 -0
- data/README.md +40 -70
- data/app/controllers/pg_reports/dashboard_controller.rb +185 -19
- data/app/views/layouts/pg_reports/application.html.erb +88 -6
- data/app/views/pg_reports/dashboard/index.html.erb +391 -23
- data/bin/pg_reports +111 -0
- data/config/brakeman.ignore +74 -0
- data/config/locales/en.yml +19 -5
- data/config/locales/ru.yml +19 -5
- data/config/locales/uk.yml +19 -5
- data/config/routes.rb +1 -0
- data/lib/pg_reports/configuration.rb +8 -0
- data/lib/pg_reports/connection/target.rb +1 -1
- data/lib/pg_reports/executor.rb +7 -2
- data/lib/pg_reports/grafana/exporter.rb +5 -1
- data/lib/pg_reports/modules/system.rb +28 -4
- data/lib/pg_reports/standalone.rb +210 -0
- data/lib/pg_reports/version.rb +1 -1
- data/lib/pg_reports.rb +9 -0
- metadata +7 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2800846cf0c02bdf3a2605151d0bfe7b7775a1eda3a1a920eed26bc3f731748d
|
|
4
|
+
data.tar.gz: 6f07d07fcd9e396d6c9dd885f9e9c2e0c9d10595f5b8817eba59a2c8c734981a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|

|
|
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
|
-
-
|
|
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,
|
|
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
|
|
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)** · **[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
|
-
|
|
272
|
+
**[Grafana / Prometheus integration guide →](docs/grafana.md)** · **[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
|
-
|
|
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)
|
|
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 ||
|
|
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
|
-
|
|
279
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
execution_time =
|
|
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
|
-
|
|
349
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|