pg_reports 0.9.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -0
- data/README.md +12 -8
- data/app/controllers/pg_reports/dashboard_controller.rb +277 -221
- data/app/controllers/pg_reports/metrics_controller.rb +13 -4
- data/app/views/layouts/pg_reports/application.html.erb +222 -93
- data/app/views/pg_reports/dashboard/_settings_modal.html.erb +55 -0
- data/app/views/pg_reports/dashboard/_show_modals.html.erb +1 -42
- data/app/views/pg_reports/dashboard/_show_scripts.html.erb +75 -105
- data/app/views/pg_reports/dashboard/index.html.erb +346 -238
- data/app/views/pg_reports/dashboard/show.html.erb +1 -1
- data/bin/pg_reports +2 -2
- data/config/brakeman.ignore +10 -56
- data/config/locales/en.yml +45 -11
- data/config/locales/ru.yml +44 -11
- data/config/locales/uk.yml +44 -11
- data/config/routes.rb +2 -0
- data/lib/pg_reports/access_policy.rb +39 -0
- data/lib/pg_reports/compatibility.rb +10 -2
- data/lib/pg_reports/configuration.rb +33 -2
- data/lib/pg_reports/connection/error_translator.rb +21 -0
- data/lib/pg_reports/connection/target.rb +43 -10
- data/lib/pg_reports/csv_cell.rb +20 -0
- data/lib/pg_reports/dashboard/reports_registry.rb +37 -30
- data/lib/pg_reports/definitions/connections/connection_stats.yml +4 -0
- data/lib/pg_reports/definitions/connections/idle_connections.yml +4 -0
- data/lib/pg_reports/definitions/connections/locks.yml +1 -0
- data/lib/pg_reports/definitions/connections/long_running_queries.yml +0 -1
- data/lib/pg_reports/definitions/indexes/bloated_indexes.yml +1 -2
- data/lib/pg_reports/definitions/indexes/index_correlation.yml +2 -1
- data/lib/pg_reports/definitions/indexes/missing_indexes.yml +1 -0
- data/lib/pg_reports/definitions/queries/frequent_queries.yml +50 -0
- data/lib/pg_reports/definitions/system/cache_stats.yml +2 -1
- data/lib/pg_reports/definitions/tables/bloated_tables.yml +1 -2
- data/lib/pg_reports/definitions/tables/recently_modified.yml +3 -3
- data/lib/pg_reports/definitions/tables/unused_tables.yml +1 -1
- data/lib/pg_reports/drop_index_migration.rb +56 -0
- data/lib/pg_reports/engine.rb +4 -3
- data/lib/pg_reports/executor.rb +46 -9
- data/lib/pg_reports/modules/queries.rb +1 -0
- data/lib/pg_reports/query_monitor.rb +478 -192
- data/lib/pg_reports/rate_limiter.rb +63 -0
- data/lib/pg_reports/report.rb +1 -1
- data/lib/pg_reports/report_definition.rb +11 -1
- data/lib/pg_reports/sql/connections/connection_stats.sql +2 -1
- data/lib/pg_reports/sql/connections/idle_connections.sql +1 -0
- data/lib/pg_reports/sql/connections/locks.sql +1 -0
- data/lib/pg_reports/sql/indexes/bloated_indexes.sql +16 -8
- data/lib/pg_reports/sql/queries/activity_sample.sql +22 -0
- data/lib/pg_reports/sql/queries/frequent_queries.sql +42 -0
- data/lib/pg_reports/sql_redactor.rb +32 -0
- data/lib/pg_reports/standalone.rb +83 -20
- data/lib/pg_reports/version.rb +1 -1
- data/lib/pg_reports.rb +7 -2
- metadata +18 -9
|
@@ -4,6 +4,38 @@ module PgReports
|
|
|
4
4
|
class DashboardController < ActionController::Base
|
|
5
5
|
layout "pg_reports/application"
|
|
6
6
|
|
|
7
|
+
INDEX_LOOKUP = <<~SQL
|
|
8
|
+
#{Executor::QUERY_TAG}
|
|
9
|
+
SELECT schemaname, tablename, indexname, indexdef
|
|
10
|
+
FROM pg_indexes
|
|
11
|
+
WHERE indexname = $1 AND ($2::text = '' OR schemaname = $2)
|
|
12
|
+
SQL
|
|
13
|
+
|
|
14
|
+
MAX_RESULT_ROWS = 100
|
|
15
|
+
# Pages that draw the database selector, the only thing that shows a size
|
|
16
|
+
DATABASE_SELECTOR_ACTIONS = %w[index show].freeze
|
|
17
|
+
RATE_LIMITED_ACTIONS = %i[
|
|
18
|
+
explain_analyze execute_query run_query migration_preview create_migration
|
|
19
|
+
reset_statistics enable_pg_stat_statements send_to_telegram
|
|
20
|
+
start_query_monitoring load_query_history
|
|
21
|
+
].freeze
|
|
22
|
+
# PostgreSQL's own limit. A `$99999999` in captured text must not allocate.
|
|
23
|
+
MAX_BIND_PARAMETERS = 65_535
|
|
24
|
+
TYPED_PLACEHOLDER = /\b(interval|timestamptz|timestamp|date|time)\s+\$(\d+)/i
|
|
25
|
+
# What a read-only transaction does not stop: these work outside the database's
|
|
26
|
+
# own data, leave something behind on a pooled connection, or run SQL held in a
|
|
27
|
+
# string, which would put the name of anything else here out of reach of a check
|
|
28
|
+
# that only reads the text. This narrows what a mistake can do; it is not a
|
|
29
|
+
# sandbox.
|
|
30
|
+
ADMIN_FUNCTIONS = /\b(?:
|
|
31
|
+
pg_read_file | pg_read_binary_file | pg_ls_\w+ | pg_stat_file | pg_file_\w+ |
|
|
32
|
+
pg_terminate_backend | pg_cancel_backend | pg_reload_conf | pg_rotate_logfile |
|
|
33
|
+
pg_stat_reset\w* | pg_stat_statements_reset | pg_advisory_\w+ | pg_switch_wal |
|
|
34
|
+
pg_promote | pg_wal_replay_\w+ | pg_create_\w+ | pg_drop_\w+ | pg_logical_\w+ |
|
|
35
|
+
pg_replication_\w+ | lo_import | lo_export | dblink\w* |
|
|
36
|
+
query_to_xml\w* | ts_stat | ts_rewrite
|
|
37
|
+
)\b/x
|
|
38
|
+
|
|
7
39
|
# CSRF protection. ActionController::Base does NOT enforce this by default;
|
|
8
40
|
# we opt in explicitly because every state-changing endpoint here
|
|
9
41
|
# (switch_*, execute_query, create_migration, reset_statistics, telegram
|
|
@@ -11,21 +43,23 @@ module PgReports
|
|
|
11
43
|
# ships authenticity_token in forms and X-CSRF-Token in XHR.
|
|
12
44
|
protect_from_forgery with: :exception
|
|
13
45
|
|
|
14
|
-
before_action :authenticate_dashboard
|
|
46
|
+
before_action :authenticate_dashboard!
|
|
47
|
+
around_action :within_selected_locale
|
|
15
48
|
before_action :set_categories
|
|
16
49
|
before_action :resolve_database_selection
|
|
17
50
|
around_action :within_selected_database
|
|
18
|
-
before_action :
|
|
51
|
+
before_action :require_query_monitor, only: %i[
|
|
19
52
|
start_query_monitoring stop_query_monitoring query_monitor_status
|
|
20
53
|
query_monitor_feed load_query_history download_query_monitor
|
|
21
54
|
]
|
|
22
|
-
before_action :enforce_rate_limit!, only:
|
|
23
|
-
|
|
24
|
-
]
|
|
55
|
+
before_action :enforce_rate_limit!, only: RATE_LIMITED_ACTIONS
|
|
56
|
+
before_action :require_migration_creation, only: %i[migration_preview create_migration]
|
|
25
57
|
|
|
26
58
|
helper_method :category_disabled_reason, :category_disabled?
|
|
59
|
+
helper_method :dashboard_locales, :selected_locale
|
|
27
60
|
|
|
28
61
|
def index
|
|
62
|
+
Compatibility.check_postgresql_once!
|
|
29
63
|
@pg_stat_status = pg_stat_status
|
|
30
64
|
@current_database = PgReports.system.current_database
|
|
31
65
|
end
|
|
@@ -48,6 +82,21 @@ module PgReports
|
|
|
48
82
|
# POST /switch_target
|
|
49
83
|
# Persists the chosen target in session, clears the database choice (each
|
|
50
84
|
# target has its own list of databases), and redirects back.
|
|
85
|
+
# POST /switch_locale
|
|
86
|
+
# Persists the chosen language in session; #within_selected_locale applies it
|
|
87
|
+
# to this and every later request.
|
|
88
|
+
def switch_locale
|
|
89
|
+
requested = params[:locale].to_s
|
|
90
|
+
|
|
91
|
+
if requested.empty?
|
|
92
|
+
session.delete(:pg_reports_locale)
|
|
93
|
+
elsif dashboard_locales.any? { |locale| locale.to_s == requested }
|
|
94
|
+
session[:pg_reports_locale] = requested
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
redirect_back fallback_location: root_path
|
|
98
|
+
end
|
|
99
|
+
|
|
51
100
|
def switch_target
|
|
52
101
|
requested = params[:target].to_s
|
|
53
102
|
|
|
@@ -137,11 +186,8 @@ module PgReports
|
|
|
137
186
|
|
|
138
187
|
# Load filter parameters from YAML
|
|
139
188
|
@report_filters = load_report_filters(@category, @report_key)
|
|
140
|
-
|
|
141
|
-
@report = execute_report(@category, @report_key)
|
|
142
189
|
rescue => e
|
|
143
190
|
@error = report_error_message(e)
|
|
144
|
-
@report = nil
|
|
145
191
|
end
|
|
146
192
|
|
|
147
193
|
def run
|
|
@@ -270,11 +316,9 @@ module PgReports
|
|
|
270
316
|
return
|
|
271
317
|
end
|
|
272
318
|
|
|
273
|
-
|
|
274
|
-
final_query = substitute_params(query, query_params)
|
|
319
|
+
binds = query_binds(query, query_params)
|
|
275
320
|
|
|
276
|
-
|
|
277
|
-
if final_query.match?(/\$\d+/)
|
|
321
|
+
if binds.nil?
|
|
278
322
|
render json: {
|
|
279
323
|
success: false,
|
|
280
324
|
error: I18n.t("pg_reports.ui.errors.missing_parameter_values")
|
|
@@ -283,9 +327,9 @@ module PgReports
|
|
|
283
327
|
end
|
|
284
328
|
|
|
285
329
|
explain_output = nil
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
explain_output =
|
|
330
|
+
in_read_only_transaction do
|
|
331
|
+
rows = select_rows("EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) #{cast_typed_placeholders(query)}", binds)
|
|
332
|
+
explain_output = rows.map { |r| r["QUERY PLAN"] }.join("\n")
|
|
289
333
|
end
|
|
290
334
|
|
|
291
335
|
# Analyze the EXPLAIN output
|
|
@@ -300,7 +344,7 @@ module PgReports
|
|
|
300
344
|
problems: analysis[:problems],
|
|
301
345
|
summary: analysis[:summary]
|
|
302
346
|
}
|
|
303
|
-
rescue ActiveRecord::QueryCanceled
|
|
347
|
+
rescue ActiveRecord::QueryCanceled, PG::QueryCanceled
|
|
304
348
|
render json: {success: false, error: query_timed_out_message}, status: :unprocessable_entity
|
|
305
349
|
rescue => e
|
|
306
350
|
render json: {success: false, error: e.message}, status: :unprocessable_entity
|
|
@@ -337,11 +381,9 @@ module PgReports
|
|
|
337
381
|
return
|
|
338
382
|
end
|
|
339
383
|
|
|
340
|
-
|
|
341
|
-
final_query = substitute_params(query, query_params)
|
|
384
|
+
binds = query_binds(query, query_params)
|
|
342
385
|
|
|
343
|
-
|
|
344
|
-
if final_query.match?(/\$\d+/)
|
|
386
|
+
if binds.nil?
|
|
345
387
|
render json: {
|
|
346
388
|
success: false,
|
|
347
389
|
error: I18n.t("pg_reports.ui.errors.missing_parameter_values")
|
|
@@ -349,42 +391,8 @@ module PgReports
|
|
|
349
391
|
return
|
|
350
392
|
end
|
|
351
393
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
|
394
|
+
render json: run_limited_select(cast_typed_placeholders(query), binds)
|
|
395
|
+
rescue ActiveRecord::QueryCanceled, PG::QueryCanceled
|
|
388
396
|
render json: {success: false, error: query_timed_out_message}, status: :unprocessable_entity
|
|
389
397
|
rescue => e
|
|
390
398
|
render json: {success: false, error: e.message}, status: :unprocessable_entity
|
|
@@ -419,78 +427,41 @@ module PgReports
|
|
|
419
427
|
return
|
|
420
428
|
end
|
|
421
429
|
|
|
422
|
-
|
|
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
|
|
444
|
-
end
|
|
445
|
-
|
|
446
|
-
render json: {
|
|
447
|
-
success: true,
|
|
448
|
-
columns: columns,
|
|
449
|
-
rows: rows,
|
|
450
|
-
count: rows.size,
|
|
451
|
-
total_count: total_count,
|
|
452
|
-
truncated: truncated,
|
|
453
|
-
execution_time: execution_time
|
|
454
|
-
}
|
|
455
|
-
rescue ActiveRecord::QueryCanceled
|
|
430
|
+
render json: run_limited_select(raw_query)
|
|
431
|
+
rescue ActiveRecord::QueryCanceled, PG::QueryCanceled
|
|
456
432
|
render json: {success: false, error: query_timed_out_message}, status: :unprocessable_entity
|
|
457
433
|
rescue => e
|
|
458
434
|
render json: {success: false, error: e.message}, status: :unprocessable_entity
|
|
459
435
|
end
|
|
460
436
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
success: false,
|
|
465
|
-
error: I18n.t("pg_reports.ui.errors.migration_dev_only")
|
|
466
|
-
}, status: :forbidden
|
|
467
|
-
return
|
|
468
|
-
end
|
|
469
|
-
|
|
470
|
-
file_name = params[:file_name]
|
|
471
|
-
code = params[:code]
|
|
437
|
+
# POST /migration_preview
|
|
438
|
+
def migration_preview
|
|
439
|
+
migration = drop_index_migration or return
|
|
472
440
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
441
|
+
render json: {success: true, code: migration.code, file_name: migration.file_name(migration_timestamp)}
|
|
442
|
+
rescue => e
|
|
443
|
+
render json: {success: false, error: e.message}, status: :unprocessable_entity
|
|
444
|
+
end
|
|
477
445
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
end
|
|
446
|
+
# POST /create_migration
|
|
447
|
+
# Takes the index to drop, never code: the file is built here from what the
|
|
448
|
+
# database reports, because `rails db:migrate` will execute it.
|
|
449
|
+
def create_migration
|
|
450
|
+
migration = drop_index_migration or return
|
|
484
451
|
|
|
485
|
-
# Find migrations directory
|
|
486
452
|
migrations_path = Rails.root.join("db", "migrate")
|
|
487
453
|
unless migrations_path.exist?
|
|
488
454
|
render json: {success: false, error: I18n.t("pg_reports.ui.errors.migrations_dir_not_found")}, status: :unprocessable_entity
|
|
489
455
|
return
|
|
490
456
|
end
|
|
491
457
|
|
|
492
|
-
|
|
493
|
-
|
|
458
|
+
if migrations_path.glob("*_#{migration.name}.rb").any?
|
|
459
|
+
render json: {success: false, error: I18n.t("pg_reports.ui.errors.migration_exists")}, status: :unprocessable_entity
|
|
460
|
+
return
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
file_path = migrations_path.join(migration.file_name(migration_timestamp))
|
|
464
|
+
File.write(file_path, migration.code, mode: "wx")
|
|
494
465
|
|
|
495
466
|
render json: {success: true, file_path: file_path.to_s, message: I18n.t("pg_reports.ui.success.migration_created")}
|
|
496
467
|
rescue => e
|
|
@@ -498,11 +469,7 @@ module PgReports
|
|
|
498
469
|
end
|
|
499
470
|
|
|
500
471
|
def start_query_monitoring
|
|
501
|
-
|
|
502
|
-
Rails.logger.info("PgReports: start_query_monitoring called. Instance: #{monitor.object_id}")
|
|
503
|
-
|
|
504
|
-
result = monitor.start
|
|
505
|
-
Rails.logger.info("PgReports: start result: #{result.inspect}")
|
|
472
|
+
result = PgReports::QueryMonitor.instance.start(source: params[:source])
|
|
506
473
|
|
|
507
474
|
if result[:success]
|
|
508
475
|
render json: result
|
|
@@ -510,7 +477,6 @@ module PgReports
|
|
|
510
477
|
render json: result, status: :unprocessable_entity
|
|
511
478
|
end
|
|
512
479
|
rescue => e
|
|
513
|
-
Rails.logger.error("PgReports: start_query_monitoring error: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
|
|
514
480
|
render json: {success: false, error: e.message}, status: :unprocessable_entity
|
|
515
481
|
end
|
|
516
482
|
|
|
@@ -529,39 +495,29 @@ module PgReports
|
|
|
529
495
|
end
|
|
530
496
|
|
|
531
497
|
def query_monitor_status
|
|
532
|
-
|
|
533
|
-
status = monitor.status
|
|
534
|
-
|
|
535
|
-
render json: {
|
|
536
|
-
success: true,
|
|
537
|
-
enabled: status[:enabled],
|
|
538
|
-
session_id: status[:session_id],
|
|
539
|
-
query_count: status[:query_count],
|
|
540
|
-
history_available: status[:history_available]
|
|
541
|
-
}
|
|
498
|
+
render json: PgReports::QueryMonitor.instance.status.merge(success: true)
|
|
542
499
|
rescue => e
|
|
543
500
|
render json: {success: false, error: e.message}, status: :unprocessable_entity
|
|
544
501
|
end
|
|
545
502
|
|
|
503
|
+
# A session that has ended is an ordinary state here, not an error: it can be
|
|
504
|
+
# stopped from another tab or run out its time, and the viewer keeps the rows.
|
|
546
505
|
def query_monitor_feed
|
|
547
506
|
monitor = PgReports::QueryMonitor.instance
|
|
548
507
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
limit = params[:limit]&.to_i || 50
|
|
556
|
-
session_id = params[:session_id]
|
|
557
|
-
|
|
558
|
-
queries = monitor.queries(limit: limit, session_id: session_id)
|
|
508
|
+
since = params[:since].to_i
|
|
509
|
+
queries = monitor.queries(
|
|
510
|
+
limit: params[:limit]&.to_i || 50,
|
|
511
|
+
session_id: params[:session_id],
|
|
512
|
+
since: since
|
|
513
|
+
)
|
|
559
514
|
|
|
560
|
-
render json:
|
|
515
|
+
render json: monitor.status.merge(
|
|
561
516
|
success: true,
|
|
562
517
|
queries: queries,
|
|
518
|
+
last_seq: queries.last ? queries.last[:seq] : since,
|
|
563
519
|
timestamp: Time.current.to_i
|
|
564
|
-
|
|
520
|
+
)
|
|
565
521
|
rescue => e
|
|
566
522
|
render json: {success: false, error: e.message}, status: :unprocessable_entity
|
|
567
523
|
end
|
|
@@ -604,7 +560,7 @@ module PgReports
|
|
|
604
560
|
type: "text/csv; charset=utf-8",
|
|
605
561
|
disposition: "attachment"
|
|
606
562
|
when "json"
|
|
607
|
-
send_data queries
|
|
563
|
+
send_data JSON.generate(queries),
|
|
608
564
|
filename: "#{filename}.json",
|
|
609
565
|
type: "application/json; charset=utf-8",
|
|
610
566
|
disposition: "attachment"
|
|
@@ -622,13 +578,48 @@ module PgReports
|
|
|
622
578
|
private
|
|
623
579
|
|
|
624
580
|
def authenticate_dashboard!
|
|
625
|
-
|
|
581
|
+
auth = PgReports.config.dashboard_auth
|
|
582
|
+
return if auth == AccessPolicy::HOST_MANAGED
|
|
583
|
+
return instance_exec(&auth) if auth.present?
|
|
584
|
+
return if AccessPolicy.open_access_allowed?(request)
|
|
585
|
+
|
|
586
|
+
render plain: AccessPolicy::DASHBOARD_DENIED, status: :forbidden
|
|
626
587
|
end
|
|
627
588
|
|
|
628
589
|
def set_categories
|
|
629
590
|
@categories = Dashboard::ReportsRegistry.all
|
|
630
591
|
end
|
|
631
592
|
|
|
593
|
+
# I18n.locale is global to the process, so assigning it outright would leak
|
|
594
|
+
# the dashboard's language into the host application's own rendering (and
|
|
595
|
+
# into whatever request runs next on this thread). with_locale restores the
|
|
596
|
+
# previous value once the action and its view are done.
|
|
597
|
+
def within_selected_locale(&block)
|
|
598
|
+
I18n.with_locale(selected_locale, &block)
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def selected_locale
|
|
602
|
+
@selected_locale ||= begin
|
|
603
|
+
stored = session[:pg_reports_locale].to_s
|
|
604
|
+
match = dashboard_locales.find { |locale| locale.to_s == stored }
|
|
605
|
+
match || I18n.locale
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
# Only locales that actually carry pg_reports translations. A host app can
|
|
610
|
+
# have dozens of its own locales while translating none of this dashboard,
|
|
611
|
+
# and offering those would just switch the UI to fallback English. Picked by
|
|
612
|
+
# probing one key rather than by listing the shipped files, so a host app
|
|
613
|
+
# that supplies its own pg_reports translations is offered too.
|
|
614
|
+
def dashboard_locales
|
|
615
|
+
@dashboard_locales ||= begin
|
|
616
|
+
translated = I18n.available_locales.select do |locale|
|
|
617
|
+
I18n.exists?("pg_reports.ui.branding.title", locale)
|
|
618
|
+
end
|
|
619
|
+
translated.presence || [I18n.default_locale]
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
|
|
632
623
|
# Resolves which target/database every action should run against, based on:
|
|
633
624
|
# 1. session[:pg_reports_target] (set by #switch_target)
|
|
634
625
|
# 2. session[:pg_reports_database] (set by #switch_database)
|
|
@@ -659,7 +650,10 @@ module PgReports
|
|
|
659
650
|
|
|
660
651
|
begin
|
|
661
652
|
registry.with_context(target: @selected_target) do
|
|
662
|
-
@available_databases = registry.fetch(@selected_target).list_databases(
|
|
653
|
+
@available_databases = registry.fetch(@selected_target).list_databases(
|
|
654
|
+
current: @target_default_database,
|
|
655
|
+
sizes: DATABASE_SELECTOR_ACTIONS.include?(action_name)
|
|
656
|
+
)
|
|
663
657
|
end
|
|
664
658
|
rescue => e
|
|
665
659
|
@database_error = PgReports::Connection::ErrorTranslator.translate(e)
|
|
@@ -742,7 +736,11 @@ module PgReports
|
|
|
742
736
|
else raise ArgumentError, "Unknown category: #{category}"
|
|
743
737
|
end
|
|
744
738
|
|
|
745
|
-
|
|
739
|
+
# The report name comes from the URL and the dispatch below is public_send,
|
|
740
|
+
# so the registry decides what can run. respond_to? would also admit
|
|
741
|
+
# reset_statistics!, enable_pg_stat_statements! and Module#freeze, reachable
|
|
742
|
+
# by a plain GET that CSRF protection does not cover.
|
|
743
|
+
unless Dashboard::ReportsRegistry.find(category, report_key)
|
|
746
744
|
raise ArgumentError, "Unknown report: #{report_key}"
|
|
747
745
|
end
|
|
748
746
|
|
|
@@ -828,100 +826,140 @@ module PgReports
|
|
|
828
826
|
end
|
|
829
827
|
end
|
|
830
828
|
|
|
831
|
-
# SQL Query Monitor taps ActiveSupport::Notifications in the host
|
|
832
|
-
# application's process. In standalone mode there is no host app — pg_reports
|
|
833
|
-
# is the only process running, so there's nothing meaningful to observe.
|
|
834
829
|
# Blocks the API even if a client calls it directly (the UI panel is also
|
|
835
|
-
# hidden
|
|
836
|
-
def
|
|
837
|
-
return
|
|
830
|
+
# hidden when the monitor is unavailable, see dashboard/index view).
|
|
831
|
+
def require_query_monitor
|
|
832
|
+
return if PgReports.config.query_monitor_available?
|
|
838
833
|
|
|
839
|
-
render json: {
|
|
840
|
-
success: false,
|
|
841
|
-
error: I18n.t("pg_reports.ui.errors.query_monitor_unavailable_standalone")
|
|
842
|
-
}, status: :forbidden
|
|
834
|
+
render json: {success: false, error: I18n.t("pg_reports.ui.errors.query_monitor_disabled")}, status: :forbidden
|
|
843
835
|
end
|
|
844
836
|
|
|
845
|
-
#
|
|
846
|
-
#
|
|
847
|
-
# just a best-effort guard against a single client hammering these
|
|
848
|
-
# expensive/privileged actions, backed by Rails.cache (works with or
|
|
849
|
-
# without a shared cache backend across processes). Fails open if the
|
|
850
|
-
# cache is unavailable, consistent with the rest of the dashboard (see
|
|
851
|
-
# #resolve_database_selection, #retrieve_query_by_hash).
|
|
837
|
+
# Per-IP throttle for what is expensive, privileged or leaves the building. See
|
|
838
|
+
# RateLimiter for what it can and cannot promise.
|
|
852
839
|
def enforce_rate_limit!
|
|
853
840
|
limit = PgReports.config.raw_query_rate_limit
|
|
854
841
|
return if limit.nil?
|
|
855
842
|
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
current
|
|
863
|
-
rescue => e
|
|
864
|
-
Rails.logger.warn("PgReports: Rate limit cache unavailable: #{e.message}") if defined?(Rails.logger)
|
|
865
|
-
return
|
|
866
|
-
end
|
|
867
|
-
|
|
868
|
-
return if count <= limit
|
|
843
|
+
retry_after = RateLimiter.instance.retry_after(
|
|
844
|
+
"#{request.remote_ip}:#{params[:action]}",
|
|
845
|
+
limit: limit,
|
|
846
|
+
window: PgReports.config.raw_query_rate_limit_window_seconds
|
|
847
|
+
)
|
|
848
|
+
return unless retry_after
|
|
869
849
|
|
|
850
|
+
response.set_header("Retry-After", retry_after.to_s)
|
|
870
851
|
render json: {
|
|
871
852
|
success: false,
|
|
872
853
|
error: I18n.t("pg_reports.ui.errors.rate_limit_exceeded")
|
|
873
854
|
}, status: :too_many_requests
|
|
874
855
|
end
|
|
875
856
|
|
|
876
|
-
def
|
|
877
|
-
return
|
|
857
|
+
def require_migration_creation
|
|
858
|
+
return if PgReports.config.allow_migration_creation
|
|
878
859
|
|
|
879
|
-
|
|
880
|
-
@selected_database == @target_default_database
|
|
860
|
+
render json: {success: false, error: I18n.t("pg_reports.ui.errors.migration_dev_only")}, status: :forbidden
|
|
881
861
|
end
|
|
882
862
|
|
|
883
|
-
|
|
884
|
-
|
|
863
|
+
# The migration for the index named in the request, or nil once a response has
|
|
864
|
+
# been rendered. A migration runs on the application's own database, so one
|
|
865
|
+
# built from an index elsewhere would drop nothing, or the wrong index.
|
|
866
|
+
def drop_index_migration
|
|
867
|
+
unless on_primary_default_database?
|
|
868
|
+
render json: {success: false, error: I18n.t("pg_reports.ui.errors.migration_primary_only")}, status: :unprocessable_entity
|
|
869
|
+
return
|
|
870
|
+
end
|
|
885
871
|
|
|
886
|
-
|
|
887
|
-
params_hash.keys.map(&:to_i).sort.reverse_each do |num|
|
|
888
|
-
value = params_hash[num.to_s] || params_hash[num]
|
|
889
|
-
next if value.nil? || value.to_s.empty?
|
|
872
|
+
rows = select_rows(INDEX_LOOKUP, [params[:index_name].to_s, params[:schema].to_s])
|
|
890
873
|
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
874
|
+
# More than one means the same name in two schemas, and no way to tell which
|
|
875
|
+
unless rows.one?
|
|
876
|
+
render json: {success: false, error: I18n.t("pg_reports.ui.errors.index_not_found")}, status: :unprocessable_entity
|
|
877
|
+
return
|
|
894
878
|
end
|
|
895
879
|
|
|
896
|
-
|
|
880
|
+
DropIndexMigration.new(rows.first, version: ActiveRecord::Migration.current_version)
|
|
897
881
|
end
|
|
898
882
|
|
|
899
|
-
def
|
|
900
|
-
|
|
883
|
+
def migration_timestamp
|
|
884
|
+
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
885
|
+
end
|
|
901
886
|
|
|
902
|
-
|
|
903
|
-
if
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
887
|
+
def on_primary_default_database?
|
|
888
|
+
return true if @target_default_database.nil?
|
|
889
|
+
|
|
890
|
+
@selected_target == PgReports.connection_registry.default_name &&
|
|
891
|
+
@selected_database == @target_default_database
|
|
892
|
+
end
|
|
893
|
+
|
|
894
|
+
# Values for the query's $n placeholders, in order, or nil if one is missing.
|
|
895
|
+
# They travel as bind parameters and are never written into the SQL: the text
|
|
896
|
+
# of a captured query can hold a `$1` anywhere, including inside a string
|
|
897
|
+
# literal, where a substituted and quoted value would end that literal.
|
|
898
|
+
def query_binds(query, params_hash)
|
|
899
|
+
numbers = query.scan(/\$(\d+)/).flatten.map(&:to_i).uniq
|
|
900
|
+
return [] if numbers.empty?
|
|
901
|
+
return nil if numbers.max > MAX_BIND_PARAMETERS
|
|
902
|
+
|
|
903
|
+
binds = Array.new(numbers.max)
|
|
904
|
+
numbers.each do |number|
|
|
905
|
+
value = params_hash[number.to_s].to_s
|
|
906
|
+
return nil if value.empty?
|
|
907
|
+
|
|
908
|
+
binds[number - 1] = (value.downcase == "null") ? nil : value
|
|
912
909
|
end
|
|
910
|
+
binds
|
|
913
911
|
end
|
|
914
912
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
913
|
+
# pg_stat_statements reduces `interval '1 day'` to `interval $1`, which is only
|
|
914
|
+
# valid for a literal. The cast form means the same and accepts a parameter.
|
|
915
|
+
def cast_typed_placeholders(query)
|
|
916
|
+
query.gsub(TYPED_PLACEHOLDER) { "$#{$2}::#{$1}" }
|
|
917
|
+
end
|
|
918
918
|
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
919
|
+
# The extended protocol refuses more than one statement, which is what keeps a
|
|
920
|
+
# query to the single statement that was validated. connection.execute uses
|
|
921
|
+
# the simple protocol, where text after a `;` would simply run.
|
|
922
|
+
def select_rows(sql, binds = [])
|
|
923
|
+
PgReports.config.connection.raw_connection.exec_params(sql, binds).to_a
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
def run_limited_select(query, binds = [])
|
|
927
|
+
rows = nil
|
|
928
|
+
total_count = 0
|
|
929
|
+
truncated = false
|
|
930
|
+
execution_time = nil
|
|
931
|
+
|
|
932
|
+
in_read_only_transaction do
|
|
933
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
934
|
+
rows = select_rows("SELECT * FROM (#{wrapped(query)}) AS pg_reports_limited LIMIT #{MAX_RESULT_ROWS}", binds)
|
|
935
|
+
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
936
|
+
execution_time = ((end_time - start_time) * 1000).round(2)
|
|
937
|
+
|
|
938
|
+
total_count = rows.size
|
|
939
|
+
|
|
940
|
+
if rows.size >= MAX_RESULT_ROWS
|
|
941
|
+
count = select_rows("SELECT COUNT(*) FROM (#{wrapped(query)}) AS count_query", binds)
|
|
942
|
+
total_count = count.first["count"].to_i
|
|
943
|
+
truncated = total_count > MAX_RESULT_ROWS
|
|
944
|
+
end
|
|
924
945
|
end
|
|
946
|
+
|
|
947
|
+
{
|
|
948
|
+
success: true,
|
|
949
|
+
columns: rows.first&.keys || [],
|
|
950
|
+
rows: rows,
|
|
951
|
+
count: rows.size,
|
|
952
|
+
total_count: total_count,
|
|
953
|
+
truncated: truncated,
|
|
954
|
+
execution_time: execution_time
|
|
955
|
+
}
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
# On lines of its own, so that a query ending in a -- comment cannot comment out
|
|
959
|
+
# the closing parenthesis. As a subquery, the row limit holds whatever LIMIT the
|
|
960
|
+
# query itself asks for, and nothing has to be read out of its text.
|
|
961
|
+
def wrapped(query)
|
|
962
|
+
"\n#{query}\n"
|
|
925
963
|
end
|
|
926
964
|
|
|
927
965
|
# Generate a secure hash for a query and store it in cache
|
|
@@ -989,18 +1027,36 @@ module PgReports
|
|
|
989
1027
|
raise SecurityError, "Dangerous keyword detected: #{keyword.upcase}"
|
|
990
1028
|
end
|
|
991
1029
|
end
|
|
1030
|
+
|
|
1031
|
+
if (function = normalized[ADMIN_FUNCTIONS])
|
|
1032
|
+
raise SecurityError, "Administrative function not allowed: #{function}"
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
# U&"pg\005fls_dir" names a function without ever spelling it
|
|
1036
|
+
if normalized.include?('u&"')
|
|
1037
|
+
raise SecurityError, "Unicode-escaped identifiers are not allowed"
|
|
1038
|
+
end
|
|
992
1039
|
end
|
|
993
1040
|
|
|
994
|
-
#
|
|
995
|
-
#
|
|
996
|
-
#
|
|
997
|
-
#
|
|
998
|
-
#
|
|
999
|
-
|
|
1000
|
-
|
|
1041
|
+
# The envelope of every raw query (Execute Query / EXPLAIN ANALYZE / SQL
|
|
1042
|
+
# Console), on the connection for the selected target and database, which is
|
|
1043
|
+
# the one #select_rows uses: settings only bind the query on that connection.
|
|
1044
|
+
#
|
|
1045
|
+
# READ ONLY makes PostgreSQL refuse writes that text checks cannot see, such as
|
|
1046
|
+
# SELECT INTO or a volatile function. SET LOCAL bounds the run time.
|
|
1047
|
+
#
|
|
1048
|
+
# It always ends in ROLLBACK. Nothing here has anything to commit, and
|
|
1049
|
+
# set_config(..., false) from a SELECT survives a COMMIT: the connection goes
|
|
1050
|
+
# back to the pool, possibly the host application's, with that setting on it.
|
|
1051
|
+
def in_read_only_transaction
|
|
1052
|
+
connection = PgReports.config.connection
|
|
1053
|
+
|
|
1054
|
+
connection.transaction do
|
|
1055
|
+
connection.execute("SET TRANSACTION READ ONLY")
|
|
1001
1056
|
timeout_ms = PgReports.config.raw_query_statement_timeout_ms.to_i
|
|
1002
|
-
|
|
1057
|
+
connection.execute("SET LOCAL statement_timeout = #{timeout_ms}") if timeout_ms.positive?
|
|
1003
1058
|
yield
|
|
1059
|
+
raise ActiveRecord::Rollback
|
|
1004
1060
|
end
|
|
1005
1061
|
end
|
|
1006
1062
|
|
|
@@ -1021,9 +1077,9 @@ module PgReports
|
|
|
1021
1077
|
csv << [
|
|
1022
1078
|
query[:timestamp],
|
|
1023
1079
|
query[:duration_ms],
|
|
1024
|
-
query[:name],
|
|
1025
|
-
query[:sql],
|
|
1026
|
-
query.dig(:source_location, :file),
|
|
1080
|
+
CsvCell.safe(query[:name]),
|
|
1081
|
+
CsvCell.safe(query[:sql]),
|
|
1082
|
+
CsvCell.safe(query.dig(:source_location, :file)),
|
|
1027
1083
|
query.dig(:source_location, :line)
|
|
1028
1084
|
]
|
|
1029
1085
|
end
|