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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2ff4489af5016c4bfbe14f7187daa5ca2ea279b15887bab928c8effefc4b2dad
4
- data.tar.gz: 26815859090bbf8a84000acb3545c1eecb982887f8d0f07d9ab0e582f2380c58
3
+ metadata.gz: 2800846cf0c02bdf3a2605151d0bfe7b7775a1eda3a1a920eed26bc3f731748d
4
+ data.tar.gz: 6f07d07fcd9e396d6c9dd885f9e9c2e0c9d10595f5b8817eba59a2c8c734981a
5
5
  SHA512:
6
- metadata.gz: f94d1f175001f3a8140d8ef9b826d353bb20963d3b75487aace882fde54186466be500840252bc9e87268e55244bb96746f59fd82b6ba648271a828b596b47d4
7
- data.tar.gz: f89ff6622a18f3daf33fabf898a8736da510e2ecf84f4455e5720a8a07f3a219ed1336a1f1a286437a23da505560e724679bb27957b74b501567db2cfaee9f17
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
  ![Dashboard Screenshot](docs/dashboard.png)
11
14
 
12
15
  ## Features
13
16
 
17
+ - 🚀 **Standalone or mounted** - Run inside your Rails app, or launch the dashboard on its own with `pg_reports server` (requires Ruby; Docker images coming soon).
14
18
  - 🗄️ **Multi-database** - Auto-discovers every database on the cluster and lets you switch from a dropdown in the dashboard. No configuration required.
15
19
  - 📊 **Query Analysis** - Identify slow, heavy, and expensive queries using `pg_stat_statements`
16
20
  - 📇 **Index Analysis** - Find unused, duplicate, invalid, and missing indexes
@@ -24,7 +28,8 @@ A comprehensive PostgreSQL monitoring and analysis library for Rails application
24
28
  - 🔗 **IDE Integration** - Open source locations in VS Code, Cursor, RubyMine, or IntelliJ (with WSL support)
25
29
  - 📌 **Comparison Mode** - Save records to compare before/after optimization
26
30
  - 📊 **EXPLAIN ANALYZE** - Advanced query plan analyzer with problem detection and recommendations
27
- - 🔍 **SQL Query Monitoring** - Real-time monitoring of all executed SQL queries with source location tracking
31
+ - 🖥️ **SQL Console** - Free-form SQL editor in a modal, run SELECT queries and view results directly from the dashboard
32
+ - 🔍 **SQL Query Monitoring** - Real-time monitoring of all executed SQL queries with source location tracking (not available in standalone mode)
28
33
  - 🔌 **Connection Pool Analytics** - Monitor pool usage, wait times, saturation warnings, and connection churn
29
34
  - 🤖 **AI Prompt Export** - Copy a ready-to-paste prompt for Claude Code, Cursor, or Codex with problem context and report data
30
35
  - 🗑️ **Migration Generator** - Generate Rails migrations to drop unused indexes
@@ -68,9 +73,10 @@ You can also run the dashboard on its own, straight from the gem's root folder
68
73
  ```bash
69
74
  ./bin/pg_reports server # from a checkout; no `bundle exec` needed
70
75
  DATABASE_URL=postgres://user:pass@localhost/myapp bundle exec pg_reports server
76
+ ./bin/pg_reports server --allow-raw-query-execution # opt into the Run SQL panel
71
77
  ```
72
78
 
73
- Adds no runtime dependencies to the gem. **[Standalone guide → docs/standalone.md](docs/standalone.md)**
79
+ Settings come from `PG_REPORTS_*` env vars, an auto-detected `./pg_reports.rb` config file (full `PgReports.configure` access), or CLI flags — in that order of precedence. Adds no runtime dependencies to the gem. **[Standalone guide → docs/standalone.md](docs/standalone.md)**
74
80
 
75
81
  ## Usage
76
82
 
@@ -178,6 +184,19 @@ Requires `config.allow_raw_query_execution = true`.
178
184
 
179
185
  </details>
180
186
 
