pg_reports 0.8.1 → 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 +12 -0
- data/README.md +23 -2
- data/app/controllers/pg_reports/dashboard_controller.rb +184 -18
- data/app/views/pg_reports/dashboard/index.html.erb +290 -1
- data/bin/pg_reports +28 -2
- data/config/brakeman.ignore +74 -0
- data/config/locales/en.yml +10 -0
- data/config/locales/ru.yml +10 -0
- data/config/locales/uk.yml +10 -0
- data/config/routes.rb +1 -0
- data/lib/pg_reports/configuration.rb +8 -0
- data/lib/pg_reports/grafana/exporter.rb +5 -1
- data/lib/pg_reports/standalone.rb +59 -1
- data/lib/pg_reports/version.rb +1 -1
- data/lib/pg_reports.rb +5 -0
- metadata +2 -1
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,18 @@ 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
|
+
|
|
10
22
|
## [0.8.1] - 2026-07-03
|
|
11
23
|
|
|
12
24
|
### 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
|
|
@@ -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,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
|
|
|
@@ -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)
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
</div>
|
|
24
24
|
|
|
25
25
|
<div class="header-actions">
|
|
26
|
+
<button class="btn btn-small btn-primary" onclick="showRunQueryModal()" id="run-query-open-btn">
|
|
27
|
+
<%= t("pg_reports.ui.actions.run_query") %>
|
|
28
|
+
</button>
|
|
26
29
|
<% if @pg_stat_status[:ready] %>
|
|
27
30
|
<button class="btn btn-small btn-muted" onclick="showResetConfirmModal()" id="reset-btn">
|
|
28
31
|
<%= t("pg_reports.ui.actions.reset_statistics") %>
|
|
@@ -167,6 +170,42 @@ pg_stat_statements.track = all</pre>
|
|
|
167
170
|
</div>
|
|
168
171
|
</div>
|
|
169
172
|
|
|
173
|
+
<!-- Run Query Modal -->
|
|
174
|
+
<div id="run-query-modal" class="modal" style="display: none;">
|
|
175
|
+
<div class="modal-content modal-run-query">
|
|
176
|
+
<div class="modal-header">
|
|
177
|
+
<h3><%= t("pg_reports.ui.modals.run_query_title") %></h3>
|
|
178
|
+
<button class="modal-close" onclick="closeRunQueryModal()">×</button>
|
|
179
|
+
</div>
|
|
180
|
+
<div class="modal-body">
|
|
181
|
+
<% if PgReports.config.allow_raw_query_execution %>
|
|
182
|
+
<label class="explain-label" for="run-query-input"><%= t("pg_reports.ui.modals.query_label") %></label>
|
|
183
|
+
<textarea id="run-query-input" class="run-query-textarea" spellcheck="false" placeholder="<%= t("pg_reports.ui.modals.run_query_placeholder") %>"></textarea>
|
|
184
|
+
<div class="run-query-actions">
|
|
185
|
+
<button class="btn btn-secondary" onclick="clearRunQuery()"><%= t("pg_reports.ui.actions.clear_all") %></button>
|
|
186
|
+
<button class="btn btn-primary" onclick="executeRunQuery()" id="btn-run-query"><%= t("pg_reports.ui.actions.run_query") %></button>
|
|
187
|
+
</div>
|
|
188
|
+
<div id="run-query-loading" class="loading" style="display: none;">
|
|
189
|
+
<div class="spinner"></div>
|
|
190
|
+
</div>
|
|
191
|
+
<div id="run-query-content"></div>
|
|
192
|
+
<% else %>
|
|
193
|
+
<div class="error-message">
|
|
194
|
+
<strong><%= t("pg_reports.ui.modals.query_execution_disabled_title") %></strong><br><br>
|
|
195
|
+
<%= t("pg_reports.ui.modals.query_execution_disabled_intro") %><br>
|
|
196
|
+
<code style="display: block; margin-top: 0.5rem; padding: 0.5rem; background: rgba(0,0,0,0.2); border-radius: 4px;">
|
|
197
|
+
PgReports.configure do |config|<br>
|
|
198
|
+
config.allow_raw_query_execution = true<br>
|
|
199
|
+
end
|
|
200
|
+
</code>
|
|
201
|
+
<p style="margin-top: 0.75rem;"><%= t("pg_reports.ui.modals.query_execution_disabled_env_note") %></p>
|
|
202
|
+
<code style="display: block; margin-top: 0.5rem; padding: 0.5rem; background: rgba(0,0,0,0.2); border-radius: 4px;">PG_REPORTS_ALLOW_RAW_QUERY_EXECUTION=true</code>
|
|
203
|
+
</div>
|
|
204
|
+
<% end %>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
170
209
|
<!-- Live Monitoring Panel -->
|
|
171
210
|
<div id="live-monitoring" class="live-monitoring-panel" style="display: none;">
|
|
172
211
|
<div class="live-monitoring-header">
|
|
@@ -286,7 +325,11 @@ pg_stat_statements.track = all</pre>
|
|
|
286
325
|
</div>
|
|
287
326
|
</div>
|
|
288
327
|
|
|
289
|
-
<!-- Query Monitoring Panel
|
|
328
|
+
<!-- Query Monitoring Panel: taps ActiveSupport::Notifications in the host
|
|
329
|
+
application's process. There is no host app in standalone mode, so the
|
|
330
|
+
panel is hidden entirely rather than showing a monitor with nothing to
|
|
331
|
+
monitor. -->
|
|
332
|
+
<% unless PgReports.config.standalone %>
|
|
290
333
|
<div id="query-monitoring" class="query-monitoring-panel" style="display: none;">
|
|
291
334
|
<div class="query-monitoring-header">
|
|
292
335
|
<div class="query-monitoring-title">
|
|
@@ -331,6 +374,7 @@ pg_stat_statements.track = all</pre>
|
|
|
331
374
|
</div>
|
|
332
375
|
</div>
|
|
333
376
|
</div>
|
|
377
|
+
<% end %>
|
|
334
378
|
|
|
335
379
|
<div class="categories-grid">
|
|
336
380
|
<% @categories.each do |category_key, category| %>
|
|
@@ -907,9 +951,254 @@ pg_stat_statements.track = all</pre>
|
|
|
907
951
|
.toast.toast-warning {
|
|
908
952
|
border-left: 3px solid var(--accent-amber);
|
|
909
953
|
}
|
|
954
|
+
|
|
955
|
+
/* Run Query Modal */
|
|
956
|
+
.modal-run-query {
|
|
957
|
+
max-width: 1100px;
|
|
958
|
+
width: 95%;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
.explain-label {
|
|
962
|
+
display: block;
|
|
963
|
+
font-size: 0.75rem;
|
|
964
|
+
font-weight: 600;
|
|
965
|
+
color: var(--text-muted);
|
|
966
|
+
text-transform: uppercase;
|
|
967
|
+
margin-bottom: 0.5rem;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
.run-query-textarea {
|
|
971
|
+
width: 100%;
|
|
972
|
+
min-height: 180px;
|
|
973
|
+
padding: 1rem;
|
|
974
|
+
background: var(--bg-primary);
|
|
975
|
+
border: 1px solid var(--border-color);
|
|
976
|
+
border-radius: 8px;
|
|
977
|
+
color: var(--text-primary);
|
|
978
|
+
font-family: 'JetBrains Mono', monospace;
|
|
979
|
+
font-size: 0.85rem;
|
|
980
|
+
line-height: 1.5;
|
|
981
|
+
resize: vertical;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
.run-query-textarea:focus {
|
|
985
|
+
outline: none;
|
|
986
|
+
border-color: var(--accent-blue);
|
|
987
|
+
box-shadow: 0 0 0 2px rgba(107, 159, 232, 0.2);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
.run-query-textarea::placeholder {
|
|
991
|
+
color: var(--text-muted);
|
|
992
|
+
font-style: italic;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
.run-query-actions {
|
|
996
|
+
display: flex;
|
|
997
|
+
gap: 0.75rem;
|
|
998
|
+
margin: 1rem 0;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
.run-query-actions .btn {
|
|
1002
|
+
flex: 1;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/* Query Results Table (mirrors _show_styles.html.erb — index.html.erb has
|
|
1006
|
+
its own <style> block and isn't loaded through that partial) */
|
|
1007
|
+
.query-results-wrapper {
|
|
1008
|
+
margin-top: 1rem;
|
|
1009
|
+
max-height: 400px;
|
|
1010
|
+
overflow: auto;
|
|
1011
|
+
border: 1px solid var(--border-color);
|
|
1012
|
+
border-radius: 8px;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
.query-results-table {
|
|
1016
|
+
width: 100%;
|
|
1017
|
+
border-collapse: collapse;
|
|
1018
|
+
font-family: 'JetBrains Mono', monospace;
|
|
1019
|
+
font-size: 0.75rem;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
.query-results-table th {
|
|
1023
|
+
position: sticky;
|
|
1024
|
+
top: 0;
|
|
1025
|
+
padding: 0.75rem;
|
|
1026
|
+
text-align: left;
|
|
1027
|
+
background: var(--bg-tertiary);
|
|
1028
|
+
color: var(--text-muted);
|
|
1029
|
+
font-weight: 500;
|
|
1030
|
+
text-transform: uppercase;
|
|
1031
|
+
font-size: 0.65rem;
|
|
1032
|
+
letter-spacing: 0.05em;
|
|
1033
|
+
border-bottom: 1px solid var(--border-color);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
.query-results-table td {
|
|
1037
|
+
padding: 0.5rem 0.75rem;
|
|
1038
|
+
border-bottom: 1px solid var(--border-color);
|
|
1039
|
+
color: var(--text-secondary);
|
|
1040
|
+
max-width: 300px;
|
|
1041
|
+
overflow: hidden;
|
|
1042
|
+
text-overflow: ellipsis;
|
|
1043
|
+
white-space: nowrap;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
.query-results-table tr:hover td {
|
|
1047
|
+
background: var(--bg-tertiary);
|
|
1048
|
+
color: var(--text-primary);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
.query-results-info {
|
|
1052
|
+
display: flex;
|
|
1053
|
+
align-items: center;
|
|
1054
|
+
justify-content: space-between;
|
|
1055
|
+
padding: 0.75rem 1rem;
|
|
1056
|
+
background: var(--bg-tertiary);
|
|
1057
|
+
border-radius: 8px;
|
|
1058
|
+
margin-top: 1rem;
|
|
1059
|
+
font-size: 0.8rem;
|
|
1060
|
+
color: var(--text-secondary);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
.query-results-info .count {
|
|
1064
|
+
color: var(--accent-blue);
|
|
1065
|
+
font-weight: 600;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
.query-results-info .time {
|
|
1069
|
+
color: var(--accent-green);
|
|
1070
|
+
font-weight: 600;
|
|
1071
|
+
}
|
|
910
1072
|
</style>
|
|
911
1073
|
|
|
912
1074
|
<script>
|
|
1075
|
+
const allowRawQueryExecution = <%= PgReports.config.allow_raw_query_execution.to_s.downcase %>;
|
|
1076
|
+
|
|
1077
|
+
function escapeHtmlAttr(text) {
|
|
1078
|
+
return String(text)
|
|
1079
|
+
.replace(/&/g, '&')
|
|
1080
|
+
.replace(/"/g, '"')
|
|
1081
|
+
.replace(/'/g, ''')
|
|
1082
|
+
.replace(/</g, '<')
|
|
1083
|
+
.replace(/>/g, '>');
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function showRunQueryModal() {
|
|
1087
|
+
document.getElementById('run-query-modal').style.display = 'flex';
|
|
1088
|
+
document.getElementById('run-query-input')?.focus();
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function closeRunQueryModal() {
|
|
1092
|
+
document.getElementById('run-query-modal').style.display = 'none';
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
document.getElementById('run-query-modal')?.addEventListener('click', function(e) {
|
|
1096
|
+
if (e.target === this) closeRunQueryModal();
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
document.getElementById('run-query-input')?.addEventListener('keydown', function(e) {
|
|
1100
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
1101
|
+
e.preventDefault();
|
|
1102
|
+
executeRunQuery();
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
function clearRunQuery() {
|
|
1107
|
+
document.getElementById('run-query-input').value = '';
|
|
1108
|
+
document.getElementById('run-query-content').innerHTML = '';
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
async function executeRunQuery() {
|
|
1112
|
+
const input = document.getElementById('run-query-input');
|
|
1113
|
+
const loading = document.getElementById('run-query-loading');
|
|
1114
|
+
const content = document.getElementById('run-query-content');
|
|
1115
|
+
const query = input.value.trim();
|
|
1116
|
+
|
|
1117
|
+
if (!query) {
|
|
1118
|
+
showToast(PG_REPORTS_I18N.errors.query_required, 'error');
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
if (!allowRawQueryExecution) {
|
|
1123
|
+
showToast(PG_REPORTS_I18N.errors.execute_disabled_toast, 'error');
|
|
1124
|
+
content.innerHTML = `<div class="error-message">
|
|
1125
|
+
<strong>${PG_REPORTS_I18N.modals.query_execution_disabled_title}</strong><br><br>
|
|
1126
|
+
${PG_REPORTS_I18N.modals.query_execution_disabled_intro}<br>
|
|
1127
|
+
<code style="display: block; margin-top: 0.5rem; padding: 0.5rem; background: rgba(0,0,0,0.2); border-radius: 4px;">
|
|
1128
|
+
PgReports.configure do |config|<br>
|
|
1129
|
+
config.allow_raw_query_execution = true<br>
|
|
1130
|
+
end
|
|
1131
|
+
</code>
|
|
1132
|
+
</div>`;
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
loading.style.display = 'flex';
|
|
1137
|
+
content.innerHTML = '';
|
|
1138
|
+
|
|
1139
|
+
try {
|
|
1140
|
+
const response = await fetch(`${pgReportsRoot}/run_query`, {
|
|
1141
|
+
method: 'POST',
|
|
1142
|
+
headers: {
|
|
1143
|
+
'Content-Type': 'application/json',
|
|
1144
|
+
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
|
|
1145
|
+
},
|
|
1146
|
+
body: JSON.stringify({ query: query })
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
const data = await response.json();
|
|
1150
|
+
loading.style.display = 'none';
|
|
1151
|
+
|
|
1152
|
+
if (data.success) {
|
|
1153
|
+
let html = '';
|
|
1154
|
+
|
|
1155
|
+
html += `<div class="query-results-info">
|
|
1156
|
+
<span>${PG_REPORTS_I18N.results.rows_label} <span class="count">${data.count}</span></span>
|
|
1157
|
+
<span>${PG_REPORTS_I18N.results.execution_time_label} <span class="time">${data.execution_time} ms</span></span>
|
|
1158
|
+
</div>`;
|
|
1159
|
+
|
|
1160
|
+
if (data.rows && data.rows.length > 0) {
|
|
1161
|
+
html += '<div class="query-results-wrapper">';
|
|
1162
|
+
html += '<table class="query-results-table">';
|
|
1163
|
+
|
|
1164
|
+
html += '<thead><tr>';
|
|
1165
|
+
data.columns.forEach(col => {
|
|
1166
|
+
html += `<th>${escapeHtml(col)}</th>`;
|
|
1167
|
+
});
|
|
1168
|
+
html += '</tr></thead>';
|
|
1169
|
+
|
|
1170
|
+
html += '<tbody>';
|
|
1171
|
+
data.rows.forEach(row => {
|
|
1172
|
+
html += '<tr>';
|
|
1173
|
+
data.columns.forEach(col => {
|
|
1174
|
+
const value = row[col];
|
|
1175
|
+
const displayValue = value === null ? PG_REPORTS_I18N.results.null_placeholder : String(value);
|
|
1176
|
+
html += `<td title="${escapeHtmlAttr(displayValue)}">${escapeHtml(displayValue)}</td>`;
|
|
1177
|
+
});
|
|
1178
|
+
html += '</tr>';
|
|
1179
|
+
});
|
|
1180
|
+
html += '</tbody>';
|
|
1181
|
+
|
|
1182
|
+
html += '</table>';
|
|
1183
|
+
html += '</div>';
|
|
1184
|
+
|
|
1185
|
+
if (data.truncated) {
|
|
1186
|
+
html += `<p style="margin-top: 0.5rem; color: var(--text-muted); font-size: 0.75rem;">${pgReportsFormat(PG_REPORTS_I18N.results.showing_first_of_total, { count: data.rows.length, total: data.total_count })}</p>`;
|
|
1187
|
+
}
|
|
1188
|
+
} else {
|
|
1189
|
+
html += '<p style="margin-top: 1rem; color: var(--text-muted);">' + PG_REPORTS_I18N.results.no_rows_returned + '</p>';
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
content.innerHTML = html;
|
|
1193
|
+
} else {
|
|
1194
|
+
content.innerHTML = `<div class="error-message">${escapeHtml(data.error || PG_REPORTS_I18N.errors.run_query_failed)}</div>`;
|
|
1195
|
+
}
|
|
1196
|
+
} catch (error) {
|
|
1197
|
+
loading.style.display = 'none';
|
|
1198
|
+
content.innerHTML = `<div class="error-message">${PG_REPORTS_I18N.errors.network_error_prefix} ${escapeHtml(error.message)}</div>`;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
913
1202
|
function showPgStatInfo() {
|
|
914
1203
|
document.getElementById('pg-stat-modal').style.display = 'flex';
|
|
915
1204
|
}
|
data/bin/pg_reports
CHANGED
|
@@ -23,7 +23,16 @@ options = {
|
|
|
23
23
|
host: "127.0.0.1",
|
|
24
24
|
mount: "/",
|
|
25
25
|
database_url: nil,
|
|
26
|
-
server: nil
|
|
26
|
+
server: nil,
|
|
27
|
+
config_file: nil
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# Per-setting overrides from flags. nil means "unset" — the config file / ENV
|
|
31
|
+
# value is kept. Populated below and forwarded to Standalone.run(overrides:).
|
|
32
|
+
overrides = {
|
|
33
|
+
allow_raw_query_execution: nil,
|
|
34
|
+
allow_migration_creation: nil,
|
|
35
|
+
load_external_fonts: nil
|
|
27
36
|
}
|
|
28
37
|
|
|
29
38
|
banner = <<~BANNER
|
|
@@ -35,6 +44,12 @@ banner = <<~BANNER
|
|
|
35
44
|
Connection is resolved from --database-url, else DATABASE_URL, else the
|
|
36
45
|
standard libpq env vars (PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE).
|
|
37
46
|
|
|
47
|
+
Configuration is layered, lowest to highest precedence:
|
|
48
|
+
PG_REPORTS_* env vars < config file < the flags below.
|
|
49
|
+
A config file (--config, else ./pg_reports.rb or config/pg_reports.rb) is
|
|
50
|
+
plain Ruby calling `PgReports.configure` — the way to set every option that
|
|
51
|
+
has no flag (thresholds, Telegram, Grafana, dashboard_auth, …).
|
|
52
|
+
|
|
38
53
|
Options:
|
|
39
54
|
BANNER
|
|
40
55
|
|
|
@@ -45,6 +60,10 @@ parser = OptionParser.new do |o|
|
|
|
45
60
|
o.on("-m", "--mount PATH", "Path to mount the dashboard at (default: /)") { |v| options[:mount] = v }
|
|
46
61
|
o.on("-d", "--database-url URL", "PostgreSQL connection URL") { |v| options[:database_url] = v }
|
|
47
62
|
o.on("-s", "--server NAME", "Web server to use (e.g. puma, webrick)") { |v| options[:server] = v }
|
|
63
|
+
o.on("-c", "--config PATH", "Ruby config file to load (default: ./pg_reports.rb or config/pg_reports.rb)") { |v| options[:config_file] = v }
|
|
64
|
+
o.on("--[no-]allow-raw-query-execution", "Allow running raw SQL / EXPLAIN ANALYZE from the dashboard") { |v| overrides[:allow_raw_query_execution] = v }
|
|
65
|
+
o.on("--[no-]allow-migration-creation", "Allow the dashboard to write migration files to db/migrate/") { |v| overrides[:allow_migration_creation] = v }
|
|
66
|
+
o.on("--[no-]external-fonts", "Load Google Fonts in the dashboard (off by default)") { |v| overrides[:load_external_fonts] = v }
|
|
48
67
|
o.on("-h", "--help", "Show this help") do
|
|
49
68
|
puts o
|
|
50
69
|
exit
|
|
@@ -70,11 +89,18 @@ when "server"
|
|
|
70
89
|
host: options[:host],
|
|
71
90
|
mount_path: options[:mount],
|
|
72
91
|
database_url: options[:database_url],
|
|
73
|
-
server: options[:server]
|
|
92
|
+
server: options[:server],
|
|
93
|
+
config_file: options[:config_file],
|
|
94
|
+
overrides: overrides
|
|
74
95
|
)
|
|
75
96
|
rescue PgReports::Standalone::ServerUnavailable => e
|
|
76
97
|
warn e.message
|
|
77
98
|
exit 1
|
|
99
|
+
rescue PgReports::Error => e
|
|
100
|
+
# Config-file and connection problems surface as PgReports::Error with a
|
|
101
|
+
# human-readable message — print it, not a backtrace.
|
|
102
|
+
warn "pg_reports: #{e.message}"
|
|
103
|
+
exit 1
|
|
78
104
|
rescue Interrupt
|
|
79
105
|
warn "\npg_reports: stopped"
|
|
80
106
|
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"ignored_warnings": [
|
|
3
|
+
{
|
|
4
|
+
"warning_type": "SQL Injection",
|
|
5
|
+
"warning_code": 0,
|
|
6
|
+
"fingerprint": "5209397462e689c349f77e534f897c12a8550b9a013bd535153a929c12b944e2",
|
|
7
|
+
"check_name": "SQL",
|
|
8
|
+
"message": "Possible SQL injection",
|
|
9
|
+
"file": "app/controllers/pg_reports/dashboard_controller.rb",
|
|
10
|
+
"line": 357,
|
|
11
|
+
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
|
|
12
|
+
"code": "ActiveRecord::Base.connection.execute(\"SELECT COUNT(*) FROM (#{substitute_params(retrieve_query_by_hash(params[:query_hash]), (params[:params] or {}))}) AS count_query\")",
|
|
13
|
+
"render_path": null,
|
|
14
|
+
"location": {
|
|
15
|
+
"type": "method",
|
|
16
|
+
"class": "PgReports::DashboardController",
|
|
17
|
+
"method": "execute_query"
|
|
18
|
+
},
|
|
19
|
+
"user_input": "params[:params]",
|
|
20
|
+
"confidence": "High",
|
|
21
|
+
"cwe_id": [
|
|
22
|
+
89
|
|
23
|
+
],
|
|
24
|
+
"note": "Reviewed & accepted: user-query execution feature (execute_query count). Gated by config.allow_raw_query_execution; the query is fetched by hash from an internal registry (retrieve_query_by_hash), parameters are validated/substituted, and SecurityError checks reject dangerous input. Not arbitrary attacker-controlled SQL."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"warning_type": "Cross-Site Request Forgery",
|
|
28
|
+
"warning_code": 7,
|
|
29
|
+
"fingerprint": "b9682cc0e0a1cd86fddef96834d4f71c3558989c2136fe58b5ff51c75b83c8b4",
|
|
30
|
+
"check_name": "ForgerySetting",
|
|
31
|
+
"message": "`protect_from_forgery` should be called in `PgReports::MetricsController`",
|
|
32
|
+
"file": "app/controllers/pg_reports/metrics_controller.rb",
|
|
33
|
+
"line": 6,
|
|
34
|
+
"link": "https://brakemanscanner.org/docs/warning_types/cross-site_request_forgery/",
|
|
35
|
+
"code": null,
|
|
36
|
+
"render_path": null,
|
|
37
|
+
"location": {
|
|
38
|
+
"type": "controller",
|
|
39
|
+
"controller": "PgReports::MetricsController"
|
|
40
|
+
},
|
|
41
|
+
"user_input": null,
|
|
42
|
+
"confidence": "High",
|
|
43
|
+
"cwe_id": [
|
|
44
|
+
352
|
|
45
|
+
],
|
|
46
|
+
"note": "Reviewed & accepted: read-only Prometheus /metrics endpoint. Uses bearer-token auth (config.grafana_metrics_token) with constant-time compare; CSRF protection is not applicable to a GET scrape endpoint."
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"warning_type": "SQL Injection",
|
|
50
|
+
"warning_code": 0,
|
|
51
|
+
"fingerprint": "f133378f2542f3c09b527aebcc0939a1d227c46b0ab124be613e1e5aac829c79",
|
|
52
|
+
"check_name": "SQL",
|
|
53
|
+
"message": "Possible SQL injection",
|
|
54
|
+
"file": "app/controllers/pg_reports/dashboard_controller.rb",
|
|
55
|
+
"line": 278,
|
|
56
|
+
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
|
|
57
|
+
"code": "ActiveRecord::Base.connection.execute(\"EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) #{substitute_params(retrieve_query_by_hash(params[:query_hash]), (params[:params] or {}))}\")",
|
|
58
|
+
"render_path": null,
|
|
59
|
+
"location": {
|
|
60
|
+
"type": "method",
|
|
61
|
+
"class": "PgReports::DashboardController",
|
|
62
|
+
"method": "explain_analyze"
|
|
63
|
+
},
|
|
64
|
+
"user_input": "params[:params]",
|
|
65
|
+
"confidence": "High",
|
|
66
|
+
"cwe_id": [
|
|
67
|
+
89
|
|
68
|
+
],
|
|
69
|
+
"note": "Reviewed & accepted: EXPLAIN ANALYZE feature. Same safeguards as execute_query - query resolved by hash from internal registry, params validated, SecurityError guards. Intentional DBA tooling."
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
"updated": "2026-07-04 20:27:49 +0300",
|
|
73
|
+
"brakeman_version": "8.0.5"
|
|
74
|
+
}
|
data/config/locales/en.yml
CHANGED
|
@@ -622,6 +622,7 @@ en:
|
|
|
622
622
|
ide_settings_button_title: "IDE Settings"
|
|
623
623
|
explain_analyze: "📊 EXPLAIN ANALYZE"
|
|
624
624
|
execute_query: "▶ Execute Query"
|
|
625
|
+
run_query: "SQL Console"
|
|
625
626
|
create_migration_file: "📁 Create File & Open in IDE"
|
|
626
627
|
start_monitoring: "▶ Start Monitoring"
|
|
627
628
|
stop_monitoring: "⏹ Stop Monitoring"
|
|
@@ -663,6 +664,9 @@ en:
|
|
|
663
664
|
migration_warning_dev_only: "This operation should only be performed in a local development environment."
|
|
664
665
|
query_execution_disabled_title: "⚠️ Query execution is disabled"
|
|
665
666
|
query_execution_disabled_intro: "To enable this feature, add to your configuration:"
|
|
667
|
+
query_execution_disabled_env_note: "...or set the following environment variable instead:"
|
|
668
|
+
run_query_title: "SQL Console"
|
|
669
|
+
run_query_placeholder: "SELECT * FROM users LIMIT 10;"
|
|
666
670
|
settings:
|
|
667
671
|
default_ide_label: "Default IDE for source links:"
|
|
668
672
|
ide_show_menu: "Show menu (default)"
|
|
@@ -704,6 +708,7 @@ en:
|
|
|
704
708
|
requires_pg_stat: "🔒 Requires pg_stat_statements"
|
|
705
709
|
reports_count_suffix: "reports"
|
|
706
710
|
primary_only_reason: "🔒 This category only runs on the primary database — it inspects the host application's models. Switch back to the default database to use it."
|
|
711
|
+
standalone_no_host_app_reason: "🔒 Not available in standalone mode — this category inspects the host application's models, and there is no host app when running the dashboard on its own."
|
|
707
712
|
documentation:
|
|
708
713
|
toggle_title: "📖 What does this report show?"
|
|
709
714
|
what_section: "📋 What"
|
|
@@ -768,6 +773,8 @@ en:
|
|
|
768
773
|
decode_query_failed: "Failed to decode query"
|
|
769
774
|
explain_analyze_failed: "Failed to run EXPLAIN ANALYZE"
|
|
770
775
|
execute_query_failed: "Failed to execute query"
|
|
776
|
+
run_query_failed: "Failed to run query"
|
|
777
|
+
query_required: "Please enter a query"
|
|
771
778
|
create_migration_failed: "Failed to create migration"
|
|
772
779
|
explain_disabled_toast: "⚠️ EXPLAIN ANALYZE is disabled. Enable in configuration: config.allow_raw_query_execution = true"
|
|
773
780
|
execute_disabled_toast: "⚠️ Query execution is disabled. Enable in configuration: config.allow_raw_query_execution = true"
|
|
@@ -775,6 +782,9 @@ en:
|
|
|
775
782
|
query_hash_required: "Query hash is required"
|
|
776
783
|
query_execution_disabled: "Query execution from dashboard is disabled. Enable it in configuration with 'config.allow_raw_query_execution = true'"
|
|
777
784
|
query_not_found_expired: "Query not found or expired. Please refresh the page."
|
|
785
|
+
query_monitor_unavailable_standalone: "SQL Query Monitor isn't available in standalone mode — there is no host application process to observe."
|
|
786
|
+
query_timed_out: "Query timed out after %{ms}ms (statement_timeout). Try narrowing the query or increase config.raw_query_statement_timeout_ms."
|
|
787
|
+
rate_limit_exceeded: "Too many requests. Please wait a bit before trying again."
|
|
778
788
|
security_violation_prefix: "Security violation:"
|
|
779
789
|
trigger_variables_not_allowed: "Cannot EXPLAIN ANALYZE queries with trigger variables (NEW, OLD). These are only available within trigger functions."
|
|
780
790
|
missing_parameter_values: "Please provide values for all parameter placeholders ($1, $2, etc.)"
|
data/config/locales/ru.yml
CHANGED
|
@@ -587,6 +587,7 @@ ru:
|
|
|
587
587
|
ide_settings_button_title: "Настройки IDE"
|
|
588
588
|
explain_analyze: "📊 EXPLAIN ANALYZE"
|
|
589
589
|
execute_query: "▶ Выполнить запрос"
|
|
590
|
+
run_query: "SQL-консоль"
|
|
590
591
|
create_migration_file: "📁 Создать файл и открыть в IDE"
|
|
591
592
|
start_monitoring: "▶ Запустить мониторинг"
|
|
592
593
|
stop_monitoring: "⏹ Остановить мониторинг"
|
|
@@ -628,6 +629,9 @@ ru:
|
|
|
628
629
|
migration_warning_dev_only: "Эту операцию следует выполнять только в локальном dev-окружении."
|
|
629
630
|
query_execution_disabled_title: "⚠️ Выполнение запросов отключено"
|
|
630
631
|
query_execution_disabled_intro: "Чтобы включить эту функцию, добавьте в конфигурацию:"
|
|
632
|
+
query_execution_disabled_env_note: "...или вместо этого установите переменную окружения:"
|
|
633
|
+
run_query_title: "SQL-консоль"
|
|
634
|
+
run_query_placeholder: "SELECT * FROM users LIMIT 10;"
|
|
631
635
|
settings:
|
|
632
636
|
default_ide_label: "IDE по умолчанию для ссылок на исходники:"
|
|
633
637
|
ide_show_menu: "Показывать меню (по умолчанию)"
|
|
@@ -669,6 +673,7 @@ ru:
|
|
|
669
673
|
requires_pg_stat: "🔒 Требуется pg_stat_statements"
|
|
670
674
|
reports_count_suffix: "отчётов"
|
|
671
675
|
primary_only_reason: "🔒 Эта категория работает только на первичной базе — она проверяет модели хост-приложения. Чтобы пользоваться, переключитесь обратно на базу по умолчанию."
|
|
676
|
+
standalone_no_host_app_reason: "🔒 Недоступно в автономном режиме — эта категория проверяет модели хост-приложения, а при самостоятельном запуске дашборда хост-приложения нет."
|
|
672
677
|
documentation:
|
|
673
678
|
toggle_title: "📖 Что показывает этот отчёт?"
|
|
674
679
|
what_section: "📋 Что"
|
|
@@ -733,6 +738,8 @@ ru:
|
|
|
733
738
|
decode_query_failed: "Не удалось декодировать запрос"
|
|
734
739
|
explain_analyze_failed: "Не удалось выполнить EXPLAIN ANALYZE"
|
|
735
740
|
execute_query_failed: "Не удалось выполнить запрос"
|
|
741
|
+
run_query_failed: "Не удалось выполнить запрос"
|
|
742
|
+
query_required: "Введите запрос"
|
|
736
743
|
create_migration_failed: "Не удалось создать миграцию"
|
|
737
744
|
explain_disabled_toast: "⚠️ EXPLAIN ANALYZE отключен. Включите в конфигурации: config.allow_raw_query_execution = true"
|
|
738
745
|
execute_disabled_toast: "⚠️ Выполнение запросов отключено. Включите в конфигурации: config.allow_raw_query_execution = true"
|
|
@@ -740,6 +747,9 @@ ru:
|
|
|
740
747
|
query_hash_required: "Требуется хэш запроса"
|
|
741
748
|
query_execution_disabled: "Выполнение запросов из дашборда отключено. Включите в конфигурации: 'config.allow_raw_query_execution = true'"
|
|
742
749
|
query_not_found_expired: "Запрос не найден или истёк срок действия. Обновите страницу."
|
|
750
|
+
query_monitor_unavailable_standalone: "SQL Query Monitor недоступен в автономном режиме — нет процесса основного приложения для наблюдения."
|
|
751
|
+
query_timed_out: "Превышено время ожидания запроса (%{ms} мс, statement_timeout). Сузьте запрос или увеличьте config.raw_query_statement_timeout_ms."
|
|
752
|
+
rate_limit_exceeded: "Слишком много запросов. Подождите немного и попробуйте снова."
|
|
743
753
|
security_violation_prefix: "Нарушение безопасности:"
|
|
744
754
|
trigger_variables_not_allowed: "Нельзя выполнить EXPLAIN ANALYZE для запросов с триггерными переменными (NEW, OLD). Они доступны только в контексте триггерных функций."
|
|
745
755
|
missing_parameter_values: "Укажите значения для всех плейсхолдеров параметров ($1, $2 и т.д.)"
|
data/config/locales/uk.yml
CHANGED
|
@@ -587,6 +587,7 @@ uk:
|
|
|
587
587
|
ide_settings_button_title: "Налаштування IDE"
|
|
588
588
|
explain_analyze: "📊 EXPLAIN ANALYZE"
|
|
589
589
|
execute_query: "▶ Виконати запит"
|
|
590
|
+
run_query: "SQL-консоль"
|
|
590
591
|
create_migration_file: "📁 Створити файл і відкрити в IDE"
|
|
591
592
|
start_monitoring: "▶ Запустити моніторинг"
|
|
592
593
|
stop_monitoring: "⏹ Зупинити моніторинг"
|
|
@@ -628,6 +629,9 @@ uk:
|
|
|
628
629
|
migration_warning_dev_only: "Цю операцію слід виконувати лише в локальному dev-середовищі."
|
|
629
630
|
query_execution_disabled_title: "⚠️ Виконання запитів вимкнено"
|
|
630
631
|
query_execution_disabled_intro: "Щоб увімкнути цю функцію, додайте до конфігурації:"
|
|
632
|
+
query_execution_disabled_env_note: "...або замість цього встановіть змінну середовища:"
|
|
633
|
+
run_query_title: "SQL-консоль"
|
|
634
|
+
run_query_placeholder: "SELECT * FROM users LIMIT 10;"
|
|
631
635
|
settings:
|
|
632
636
|
default_ide_label: "IDE за замовчуванням для посилань на джерела:"
|
|
633
637
|
ide_show_menu: "Показувати меню (за замовчуванням)"
|
|
@@ -669,6 +673,7 @@ uk:
|
|
|
669
673
|
requires_pg_stat: "🔒 Потрібен pg_stat_statements"
|
|
670
674
|
reports_count_suffix: "звітів"
|
|
671
675
|
primary_only_reason: "🔒 Ця категорія працює лише на первинній базі — вона перевіряє моделі хост-застосунку. Щоб користуватися, перемкніться на базу за замовчуванням."
|
|
676
|
+
standalone_no_host_app_reason: "🔒 Недоступно в автономному режимі — ця категорія перевіряє моделі хост-застосунку, а при самостійному запуску дашборда хост-застосунку немає."
|
|
672
677
|
documentation:
|
|
673
678
|
toggle_title: "📖 Що показує цей звіт?"
|
|
674
679
|
what_section: "📋 Що"
|
|
@@ -733,6 +738,8 @@ uk:
|
|
|
733
738
|
decode_query_failed: "Не вдалося декодувати запит"
|
|
734
739
|
explain_analyze_failed: "Не вдалося виконати EXPLAIN ANALYZE"
|
|
735
740
|
execute_query_failed: "Не вдалося виконати запит"
|
|
741
|
+
run_query_failed: "Не вдалося виконати запит"
|
|
742
|
+
query_required: "Введіть запит"
|
|
736
743
|
create_migration_failed: "Не вдалося створити міграцію"
|
|
737
744
|
explain_disabled_toast: "⚠️ EXPLAIN ANALYZE вимкнено. Увімкніть у конфігурації: config.allow_raw_query_execution = true"
|
|
738
745
|
execute_disabled_toast: "⚠️ Виконання запитів вимкнено. Увімкніть у конфігурації: config.allow_raw_query_execution = true"
|
|
@@ -740,6 +747,9 @@ uk:
|
|
|
740
747
|
query_hash_required: "Потрібен хеш запиту"
|
|
741
748
|
query_execution_disabled: "Виконання запитів із дашборду вимкнено. Увімкніть у конфігурації: 'config.allow_raw_query_execution = true'"
|
|
742
749
|
query_not_found_expired: "Запит не знайдено або термін дії минув. Оновіть сторінку."
|
|
750
|
+
query_monitor_unavailable_standalone: "SQL Query Monitor недоступний в автономному режимі — немає процесу основного застосунку для спостереження."
|
|
751
|
+
query_timed_out: "Перевищено час очікування запиту (%{ms} мс, statement_timeout). Звузьте запит або збільшіть config.raw_query_statement_timeout_ms."
|
|
752
|
+
rate_limit_exceeded: "Забагато запитів. Зачекайте трохи і спробуйте знову."
|
|
743
753
|
security_violation_prefix: "Порушення безпеки:"
|
|
744
754
|
trigger_variables_not_allowed: "Не можна виконати EXPLAIN ANALYZE для запитів із тригерними змінними (NEW, OLD). Вони доступні лише в контексті тригерних функцій."
|
|
745
755
|
missing_parameter_values: "Вкажіть значення для всіх плейсхолдерів параметрів ($1, $2 тощо)"
|
data/config/routes.rb
CHANGED
|
@@ -14,6 +14,7 @@ PgReports::Engine.routes.draw do
|
|
|
14
14
|
post "reset_statistics", to: "dashboard#reset_statistics", as: :reset_statistics
|
|
15
15
|
post "explain_analyze", to: "dashboard#explain_analyze", as: :explain_analyze
|
|
16
16
|
post "execute_query", to: "dashboard#execute_query", as: :execute_query
|
|
17
|
+
post "run_query", to: "dashboard#run_query", as: :run_query
|
|
17
18
|
post "create_migration", to: "dashboard#create_migration", as: :create_migration
|
|
18
19
|
|
|
19
20
|
# Query monitoring
|
|
@@ -27,6 +27,7 @@ module PgReports
|
|
|
27
27
|
|
|
28
28
|
# Dashboard settings
|
|
29
29
|
attr_accessor :dashboard_auth # Proc for dashboard authentication
|
|
30
|
+
attr_accessor :standalone # True when running via PgReports::Standalone (no host app)
|
|
30
31
|
|
|
31
32
|
# Assets / privacy settings
|
|
32
33
|
attr_accessor :load_external_fonts # When true, loads Google Fonts in the dashboard layout
|
|
@@ -42,6 +43,9 @@ module PgReports
|
|
|
42
43
|
# Security settings
|
|
43
44
|
attr_accessor :allow_raw_query_execution # Allow execute_query and explain_analyze from dashboard
|
|
44
45
|
attr_accessor :allow_migration_creation # Allow dashboard's "Generate Migration" button to write files into db/migrate/
|
|
46
|
+
attr_accessor :raw_query_statement_timeout_ms # Postgres statement_timeout (ms) applied to Execute Query / EXPLAIN ANALYZE / SQL Console. 0 disables it.
|
|
47
|
+
attr_accessor :raw_query_rate_limit # Max privileged requests (raw query execution / Generate Migration) per client IP per window. nil disables rate limiting.
|
|
48
|
+
attr_accessor :raw_query_rate_limit_window_seconds # Window size (seconds) for raw_query_rate_limit
|
|
45
49
|
|
|
46
50
|
# Grafana / Prometheus exporter settings
|
|
47
51
|
attr_accessor :grafana_favorites # Reports exposed at /metrics (Array of keys or Hash with per-report opts)
|
|
@@ -74,6 +78,7 @@ module PgReports
|
|
|
74
78
|
|
|
75
79
|
# Dashboard
|
|
76
80
|
@dashboard_auth = nil
|
|
81
|
+
@standalone = false
|
|
77
82
|
|
|
78
83
|
# Assets / privacy
|
|
79
84
|
@load_external_fonts = ActiveModel::Type::Boolean.new.cast(ENV.fetch("PG_REPORTS_LOAD_EXTERNAL_FONTS", false))
|
|
@@ -101,6 +106,9 @@ module PgReports
|
|
|
101
106
|
else
|
|
102
107
|
ActiveModel::Type::Boolean.new.cast(env_override)
|
|
103
108
|
end
|
|
109
|
+
@raw_query_statement_timeout_ms = ENV.fetch("PG_REPORTS_RAW_QUERY_STATEMENT_TIMEOUT_MS", 5_000).to_i
|
|
110
|
+
@raw_query_rate_limit = ENV.fetch("PG_REPORTS_RAW_QUERY_RATE_LIMIT", 30).to_i
|
|
111
|
+
@raw_query_rate_limit_window_seconds = ENV.fetch("PG_REPORTS_RAW_QUERY_RATE_LIMIT_WINDOW_SECONDS", 60).to_i
|
|
104
112
|
|
|
105
113
|
# Grafana / Prometheus exporter
|
|
106
114
|
@grafana_favorites = []
|
|
@@ -105,7 +105,11 @@ module PgReports
|
|
|
105
105
|
mod = module_for(key) or raise ArgumentError, "Unknown report: #{key}"
|
|
106
106
|
|
|
107
107
|
started = @clock.now
|
|
108
|
-
|
|
108
|
+
args = report_args(opts)
|
|
109
|
+
# Call with no arguments when there are no kwargs to forward. On Ruby 2.7
|
|
110
|
+
# `public_send(key, **{})` does not reliably elide to a no-arg call, which
|
|
111
|
+
# breaks report methods (and `have_received(...).with(no_args)` matchers).
|
|
112
|
+
report = args.empty? ? mod.public_send(key) : mod.public_send(key, **args)
|
|
109
113
|
finished = @clock.now
|
|
110
114
|
|
|
111
115
|
{
|
|
@@ -24,6 +24,10 @@ module PgReports
|
|
|
24
24
|
# Rack handlers tried, in order, when none is named explicitly.
|
|
25
25
|
CANDIDATE_SERVERS = %w[puma webrick].freeze
|
|
26
26
|
|
|
27
|
+
# Config files auto-loaded (first that exists), relative to the working
|
|
28
|
+
# directory, when no explicit --config path is given.
|
|
29
|
+
DEFAULT_CONFIG_FILES = %w[pg_reports.rb config/pg_reports.rb].freeze
|
|
30
|
+
|
|
27
31
|
class ServerUnavailable < PgReports::Error; end
|
|
28
32
|
|
|
29
33
|
# Boot the app and start a (blocking) web server.
|
|
@@ -34,14 +38,27 @@ module PgReports
|
|
|
34
38
|
# @param database_url [String, nil] explicit connection URL; otherwise resolved
|
|
35
39
|
# from DATABASE_URL or libpq-style PG* env vars
|
|
36
40
|
# @param server [String, nil] Rack handler name to force (e.g. "puma")
|
|
41
|
+
# @param config_file [String, nil] path to a Ruby config file that calls
|
|
42
|
+
# `PgReports.configure`; auto-detected from DEFAULT_CONFIG_FILES otherwise
|
|
43
|
+
# @param overrides [Hash] per-setting overrides applied last (from CLI flags);
|
|
44
|
+
# nil values are ignored. Keys are Configuration attribute names.
|
|
37
45
|
def run(port: DEFAULT_PORT, host: DEFAULT_HOST, mount_path: DEFAULT_MOUNT,
|
|
38
|
-
database_url: nil, server: nil)
|
|
46
|
+
database_url: nil, server: nil, config_file: nil, overrides: {})
|
|
39
47
|
# Rails' ActiveRecord railtie reads the connection from DATABASE_URL when no
|
|
40
48
|
# config/database.yml exists — so we route our resolved connection through
|
|
41
49
|
# it. The connection registry then auto-registers it as the :primary target,
|
|
42
50
|
# and database switching / multi-cluster all work unchanged.
|
|
43
51
|
ENV["DATABASE_URL"] = connection_url(database_url)
|
|
44
52
|
|
|
53
|
+
# Mark this process as standalone so the dashboard can hide reports that
|
|
54
|
+
# only make sense with a host app (e.g. Schema Analysis, which introspects
|
|
55
|
+
# the host application's ActiveRecord models — there are none here).
|
|
56
|
+
PgReports.config.standalone = true
|
|
57
|
+
|
|
58
|
+
# Layer settings on top of the ENV-derived defaults: config file first, then
|
|
59
|
+
# explicit CLI overrides win.
|
|
60
|
+
apply_configuration(config_file: config_file, overrides: overrides)
|
|
61
|
+
|
|
45
62
|
app = build_application(mount_path)
|
|
46
63
|
app.initialize!
|
|
47
64
|
verify_connection!
|
|
@@ -76,6 +93,47 @@ module PgReports
|
|
|
76
93
|
|
|
77
94
|
private
|
|
78
95
|
|
|
96
|
+
# Apply configuration in increasing order of precedence:
|
|
97
|
+
# ENV vars (already read when Configuration was built)
|
|
98
|
+
# < config file (full Ruby, calls PgReports.configure)
|
|
99
|
+
# < CLI overrides (individual flags)
|
|
100
|
+
#
|
|
101
|
+
# The config file is the escape hatch for every setting that has no dedicated
|
|
102
|
+
# flag — thresholds, Telegram, Grafana favorites, even the dashboard_auth
|
|
103
|
+
# proc. CLI flags cover only the handful of common security toggles.
|
|
104
|
+
def apply_configuration(config_file:, overrides:)
|
|
105
|
+
path = config_file || detect_config_file
|
|
106
|
+
load_config_file(path) if path
|
|
107
|
+
|
|
108
|
+
overrides.each do |key, value|
|
|
109
|
+
next if value.nil? # unset flag — leave the file/ENV value in place
|
|
110
|
+
PgReports.config.public_send(:"#{key}=", value)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# First existing DEFAULT_CONFIG_FILES entry (relative to the working dir), or
|
|
115
|
+
# nil when the user keeps no config file.
|
|
116
|
+
def detect_config_file
|
|
117
|
+
DEFAULT_CONFIG_FILES
|
|
118
|
+
.map { |f| File.expand_path(f, Dir.pwd) }
|
|
119
|
+
.find { |f| File.file?(f) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Evaluate a Ruby config file. A missing explicit path is an error (the user
|
|
123
|
+
# asked for it); a broken file is reported with context rather than a raw
|
|
124
|
+
# backtrace.
|
|
125
|
+
def load_config_file(path)
|
|
126
|
+
full = File.expand_path(path)
|
|
127
|
+
raise PgReports::Error, "Config file not found: #{path}" unless File.file?(full)
|
|
128
|
+
|
|
129
|
+
warn "pg_reports: loading config from #{full}"
|
|
130
|
+
load full
|
|
131
|
+
rescue PgReports::Error
|
|
132
|
+
raise
|
|
133
|
+
rescue => e
|
|
134
|
+
raise PgReports::Error, "Failed to load config file #{full}: #{e.message}"
|
|
135
|
+
end
|
|
136
|
+
|
|
79
137
|
# Build (and register as Rails.application) a minimal Rails app that mounts
|
|
80
138
|
# the engine. Kept intentionally small: no asset pipeline (views are inline),
|
|
81
139
|
# cookie sessions for the dashboard's database selector + CSRF.
|
data/lib/pg_reports/version.rb
CHANGED
data/lib/pg_reports.rb
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# ActiveSupport < 7.1 relies on Ruby's `logger` being loaded implicitly, which
|
|
4
|
+
# concurrent-ruby dropped in 1.3.5. Require it up front so pg_reports loads on
|
|
5
|
+
# Rails 6.1/7.0 (and any bundle with a modern concurrent-ruby). Harmless on
|
|
6
|
+
# newer Rails, which require it themselves.
|
|
7
|
+
require "logger"
|
|
3
8
|
require "active_support"
|
|
4
9
|
require "active_support/core_ext"
|
|
5
10
|
require "active_record"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pg_reports
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.8.
|
|
4
|
+
version: 0.8.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eldar Avatov
|
|
@@ -160,6 +160,7 @@ files:
|
|
|
160
160
|
- app/views/pg_reports/dashboard/index.html.erb
|
|
161
161
|
- app/views/pg_reports/dashboard/show.html.erb
|
|
162
162
|
- bin/pg_reports
|
|
163
|
+
- config/brakeman.ignore
|
|
163
164
|
- config/locales/en.yml
|
|
164
165
|
- config/locales/ru.yml
|
|
165
166
|
- config/locales/uk.yml
|