pg_reports 0.9.1 → 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 +72 -0
- data/README.md +12 -8
- data/app/controllers/pg_reports/dashboard_controller.rb +230 -221
- data/app/controllers/pg_reports/metrics_controller.rb +13 -4
- data/app/views/layouts/pg_reports/application.html.erb +126 -80
- data/app/views/pg_reports/dashboard/_show_scripts.html.erb +74 -66
- data/app/views/pg_reports/dashboard/index.html.erb +310 -124
- data/bin/pg_reports +2 -2
- data/config/brakeman.ignore +10 -56
- data/config/locales/en.yml +39 -8
- data/config/locales/ru.yml +38 -8
- data/config/locales/uk.yml +38 -8
- data/config/routes.rb +1 -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 +17 -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,23 +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!
|
|
15
47
|
around_action :within_selected_locale
|
|
16
48
|
before_action :set_categories
|
|
17
49
|
before_action :resolve_database_selection
|
|
18
50
|
around_action :within_selected_database
|
|
19
|
-
before_action :
|
|
51
|
+
before_action :require_query_monitor, only: %i[
|
|
20
52
|
start_query_monitoring stop_query_monitoring query_monitor_status
|
|
21
53
|
query_monitor_feed load_query_history download_query_monitor
|
|
22
54
|
]
|
|
23
|
-
before_action :enforce_rate_limit!, only:
|
|
24
|
-
|
|
25
|
-
]
|
|
55
|
+
before_action :enforce_rate_limit!, only: RATE_LIMITED_ACTIONS
|
|
56
|
+
before_action :require_migration_creation, only: %i[migration_preview create_migration]
|
|
26
57
|
|
|
27
58
|
helper_method :category_disabled_reason, :category_disabled?
|
|
28
59
|
helper_method :dashboard_locales, :selected_locale
|
|
29
60
|
|
|
30
61
|
def index
|
|
62
|
+
Compatibility.check_postgresql_once!
|
|
31
63
|
@pg_stat_status = pg_stat_status
|
|
32
64
|
@current_database = PgReports.system.current_database
|
|
33
65
|
end
|
|
@@ -154,11 +186,8 @@ module PgReports
|
|
|
154
186
|
|
|
155
187
|
# Load filter parameters from YAML
|
|
156
188
|
@report_filters = load_report_filters(@category, @report_key)
|
|
157
|
-
|
|
158
|
-
@report = execute_report(@category, @report_key)
|
|
159
189
|
rescue => e
|
|
160
190
|
@error = report_error_message(e)
|
|
161
|
-
@report = nil
|
|
162
191
|
end
|
|
163
192
|
|
|
164
193
|
def run
|
|
@@ -287,11 +316,9 @@ module PgReports
|
|
|
287
316
|
return
|
|
288
317
|
end
|
|
289
318
|
|
|
290
|
-
|
|
291
|
-
final_query = substitute_params(query, query_params)
|
|
319
|
+
binds = query_binds(query, query_params)
|
|
292
320
|
|
|
293
|
-
|
|
294
|
-
if final_query.match?(/\$\d+/)
|
|
321
|
+
if binds.nil?
|
|
295
322
|
render json: {
|
|
296
323
|
success: false,
|
|
297
324
|
error: I18n.t("pg_reports.ui.errors.missing_parameter_values")
|
|
@@ -300,9 +327,9 @@ module PgReports
|
|
|
300
327
|
end
|
|
301
328
|
|
|
302
329
|
explain_output = nil
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
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")
|
|
306
333
|
end
|
|
307
334
|
|
|
308
335
|
# Analyze the EXPLAIN output
|
|
@@ -317,7 +344,7 @@ module PgReports
|
|
|
317
344
|
problems: analysis[:problems],
|
|
318
345
|
summary: analysis[:summary]
|
|
319
346
|
}
|
|
320
|
-
rescue ActiveRecord::QueryCanceled
|
|
347
|
+
rescue ActiveRecord::QueryCanceled, PG::QueryCanceled
|
|
321
348
|
render json: {success: false, error: query_timed_out_message}, status: :unprocessable_entity
|
|
322
349
|
rescue => e
|
|
323
350
|
render json: {success: false, error: e.message}, status: :unprocessable_entity
|
|
@@ -354,11 +381,9 @@ module PgReports
|
|
|
354
381
|
return
|
|
355
382
|
end
|
|
356
383
|
|
|
357
|
-
|
|
358
|
-
final_query = substitute_params(query, query_params)
|
|
384
|
+
binds = query_binds(query, query_params)
|
|
359
385
|
|
|
360
|
-
|
|
361
|
-
if final_query.match?(/\$\d+/)
|
|
386
|
+
if binds.nil?
|
|
362
387
|
render json: {
|
|
363
388
|
success: false,
|
|
364
389
|
error: I18n.t("pg_reports.ui.errors.missing_parameter_values")
|
|
@@ -366,42 +391,8 @@ module PgReports
|
|
|
366
391
|
return
|
|
367
392
|
end
|
|
368
393
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
rows = columns = nil
|
|
373
|
-
total_count = 0
|
|
374
|
-
truncated = false
|
|
375
|
-
execution_time = nil
|
|
376
|
-
|
|
377
|
-
with_statement_timeout do
|
|
378
|
-
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
379
|
-
result = ActiveRecord::Base.connection.execute(limited_query)
|
|
380
|
-
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
381
|
-
execution_time = ((end_time - start_time) * 1000).round(2)
|
|
382
|
-
|
|
383
|
-
rows = result.to_a
|
|
384
|
-
columns = rows.first&.keys || []
|
|
385
|
-
total_count = rows.size
|
|
386
|
-
|
|
387
|
-
# Check if we need to get total count
|
|
388
|
-
if rows.size >= 100
|
|
389
|
-
count_result = ActiveRecord::Base.connection.execute("SELECT COUNT(*) FROM (#{final_query}) AS count_query")
|
|
390
|
-
total_count = count_result.first["count"].to_i
|
|
391
|
-
truncated = total_count > 100
|
|
392
|
-
end
|
|
393
|
-
end
|
|
394
|
-
|
|
395
|
-
render json: {
|
|
396
|
-
success: true,
|
|
397
|
-
columns: columns,
|
|
398
|
-
rows: rows,
|
|
399
|
-
count: rows.size,
|
|
400
|
-
total_count: total_count,
|
|
401
|
-
truncated: truncated,
|
|
402
|
-
execution_time: execution_time
|
|
403
|
-
}
|
|
404
|
-
rescue ActiveRecord::QueryCanceled
|
|
394
|
+
render json: run_limited_select(cast_typed_placeholders(query), binds)
|
|
395
|
+
rescue ActiveRecord::QueryCanceled, PG::QueryCanceled
|
|
405
396
|
render json: {success: false, error: query_timed_out_message}, status: :unprocessable_entity
|
|
406
397
|
rescue => e
|
|
407
398
|
render json: {success: false, error: e.message}, status: :unprocessable_entity
|
|
@@ -436,78 +427,41 @@ module PgReports
|
|
|
436
427
|
return
|
|
437
428
|
end
|
|
438
429
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
rows = columns = nil
|
|
442
|
-
total_count = 0
|
|
443
|
-
truncated = false
|
|
444
|
-
execution_time = nil
|
|
445
|
-
|
|
446
|
-
with_statement_timeout do
|
|
447
|
-
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
448
|
-
result = ActiveRecord::Base.connection.execute(limited_query)
|
|
449
|
-
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
450
|
-
execution_time = ((end_time - start_time) * 1000).round(2)
|
|
451
|
-
|
|
452
|
-
rows = result.to_a
|
|
453
|
-
columns = rows.first&.keys || []
|
|
454
|
-
total_count = rows.size
|
|
455
|
-
|
|
456
|
-
if rows.size >= 100
|
|
457
|
-
count_result = ActiveRecord::Base.connection.execute("SELECT COUNT(*) FROM (#{raw_query}) AS count_query")
|
|
458
|
-
total_count = count_result.first["count"].to_i
|
|
459
|
-
truncated = total_count > 100
|
|
460
|
-
end
|
|
461
|
-
end
|
|
462
|
-
|
|
463
|
-
render json: {
|
|
464
|
-
success: true,
|
|
465
|
-
columns: columns,
|
|
466
|
-
rows: rows,
|
|
467
|
-
count: rows.size,
|
|
468
|
-
total_count: total_count,
|
|
469
|
-
truncated: truncated,
|
|
470
|
-
execution_time: execution_time
|
|
471
|
-
}
|
|
472
|
-
rescue ActiveRecord::QueryCanceled
|
|
430
|
+
render json: run_limited_select(raw_query)
|
|
431
|
+
rescue ActiveRecord::QueryCanceled, PG::QueryCanceled
|
|
473
432
|
render json: {success: false, error: query_timed_out_message}, status: :unprocessable_entity
|
|
474
433
|
rescue => e
|
|
475
434
|
render json: {success: false, error: e.message}, status: :unprocessable_entity
|
|
476
435
|
end
|
|
477
436
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
success: false,
|
|
482
|
-
error: I18n.t("pg_reports.ui.errors.migration_dev_only")
|
|
483
|
-
}, status: :forbidden
|
|
484
|
-
return
|
|
485
|
-
end
|
|
486
|
-
|
|
487
|
-
file_name = params[:file_name]
|
|
488
|
-
code = params[:code]
|
|
437
|
+
# POST /migration_preview
|
|
438
|
+
def migration_preview
|
|
439
|
+
migration = drop_index_migration or return
|
|
489
440
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
|
494
445
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
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
|
|
501
451
|
|
|
502
|
-
# Find migrations directory
|
|
503
452
|
migrations_path = Rails.root.join("db", "migrate")
|
|
504
453
|
unless migrations_path.exist?
|
|
505
454
|
render json: {success: false, error: I18n.t("pg_reports.ui.errors.migrations_dir_not_found")}, status: :unprocessable_entity
|
|
506
455
|
return
|
|
507
456
|
end
|
|
508
457
|
|
|
509
|
-
|
|
510
|
-
|
|
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")
|
|
511
465
|
|
|
512
466
|
render json: {success: true, file_path: file_path.to_s, message: I18n.t("pg_reports.ui.success.migration_created")}
|
|
513
467
|
rescue => e
|
|
@@ -515,11 +469,7 @@ module PgReports
|
|
|
515
469
|
end
|
|
516
470
|
|
|
517
471
|
def start_query_monitoring
|
|
518
|
-
|
|
519
|
-
Rails.logger.info("PgReports: start_query_monitoring called. Instance: #{monitor.object_id}")
|
|
520
|
-
|
|
521
|
-
result = monitor.start
|
|
522
|
-
Rails.logger.info("PgReports: start result: #{result.inspect}")
|
|
472
|
+
result = PgReports::QueryMonitor.instance.start(source: params[:source])
|
|
523
473
|
|
|
524
474
|
if result[:success]
|
|
525
475
|
render json: result
|
|
@@ -527,7 +477,6 @@ module PgReports
|
|
|
527
477
|
render json: result, status: :unprocessable_entity
|
|
528
478
|
end
|
|
529
479
|
rescue => e
|
|
530
|
-
Rails.logger.error("PgReports: start_query_monitoring error: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
|
|
531
480
|
render json: {success: false, error: e.message}, status: :unprocessable_entity
|
|
532
481
|
end
|
|
533
482
|
|
|
@@ -546,39 +495,29 @@ module PgReports
|
|
|
546
495
|
end
|
|
547
496
|
|
|
548
497
|
def query_monitor_status
|
|
549
|
-
|
|
550
|
-
status = monitor.status
|
|
551
|
-
|
|
552
|
-
render json: {
|
|
553
|
-
success: true,
|
|
554
|
-
enabled: status[:enabled],
|
|
555
|
-
session_id: status[:session_id],
|
|
556
|
-
query_count: status[:query_count],
|
|
557
|
-
history_available: status[:history_available]
|
|
558
|
-
}
|
|
498
|
+
render json: PgReports::QueryMonitor.instance.status.merge(success: true)
|
|
559
499
|
rescue => e
|
|
560
500
|
render json: {success: false, error: e.message}, status: :unprocessable_entity
|
|
561
501
|
end
|
|
562
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.
|
|
563
505
|
def query_monitor_feed
|
|
564
506
|
monitor = PgReports::QueryMonitor.instance
|
|
565
507
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
limit = params[:limit]&.to_i || 50
|
|
573
|
-
session_id = params[:session_id]
|
|
574
|
-
|
|
575
|
-
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
|
+
)
|
|
576
514
|
|
|
577
|
-
render json:
|
|
515
|
+
render json: monitor.status.merge(
|
|
578
516
|
success: true,
|
|
579
517
|
queries: queries,
|
|
518
|
+
last_seq: queries.last ? queries.last[:seq] : since,
|
|
580
519
|
timestamp: Time.current.to_i
|
|
581
|
-
|
|
520
|
+
)
|
|
582
521
|
rescue => e
|
|
583
522
|
render json: {success: false, error: e.message}, status: :unprocessable_entity
|
|
584
523
|
end
|
|
@@ -621,7 +560,7 @@ module PgReports
|
|
|
621
560
|
type: "text/csv; charset=utf-8",
|
|
622
561
|
disposition: "attachment"
|
|
623
562
|
when "json"
|
|
624
|
-
send_data queries
|
|
563
|
+
send_data JSON.generate(queries),
|
|
625
564
|
filename: "#{filename}.json",
|
|
626
565
|
type: "application/json; charset=utf-8",
|
|
627
566
|
disposition: "attachment"
|
|
@@ -639,7 +578,12 @@ module PgReports
|
|
|
639
578
|
private
|
|
640
579
|
|
|
641
580
|
def authenticate_dashboard!
|
|
642
|
-
|
|
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
|
|
643
587
|
end
|
|
644
588
|
|
|
645
589
|
def set_categories
|
|
@@ -706,7 +650,10 @@ module PgReports
|
|
|
706
650
|
|
|
707
651
|
begin
|
|
708
652
|
registry.with_context(target: @selected_target) do
|
|
709
|
-
@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
|
+
)
|
|
710
657
|
end
|
|
711
658
|
rescue => e
|
|
712
659
|
@database_error = PgReports::Connection::ErrorTranslator.translate(e)
|
|
@@ -789,7 +736,11 @@ module PgReports
|
|
|
789
736
|
else raise ArgumentError, "Unknown category: #{category}"
|
|
790
737
|
end
|
|
791
738
|
|
|
792
|
-
|
|
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)
|
|
793
744
|
raise ArgumentError, "Unknown report: #{report_key}"
|
|
794
745
|
end
|
|
795
746
|
|
|
@@ -875,100 +826,140 @@ module PgReports
|
|
|
875
826
|
end
|
|
876
827
|
end
|
|
877
828
|
|
|
878
|
-
# SQL Query Monitor taps ActiveSupport::Notifications in the host
|
|
879
|
-
# application's process. In standalone mode there is no host app — pg_reports
|
|
880
|
-
# is the only process running, so there's nothing meaningful to observe.
|
|
881
829
|
# Blocks the API even if a client calls it directly (the UI panel is also
|
|
882
|
-
# hidden
|
|
883
|
-
def
|
|
884
|
-
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?
|
|
885
833
|
|
|
886
|
-
render json: {
|
|
887
|
-
success: false,
|
|
888
|
-
error: I18n.t("pg_reports.ui.errors.query_monitor_unavailable_standalone")
|
|
889
|
-
}, status: :forbidden
|
|
834
|
+
render json: {success: false, error: I18n.t("pg_reports.ui.errors.query_monitor_disabled")}, status: :forbidden
|
|
890
835
|
end
|
|
891
836
|
|
|
892
|
-
#
|
|
893
|
-
#
|
|
894
|
-
# just a best-effort guard against a single client hammering these
|
|
895
|
-
# expensive/privileged actions, backed by Rails.cache (works with or
|
|
896
|
-
# without a shared cache backend across processes). Fails open if the
|
|
897
|
-
# cache is unavailable, consistent with the rest of the dashboard (see
|
|
898
|
-
# #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.
|
|
899
839
|
def enforce_rate_limit!
|
|
900
840
|
limit = PgReports.config.raw_query_rate_limit
|
|
901
841
|
return if limit.nil?
|
|
902
842
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
current
|
|
910
|
-
rescue => e
|
|
911
|
-
Rails.logger.warn("PgReports: Rate limit cache unavailable: #{e.message}") if defined?(Rails.logger)
|
|
912
|
-
return
|
|
913
|
-
end
|
|
914
|
-
|
|
915
|
-
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
|
|
916
849
|
|
|
850
|
+
response.set_header("Retry-After", retry_after.to_s)
|
|
917
851
|
render json: {
|
|
918
852
|
success: false,
|
|
919
853
|
error: I18n.t("pg_reports.ui.errors.rate_limit_exceeded")
|
|
920
854
|
}, status: :too_many_requests
|
|
921
855
|
end
|
|
922
856
|
|
|
923
|
-
def
|
|
924
|
-
return
|
|
857
|
+
def require_migration_creation
|
|
858
|
+
return if PgReports.config.allow_migration_creation
|
|
925
859
|
|
|
926
|
-
|
|
927
|
-
@selected_database == @target_default_database
|
|
860
|
+
render json: {success: false, error: I18n.t("pg_reports.ui.errors.migration_dev_only")}, status: :forbidden
|
|
928
861
|
end
|
|
929
862
|
|
|
930
|
-
|
|
931
|
-
|
|
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
|
|
932
871
|
|
|
933
|
-
|
|
934
|
-
params_hash.keys.map(&:to_i).sort.reverse_each do |num|
|
|
935
|
-
value = params_hash[num.to_s] || params_hash[num]
|
|
936
|
-
next if value.nil? || value.to_s.empty?
|
|
872
|
+
rows = select_rows(INDEX_LOOKUP, [params[:index_name].to_s, params[:schema].to_s])
|
|
937
873
|
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
|
941
878
|
end
|
|
942
879
|
|
|
943
|
-
|
|
880
|
+
DropIndexMigration.new(rows.first, version: ActiveRecord::Migration.current_version)
|
|
944
881
|
end
|
|
945
882
|
|
|
946
|
-
def
|
|
947
|
-
|
|
883
|
+
def migration_timestamp
|
|
884
|
+
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
885
|
+
end
|
|
948
886
|
|
|
949
|
-
|
|
950
|
-
if
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
|
959
909
|
end
|
|
910
|
+
binds
|
|
960
911
|
end
|
|
961
912
|
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
|
965
918
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
|
971
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"
|
|
972
963
|
end
|
|
973
964
|
|
|
974
965
|
# Generate a secure hash for a query and store it in cache
|
|
@@ -1036,18 +1027,36 @@ module PgReports
|
|
|
1036
1027
|
raise SecurityError, "Dangerous keyword detected: #{keyword.upcase}"
|
|
1037
1028
|
end
|
|
1038
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
|
|
1039
1039
|
end
|
|
1040
1040
|
|
|
1041
|
-
#
|
|
1042
|
-
#
|
|
1043
|
-
#
|
|
1044
|
-
#
|
|
1045
|
-
#
|
|
1046
|
-
|
|
1047
|
-
|
|
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")
|
|
1048
1056
|
timeout_ms = PgReports.config.raw_query_statement_timeout_ms.to_i
|
|
1049
|
-
|
|
1057
|
+
connection.execute("SET LOCAL statement_timeout = #{timeout_ms}") if timeout_ms.positive?
|
|
1050
1058
|
yield
|
|
1059
|
+
raise ActiveRecord::Rollback
|
|
1051
1060
|
end
|
|
1052
1061
|
end
|
|
1053
1062
|
|
|
@@ -1068,9 +1077,9 @@ module PgReports
|
|
|
1068
1077
|
csv << [
|
|
1069
1078
|
query[:timestamp],
|
|
1070
1079
|
query[:duration_ms],
|
|
1071
|
-
query[:name],
|
|
1072
|
-
query[:sql],
|
|
1073
|
-
query.dig(:source_location, :file),
|
|
1080
|
+
CsvCell.safe(query[:name]),
|
|
1081
|
+
CsvCell.safe(query[:sql]),
|
|
1082
|
+
CsvCell.safe(query.dig(:source_location, :file)),
|
|
1074
1083
|
query.dig(:source_location, :line)
|
|
1075
1084
|
]
|
|
1076
1085
|
end
|