187
+ <details>
188
+ <summary><strong>SQL Console — free-form SQL editor</strong></summary>
189
+
190
+ Click **SQL Console** in the header to open a large modal with a SQL editor. Type or paste a query, run it (⌘/Ctrl+Enter also works), and see the results in a table with row count and execution time.
191
+
192
+ Only `SELECT` statements are allowed — the same denylist validation used for the query-hash based **Execute Query** panel (single statement, no `INSERT`/`UPDATE`/`DELETE`/`DROP`/`ALTER`/`CREATE`/`TRUNCATE`/`GRANT`/`REVOKE`) applies here, since this is client-typed SQL rather than a server-generated query. See [Security model](docs/configuration.md#security-model) for the full threat model and residual risks (this is a denylist, not a sandbox).
193
+
194
+ Every query (here and in Execute Query / EXPLAIN ANALYZE) runs under a bounded `statement_timeout` (`config.raw_query_statement_timeout_ms`, default 5s) and these endpoints are rate-limited per client IP (`config.raw_query_rate_limit`, default 30/min) — see [Raw query execution](docs/configuration.md#raw-query-execution-explain-analyze--execute-query--sql-console).
195
+
196
+ Requires `config.allow_raw_query_execution = true`.
197
+
198
+ </details>
199
+
181
200
  <details>
182
201
  <summary><strong>SQL Query Monitor — real-time query capture</strong></summary>
183
202
 
@@ -200,6 +219,8 @@ end
200
219
 
201
220
  Use cases: debugging N+1, identifying slow queries during feature development, tracking down unexpected queries, teaching ActiveRecord behavior.
202
221
 
222
+ Not available in [standalone mode](docs/standalone.md) — there is no host application process to subscribe to, so the panel and its API are both disabled.
223
+
203
224
  </details>
204
225
 
205
226
  <details>
@@ -15,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
- result = ActiveRecord::Base.connection.execute("EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) #{final_query}")
279
- explain_output = result.map { |r| r["QUERY PLAN"] }.join("\n")
285
+ explain_output = nil
286
+ with_statement_timeout do
287
+ result = ActiveRecord::Base.connection.execute("EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) #{final_query}")
288
+ explain_output = result.map { |r| r["QUERY PLAN"] }.join("\n")
289
+ end
280
290
 
281
291
  # Analyze the EXPLAIN output
282
292
  analyzer = ExplainAnalyzer.new(explain_output)
@@ -290,6 +300,8 @@ module PgReports
290
300
  problems: analysis[:problems],
291
301
  summary: analysis[:summary]
292
302
  }
303
+ rescue ActiveRecord::QueryCanceled
304
+ render json: {success: false, error: query_timed_out_message}, status: :unprocessable_entity
293
305
  rescue => e
294
306
  render json: {success: false, error: e.message}, status: :unprocessable_entity
295
307
  end
@@ -340,23 +352,95 @@ module PgReports
340
352
  # Execute with LIMIT to prevent huge result sets
341
353
  limited_query = add_limit_if_missing(final_query, 100)
342
354
 
343
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
344
- result = ActiveRecord::Base.connection.execute(limited_query)
345
- end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
346
- execution_time = ((end_time - start_time) * 1000).round(2)
355
+ rows = columns = nil
356
+ total_count = 0
357
+ truncated = false
358
+ execution_time = nil
359
+
360
+ with_statement_timeout do
361
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
362
+ result = ActiveRecord::Base.connection.execute(limited_query)
363
+ end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
364
+ execution_time = ((end_time - start_time) * 1000).round(2)
365
+
366
+ rows = result.to_a
367
+ columns = rows.first&.keys || []
368
+ total_count = rows.size
369
+
370
+ # Check if we need to get total count
371
+ if rows.size >= 100
372
+ count_result = ActiveRecord::Base.connection.execute("SELECT COUNT(*) FROM (#{final_query}) AS count_query")
373
+ total_count = count_result.first["count"].to_i
374
+ truncated = total_count > 100
375
+ end
376
+ end
377
+
378
+ render json: {
379
+ success: true,
380
+ columns: columns,
381
+ rows: rows,
382
+ count: rows.size,
383
+ total_count: total_count,
384
+ truncated: truncated,
385
+ execution_time: execution_time
386
+ }
387
+ rescue ActiveRecord::QueryCanceled
388
+ render json: {success: false, error: query_timed_out_message}, status: :unprocessable_entity
389
+ rescue => e
390
+ render json: {success: false, error: e.message}, status: :unprocessable_entity
391
+ end
392
+
393
+ # POST /run_query
394
+ # Free-text SQL runner backing the "Run Query" modal. Unlike #execute_query
395
+ # (which only ever runs queries the server itself generated and cached by
396
+ # hash — see CHANGELOG 0.5.1), this endpoint accepts client-typed SQL
397
+ # directly, so it applies the same SELECT-only/denylist validation that
398
+ # normally happens on cache retrieval directly to the submitted text.
399
+ def run_query
400
+ raw_query = params[:query].to_s
347
401
 
348
- rows = result.to_a
349
- columns = rows.first&.keys || []
402
+ if raw_query.blank?
403
+ render json: {success: false, error: I18n.t("pg_reports.ui.errors.query_required")}, status: :unprocessable_entity
404
+ return
405
+ end
350
406
 
351
- # Check if we need to get total count
352
- total_count = rows.size
353
- truncated = false
407
+ unless PgReports.config.allow_raw_query_execution
408
+ render json: {
409
+ success: false,
410
+ error: I18n.t("pg_reports.ui.errors.query_execution_disabled")
411
+ }, status: :forbidden
412
+ return
413
+ end
414
+
415
+ begin
416
+ enforce_select_only!(raw_query)
417
+ rescue SecurityError => e
418
+ render json: {success: false, error: "#{I18n.t("pg_reports.ui.errors.security_violation_prefix")} #{e.message}"}, status: :forbidden
419
+ return
420
+ end
354
421
 
355
- if rows.size >= 100
356
- # Check if there are more rows
357
- count_result = ActiveRecord::Base.connection.execute("SELECT COUNT(*) FROM (#{final_query}) AS count_query")
358
- total_count = count_result.first["count"].to_i
359
- truncated = total_count > 100
422
+ limited_query = add_limit_if_missing(raw_query, 100)
423
+
424
+ rows = columns = nil
425
+ total_count = 0
426
+ truncated = false
427
+ execution_time = nil
428
+
429
+ with_statement_timeout do
430
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
431
+ result = ActiveRecord::Base.connection.execute(limited_query)
432
+ end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
433
+ execution_time = ((end_time - start_time) * 1000).round(2)
434
+
435
+ rows = result.to_a
436
+ columns = rows.first&.keys || []
437
+ total_count = rows.size
438
+
439
+ if rows.size >= 100
440
+ count_result = ActiveRecord::Base.connection.execute("SELECT COUNT(*) FROM (#{raw_query}) AS count_query")
441
+ total_count = count_result.first["count"].to_i
442
+ truncated = total_count > 100
443
+ end
360
444
  end
361
445
 
362
446
  render json: {
@@ -368,6 +452,8 @@ module PgReports
368
452
  truncated: truncated,
369
453
  execution_time: execution_time
370
454
  }
455
+ rescue ActiveRecord::QueryCanceled
456
+ render json: {success: false, error: query_timed_out_message}, status: :unprocessable_entity
371
457
  rescue => e
372
458
  render json: {success: false, error: e.message}, status: :unprocessable_entity
373
459
  end
@@ -668,6 +754,17 @@ module PgReports
668
754
  def category_disabled_reason(category)
669
755
  constraint = Dashboard::ReportsRegistry.target_constraint(category)
670
756
  return nil unless constraint == :primary_default_database_only
757
+
758
+ # In standalone mode there is no host application, so its ActiveRecord
759
+ # models don't exist — these reports would inspect nothing. Disable the
760
+ # whole category with an explanation instead of returning empty results.
761
+ if PgReports.config.standalone
762
+ return I18n.t("pg_reports.ui.categories.standalone_no_host_app_reason",
763
+ default: "🔒 This category isn't available in standalone mode — it " \
764
+ "inspects the host application's models, and there is no host app " \
765
+ "when running the dashboard on its own.")
766
+ end
767
+
671
768
  return nil if on_primary_default_database?
672
769
 
673
770
  I18n.t("pg_reports.ui.categories.primary_only_reason",
@@ -680,6 +777,51 @@ module PgReports
680
777
  category_disabled_reason(category).present?
681
778
  end
682
779
 
780
+ # SQL Query Monitor taps ActiveSupport::Notifications in the host
781
+ # application's process. In standalone mode there is no host app — pg_reports
782
+ # is the only process running, so there's nothing meaningful to observe.
783
+ # Blocks the API even if a client calls it directly (the UI panel is also
784
+ # hidden in standalone, see dashboard/index view).
785
+ def block_query_monitor_in_standalone
786
+ return unless PgReports.config.standalone
787
+
788
+ render json: {
789
+ success: false,
790
+ error: I18n.t("pg_reports.ui.errors.query_monitor_unavailable_standalone")
791
+ }, status: :forbidden
792
+ end
793
+
794
+ # Soft per-IP throttle for the dashboard's privileged raw-query and
795
+ # migration endpoints. This is not a hardened distributed rate limiter —
796
+ # just a best-effort guard against a single client hammering these
797
+ # expensive/privileged actions, backed by Rails.cache (works with or
798
+ # without a shared cache backend across processes). Fails open if the
799
+ # cache is unavailable, consistent with the rest of the dashboard (see
800
+ # #resolve_database_selection, #retrieve_query_by_hash).
801
+ def enforce_rate_limit!
802
+ limit = PgReports.config.raw_query_rate_limit
803
+ return if limit.nil?
804
+
805
+ window = PgReports.config.raw_query_rate_limit_window_seconds
806
+ key = "pg_reports:rate_limit:#{request.remote_ip}:#{params[:action]}"
807
+
808
+ count = begin
809
+ current = (Rails.cache.read(key) || 0) + 1
810
+ Rails.cache.write(key, current, expires_in: window)
811
+ current
812
+ rescue => e
813
+ Rails.logger.warn("PgReports: Rate limit cache unavailable: #{e.message}") if defined?(Rails.logger)
814
+ return
815
+ end
816
+
817
+ return if count <= limit
818
+
819
+ render json: {
820
+ success: false,
821
+ error: I18n.t("pg_reports.ui.errors.rate_limit_exceeded")
822
+ }, status: :too_many_requests
823
+ end
824
+
683
825
  def on_primary_default_database?
684
826
  return true if @target_default_database.nil?
685
827
 
@@ -768,7 +910,15 @@ module PgReports
768
910
  return nil
769
911
  end
770
912
 
771
- # Strict validation: must be a SELECT query only
913
+ enforce_select_only!(query)
914
+
915
+ query
916
+ end
917
+
918
+ # Strict validation: must be a single SELECT statement, no dangerous
919
+ # keywords. This is a denylist, not a sandbox (see docs/configuration.md)
920
+ # — shared by #retrieve_query_by_hash and the free-text #run_query action.
921
+ def enforce_select_only!(query)
772
922
  normalized = query.strip.gsub(/\s+/, " ").downcase
773
923
 
774
924
  # Check for semicolons (prevents multiple statements)
@@ -788,8 +938,24 @@ module PgReports
788
938
  raise SecurityError, "Dangerous keyword detected: #{keyword.upcase}"
789
939
  end
790
940
  end
941
+ end
791
942
 
792
- query
943
+ # Bounds how long a single raw-query execution (Execute Query / EXPLAIN
944
+ # ANALYZE / SQL Console) can run, so a runaway query can't hang the
945
+ # connection indefinitely. SET LOCAL only takes effect inside a
946
+ # transaction and reverts automatically at its end (commit or rollback) —
947
+ # safe here since every caller only ever runs SELECTs.
948
+ def with_statement_timeout
949
+ ActiveRecord::Base.transaction do
950
+ timeout_ms = PgReports.config.raw_query_statement_timeout_ms.to_i
951
+ ActiveRecord::Base.connection.execute("SET LOCAL statement_timeout = #{timeout_ms}") if timeout_ms.positive?
952
+ yield
953
+ end
954
+ end
955
+
956
+ def query_timed_out_message
957
+ I18n.t("pg_reports.ui.errors.query_timed_out",
958
+ ms: PgReports.config.raw_query_statement_timeout_ms.to_i)
793
959
  end
794
960
 
795
961
  def generate_query_monitor_csv(queries)
@@ -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()">&times;</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
+ &nbsp;&nbsp;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, '&amp;')
1080
+ .replace(/"/g, '&quot;')
1081
+ .replace(/'/g, '&#39;')
1082
+ .replace(/</g, '&lt;')
1083
+ .replace(/>/g, '&gt;');
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
+ &nbsp;&nbsp;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
+ }
@@ -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.)"
@@ -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 и т.д.)"
@@ -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
- report = mod.public_send(key, **report_args(opts))
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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgReports
4
- VERSION = "0.8.1"
4
+ VERSION = "0.8.2"
5
5
  end
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.1
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