pg_reports 0.7.0 → 0.8.1
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 +50 -0
- data/README.md +36 -150
- data/app/controllers/pg_reports/dashboard_controller.rb +143 -3
- data/app/views/layouts/pg_reports/application.html.erb +213 -6
- data/app/views/pg_reports/dashboard/_database_selector.html.erb +39 -0
- data/app/views/pg_reports/dashboard/_target_selector.html.erb +27 -0
- data/app/views/pg_reports/dashboard/index.html.erb +143 -50
- data/app/views/pg_reports/dashboard/show.html.erb +4 -0
- data/bin/pg_reports +85 -0
- data/config/locales/en.yml +18 -7
- data/config/locales/ru.yml +18 -7
- data/config/locales/uk.yml +18 -7
- data/config/routes.rb +3 -0
- data/lib/pg_reports/configuration.rb +32 -2
- data/lib/pg_reports/connection/error_translator.rb +109 -0
- data/lib/pg_reports/connection/registry.rb +150 -0
- data/lib/pg_reports/connection/target.rb +111 -0
- data/lib/pg_reports/dashboard/reports_registry.rb +22 -8
- data/lib/pg_reports/executor.rb +20 -6
- data/lib/pg_reports/modules/system.rb +32 -5
- data/lib/pg_reports/query_monitor.rb +10 -7
- data/lib/pg_reports/standalone.rb +152 -0
- data/lib/pg_reports/version.rb +1 -1
- data/lib/pg_reports.rb +57 -0
- metadata +11 -13
data/bin/pg_reports
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Soft bundler shim: when run straight from the gem checkout (a Gemfile sits at
|
|
5
|
+
# the repo root, next to bin/), activate the bundle so the local `lib` and the
|
|
6
|
+
# dev-group web server are on the load path — letting `./bin/pg_reports` work
|
|
7
|
+
# without an explicit `bundle exec`. Installed as a gem there is no Gemfile
|
|
8
|
+
# alongside the executable, so this is skipped and RubyGems resolves everything.
|
|
9
|
+
gemfile = File.expand_path("../Gemfile", __dir__)
|
|
10
|
+
if File.exist?(gemfile) && !defined?(Bundler)
|
|
11
|
+
ENV["BUNDLE_GEMFILE"] ||= gemfile
|
|
12
|
+
begin
|
|
13
|
+
require "bundler/setup"
|
|
14
|
+
rescue LoadError
|
|
15
|
+
# Bundler unavailable — fall through and let RubyGems/$LOAD_PATH resolve.
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
require "optparse"
|
|
20
|
+
|
|
21
|
+
options = {
|
|
22
|
+
port: 4000,
|
|
23
|
+
host: "127.0.0.1",
|
|
24
|
+
mount: "/",
|
|
25
|
+
database_url: nil,
|
|
26
|
+
server: nil
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
banner = <<~BANNER
|
|
30
|
+
pg_reports — self-contained PostgreSQL insights dashboard
|
|
31
|
+
|
|
32
|
+
Usage:
|
|
33
|
+
pg_reports server [options]
|
|
34
|
+
|
|
35
|
+
Connection is resolved from --database-url, else DATABASE_URL, else the
|
|
36
|
+
standard libpq env vars (PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE).
|
|
37
|
+
|
|
38
|
+
Options:
|
|
39
|
+
BANNER
|
|
40
|
+
|
|
41
|
+
parser = OptionParser.new do |o|
|
|
42
|
+
o.banner = banner
|
|
43
|
+
o.on("-p", "--port PORT", Integer, "Port to listen on (default: 4000)") { |v| options[:port] = v }
|
|
44
|
+
o.on("-b", "--host HOST", "Host/interface to bind (default: 127.0.0.1)") { |v| options[:host] = v }
|
|
45
|
+
o.on("-m", "--mount PATH", "Path to mount the dashboard at (default: /)") { |v| options[:mount] = v }
|
|
46
|
+
o.on("-d", "--database-url URL", "PostgreSQL connection URL") { |v| options[:database_url] = v }
|
|
47
|
+
o.on("-s", "--server NAME", "Web server to use (e.g. puma, webrick)") { |v| options[:server] = v }
|
|
48
|
+
o.on("-h", "--help", "Show this help") do
|
|
49
|
+
puts o
|
|
50
|
+
exit
|
|
51
|
+
end
|
|
52
|
+
o.on("-v", "--version", "Show version") do
|
|
53
|
+
require "pg_reports/version"
|
|
54
|
+
puts PgReports::VERSION
|
|
55
|
+
exit
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# parse! permutes, so options are recognized before or after the subcommand
|
|
60
|
+
# (e.g. `pg_reports server --port 4055`).
|
|
61
|
+
parser.parse!(ARGV)
|
|
62
|
+
command = ARGV.shift || "server"
|
|
63
|
+
|
|
64
|
+
case command
|
|
65
|
+
when "server"
|
|
66
|
+
require "pg_reports"
|
|
67
|
+
begin
|
|
68
|
+
PgReports::Standalone.run(
|
|
69
|
+
port: options[:port],
|
|
70
|
+
host: options[:host],
|
|
71
|
+
mount_path: options[:mount],
|
|
72
|
+
database_url: options[:database_url],
|
|
73
|
+
server: options[:server]
|
|
74
|
+
)
|
|
75
|
+
rescue PgReports::Standalone::ServerUnavailable => e
|
|
76
|
+
warn e.message
|
|
77
|
+
exit 1
|
|
78
|
+
rescue Interrupt
|
|
79
|
+
warn "\npg_reports: stopped"
|
|
80
|
+
end
|
|
81
|
+
else
|
|
82
|
+
warn "Unknown command: #{command.inspect}"
|
|
83
|
+
warn parser.help
|
|
84
|
+
exit 1
|
|
85
|
+
end
|
data/config/locales/en.yml
CHANGED
|
@@ -592,6 +592,10 @@ en:
|
|
|
592
592
|
navigation:
|
|
593
593
|
dashboard: "Dashboard"
|
|
594
594
|
back: "← Back"
|
|
595
|
+
database_selector:
|
|
596
|
+
label: "Database"
|
|
597
|
+
target_selector:
|
|
598
|
+
label: "Target"
|
|
595
599
|
actions:
|
|
596
600
|
cancel: "Cancel"
|
|
597
601
|
retry: "Retry"
|
|
@@ -629,11 +633,12 @@ en:
|
|
|
629
633
|
save_for_comparison: "📌 Save for Comparison"
|
|
630
634
|
saved_marker: "📌 Saved"
|
|
631
635
|
status:
|
|
632
|
-
pg_stat_ready: "
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
monitoring_unavailable: "
|
|
636
|
+
pg_stat_ready: "Active"
|
|
637
|
+
not_preloaded: "Preload required"
|
|
638
|
+
extension_missing: "Extension required"
|
|
639
|
+
disconnected: "No connection"
|
|
640
|
+
monitoring_unavailable: "Status Unavailable"
|
|
641
|
+
click_for_details: "Click to see how to enable it"
|
|
637
642
|
modals:
|
|
638
643
|
enable_pg_stat_title: "Enable pg_stat_statements"
|
|
639
644
|
enable_pg_stat_intro: "To enable pg_stat_statements, follow these steps:"
|
|
@@ -641,6 +646,9 @@ en:
|
|
|
641
646
|
restart_postgresql: "Restart PostgreSQL:"
|
|
642
647
|
create_extension_step: "Create extension:"
|
|
643
648
|
enable_button_note: "Or click \"Enable\" button after restart."
|
|
649
|
+
create_extension_title: "Create pg_stat_statements extension"
|
|
650
|
+
create_extension_intro: "The pg_stat_statements extension isn't created in this database yet. Click below to create it:"
|
|
651
|
+
create_extension_note: "If the library isn't preloaded, you'll also need to add it to shared_preload_libraries and restart PostgreSQL."
|
|
644
652
|
reset_stats_title: "⚠️ Reset Statistics"
|
|
645
653
|
reset_stats_confirm: "Are you sure you want to reset pg_stat_statements statistics?"
|
|
646
654
|
reset_stats_warning: "This action will clear all collected query statistics and cannot be undone."
|
|
@@ -665,7 +673,7 @@ en:
|
|
|
665
673
|
ide_cursor_wsl: "Cursor (WSL)"
|
|
666
674
|
ide_cursor: "Cursor"
|
|
667
675
|
monitoring:
|
|
668
|
-
live_title: "
|
|
676
|
+
live_title: "Status"
|
|
669
677
|
update_interval: "Updates every 5s"
|
|
670
678
|
toggle_title: "Toggle live monitoring"
|
|
671
679
|
query_monitor_title: "SQL Query Monitor"
|
|
@@ -675,6 +683,8 @@ en:
|
|
|
675
683
|
feed_no_queries: "No queries captured yet..."
|
|
676
684
|
unknown_source: "Unknown source"
|
|
677
685
|
expand_collapse_title: "Expand/Collapse"
|
|
686
|
+
scope_note_short: "scope: host application"
|
|
687
|
+
scope_note_long: "Query Monitor subscribes to ActiveSupport::Notifications in the host application's process. It captures every SQL the host app runs, on whichever database the host app is connected to. The dashboard's database selector does not change this scope."
|
|
678
688
|
metrics:
|
|
679
689
|
connections_label: "Connections"
|
|
680
690
|
tps_label: "TPS"
|
|
@@ -685,7 +695,7 @@ en:
|
|
|
685
695
|
cache_hit_detail: "heap blocks from cache"
|
|
686
696
|
long_queries_label: "Long Queries"
|
|
687
697
|
queries_unit: "queries"
|
|
688
|
-
long_running_threshold: ">
|
|
698
|
+
long_running_threshold: "> 5s runtime"
|
|
689
699
|
blocked_label: "Blocked"
|
|
690
700
|
processes_unit: "processes"
|
|
691
701
|
waiting_for_locks: "waiting for locks"
|
|
@@ -693,6 +703,7 @@ en:
|
|
|
693
703
|
categories:
|
|
694
704
|
requires_pg_stat: "🔒 Requires pg_stat_statements"
|
|
695
705
|
reports_count_suffix: "reports"
|
|
706
|
+
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."
|
|
696
707
|
documentation:
|
|
697
708
|
toggle_title: "📖 What does this report show?"
|
|
698
709
|
what_section: "📋 What"
|
data/config/locales/ru.yml
CHANGED
|
@@ -557,6 +557,10 @@ ru:
|
|
|
557
557
|
navigation:
|
|
558
558
|
dashboard: "Дашборд"
|
|
559
559
|
back: "← Назад"
|
|
560
|
+
database_selector:
|
|
561
|
+
label: "База данных"
|
|
562
|
+
target_selector:
|
|
563
|
+
label: "Сервер"
|
|
560
564
|
actions:
|
|
561
565
|
cancel: "Отмена"
|
|
562
566
|
retry: "Повторить"
|
|
@@ -594,11 +598,12 @@ ru:
|
|
|
594
598
|
save_for_comparison: "📌 Сохранить для сравнения"
|
|
595
599
|
saved_marker: "📌 Сохранено"
|
|
596
600
|
status:
|
|
597
|
-
pg_stat_ready: "
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
monitoring_unavailable: "
|
|
601
|
+
pg_stat_ready: "Активно"
|
|
602
|
+
not_preloaded: "Требуется предзагрузка"
|
|
603
|
+
extension_missing: "Требуется расширение"
|
|
604
|
+
disconnected: "Нет соединения"
|
|
605
|
+
monitoring_unavailable: "Статус недоступен"
|
|
606
|
+
click_for_details: "Нажмите, чтобы узнать, как включить"
|
|
602
607
|
modals:
|
|
603
608
|
enable_pg_stat_title: "Включение pg_stat_statements"
|
|
604
609
|
enable_pg_stat_intro: "Чтобы включить pg_stat_statements, выполните следующие шаги:"
|
|
@@ -606,6 +611,9 @@ ru:
|
|
|
606
611
|
restart_postgresql: "Перезапустите PostgreSQL:"
|
|
607
612
|
create_extension_step: "Создайте расширение:"
|
|
608
613
|
enable_button_note: "Или нажмите кнопку «Создать расширение» после перезапуска."
|
|
614
|
+
create_extension_title: "Создание расширения pg_stat_statements"
|
|
615
|
+
create_extension_intro: "Расширение pg_stat_statements ещё не создано в этой базе данных. Нажмите, чтобы создать его:"
|
|
616
|
+
create_extension_note: "Если библиотека не предзагружена, её также нужно добавить в shared_preload_libraries и перезапустить PostgreSQL."
|
|
609
617
|
reset_stats_title: "⚠️ Сброс статистики"
|
|
610
618
|
reset_stats_confirm: "Вы уверены, что хотите сбросить статистику pg_stat_statements?"
|
|
611
619
|
reset_stats_warning: "Это действие очистит всю собранную статистику запросов и не может быть отменено."
|
|
@@ -630,7 +638,7 @@ ru:
|
|
|
630
638
|
ide_cursor_wsl: "Cursor (WSL)"
|
|
631
639
|
ide_cursor: "Cursor"
|
|
632
640
|
monitoring:
|
|
633
|
-
live_title: "
|
|
641
|
+
live_title: "Статус"
|
|
634
642
|
update_interval: "Обновление каждые 5с"
|
|
635
643
|
toggle_title: "Переключить live-мониторинг"
|
|
636
644
|
query_monitor_title: "Монитор SQL-запросов"
|
|
@@ -640,6 +648,8 @@ ru:
|
|
|
640
648
|
feed_no_queries: "Запросы пока не захвачены..."
|
|
641
649
|
unknown_source: "Источник неизвестен"
|
|
642
650
|
expand_collapse_title: "Развернуть / свернуть"
|
|
651
|
+
scope_note_short: "область: хост-приложение"
|
|
652
|
+
scope_note_long: "Монитор SQL-запросов подписан на ActiveSupport::Notifications в процессе хост-приложения и захватывает каждый SQL, который выполняет приложение, в той базе, к которой приложение подключено. Селектор базы данных в дашборде на эту область не влияет."
|
|
643
653
|
metrics:
|
|
644
654
|
connections_label: "Соединения"
|
|
645
655
|
tps_label: "TPS"
|
|
@@ -650,7 +660,7 @@ ru:
|
|
|
650
660
|
cache_hit_detail: "блоки heap из кэша"
|
|
651
661
|
long_queries_label: "Долгие запросы"
|
|
652
662
|
queries_unit: "запросов"
|
|
653
|
-
long_running_threshold: ">
|
|
663
|
+
long_running_threshold: "> 5с выполнения"
|
|
654
664
|
blocked_label: "Заблокировано"
|
|
655
665
|
processes_unit: "процессов"
|
|
656
666
|
waiting_for_locks: "ждут блокировок"
|
|
@@ -658,6 +668,7 @@ ru:
|
|
|
658
668
|
categories:
|
|
659
669
|
requires_pg_stat: "🔒 Требуется pg_stat_statements"
|
|
660
670
|
reports_count_suffix: "отчётов"
|
|
671
|
+
primary_only_reason: "🔒 Эта категория работает только на первичной базе — она проверяет модели хост-приложения. Чтобы пользоваться, переключитесь обратно на базу по умолчанию."
|
|
661
672
|
documentation:
|
|
662
673
|
toggle_title: "📖 Что показывает этот отчёт?"
|
|
663
674
|
what_section: "📋 Что"
|
data/config/locales/uk.yml
CHANGED
|
@@ -557,6 +557,10 @@ uk:
|
|
|
557
557
|
navigation:
|
|
558
558
|
dashboard: "Дашборд"
|
|
559
559
|
back: "← Назад"
|
|
560
|
+
database_selector:
|
|
561
|
+
label: "База даних"
|
|
562
|
+
target_selector:
|
|
563
|
+
label: "Сервер"
|
|
560
564
|
actions:
|
|
561
565
|
cancel: "Скасувати"
|
|
562
566
|
retry: "Повторити"
|
|
@@ -594,11 +598,12 @@ uk:
|
|
|
594
598
|
save_for_comparison: "📌 Зберегти для порівняння"
|
|
595
599
|
saved_marker: "📌 Збережено"
|
|
596
600
|
status:
|
|
597
|
-
pg_stat_ready: "
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
monitoring_unavailable: "
|
|
601
|
+
pg_stat_ready: "Активно"
|
|
602
|
+
not_preloaded: "Потрібне передзавантаження"
|
|
603
|
+
extension_missing: "Потрібне розширення"
|
|
604
|
+
disconnected: "Немає з'єднання"
|
|
605
|
+
monitoring_unavailable: "Статус недоступний"
|
|
606
|
+
click_for_details: "Натисніть, щоб дізнатися, як увімкнути"
|
|
602
607
|
modals:
|
|
603
608
|
enable_pg_stat_title: "Увімкнення pg_stat_statements"
|
|
604
609
|
enable_pg_stat_intro: "Щоб увімкнути pg_stat_statements, виконайте такі кроки:"
|
|
@@ -606,6 +611,9 @@ uk:
|
|
|
606
611
|
restart_postgresql: "Перезапустіть PostgreSQL:"
|
|
607
612
|
create_extension_step: "Створіть розширення:"
|
|
608
613
|
enable_button_note: "Або натисніть кнопку «Створити розширення» після перезапуску."
|
|
614
|
+
create_extension_title: "Створення розширення pg_stat_statements"
|
|
615
|
+
create_extension_intro: "Розширення pg_stat_statements ще не створено в цій базі даних. Натисніть, щоб створити його:"
|
|
616
|
+
create_extension_note: "Якщо бібліотеку не передзавантажено, її також потрібно додати до shared_preload_libraries та перезапустити PostgreSQL."
|
|
609
617
|
reset_stats_title: "⚠️ Скидання статистики"
|
|
610
618
|
reset_stats_confirm: "Ви впевнені, що хочете скинути статистику pg_stat_statements?"
|
|
611
619
|
reset_stats_warning: "Ця дія очистить усю зібрану статистику запитів і не може бути скасована."
|
|
@@ -630,7 +638,7 @@ uk:
|
|
|
630
638
|
ide_cursor_wsl: "Cursor (WSL)"
|
|
631
639
|
ide_cursor: "Cursor"
|
|
632
640
|
monitoring:
|
|
633
|
-
live_title: "
|
|
641
|
+
live_title: "Статус"
|
|
634
642
|
update_interval: "Оновлення кожні 5с"
|
|
635
643
|
toggle_title: "Перемкнути live-моніторинг"
|
|
636
644
|
query_monitor_title: "Монітор SQL-запитів"
|
|
@@ -640,6 +648,8 @@ uk:
|
|
|
640
648
|
feed_no_queries: "Запитів поки не захоплено..."
|
|
641
649
|
unknown_source: "Джерело невідоме"
|
|
642
650
|
expand_collapse_title: "Розгорнути / згорнути"
|
|
651
|
+
scope_note_short: "область: хост-застосунок"
|
|
652
|
+
scope_note_long: "Монітор SQL-запитів підписаний на ActiveSupport::Notifications у процесі хост-застосунку та захоплює кожен SQL, який виконує застосунок, у тій базі, до якої він підключений. Селектор бази даних у дашборді на цю область не впливає."
|
|
643
653
|
metrics:
|
|
644
654
|
connections_label: "З'єднання"
|
|
645
655
|
tps_label: "TPS"
|
|
@@ -650,7 +660,7 @@ uk:
|
|
|
650
660
|
cache_hit_detail: "блоки heap із кешу"
|
|
651
661
|
long_queries_label: "Довгі запити"
|
|
652
662
|
queries_unit: "запитів"
|
|
653
|
-
long_running_threshold: ">
|
|
663
|
+
long_running_threshold: "> 5с виконання"
|
|
654
664
|
blocked_label: "Заблоковано"
|
|
655
665
|
processes_unit: "процесів"
|
|
656
666
|
waiting_for_locks: "чекають блокувань"
|
|
@@ -658,6 +668,7 @@ uk:
|
|
|
658
668
|
categories:
|
|
659
669
|
requires_pg_stat: "🔒 Потрібен pg_stat_statements"
|
|
660
670
|
reports_count_suffix: "звітів"
|
|
671
|
+
primary_only_reason: "🔒 Ця категорія працює лише на первинній базі — вона перевіряє моделі хост-застосунку. Щоб користуватися, перемкніться на базу за замовчуванням."
|
|
661
672
|
documentation:
|
|
662
673
|
toggle_title: "📖 Що показує цей звіт?"
|
|
663
674
|
what_section: "📋 Що"
|
data/config/routes.rb
CHANGED
|
@@ -7,6 +7,9 @@ PgReports::Engine.routes.draw do
|
|
|
7
7
|
|
|
8
8
|
get "live_metrics", to: "dashboard#live_metrics", as: :live_metrics
|
|
9
9
|
|
|
10
|
+
post "switch_database", to: "dashboard#switch_database", as: :switch_database
|
|
11
|
+
post "switch_target", to: "dashboard#switch_target", as: :switch_target
|
|
12
|
+
|
|
10
13
|
post "enable_pg_stat_statements", to: "dashboard#enable_pg_stat_statements", as: :enable_pg_stat_statements
|
|
11
14
|
post "reset_statistics", to: "dashboard#reset_statistics", as: :reset_statistics
|
|
12
15
|
post "explain_analyze", to: "dashboard#explain_analyze", as: :explain_analyze
|
|
@@ -20,7 +20,7 @@ module PgReports
|
|
|
20
20
|
attr_accessor :dead_rows_threshold # Tables with more dead rows need vacuum
|
|
21
21
|
|
|
22
22
|
# Connection settings
|
|
23
|
-
attr_accessor :connection_pool # Custom connection pool (optional)
|
|
23
|
+
attr_accessor :connection_pool # Custom connection pool (optional, legacy override)
|
|
24
24
|
|
|
25
25
|
# Output settings
|
|
26
26
|
attr_accessor :max_query_length # Truncate query text to this length
|
|
@@ -41,6 +41,7 @@ module PgReports
|
|
|
41
41
|
|
|
42
42
|
# Security settings
|
|
43
43
|
attr_accessor :allow_raw_query_execution # Allow execute_query and explain_analyze from dashboard
|
|
44
|
+
attr_accessor :allow_migration_creation # Allow dashboard's "Generate Migration" button to write files into db/migrate/
|
|
44
45
|
|
|
45
46
|
# Grafana / Prometheus exporter settings
|
|
46
47
|
attr_accessor :grafana_favorites # Reports exposed at /metrics (Array of keys or Hash with per-report opts)
|
|
@@ -92,6 +93,14 @@ module PgReports
|
|
|
92
93
|
@allow_raw_query_execution = ActiveModel::Type::Boolean.new.cast(
|
|
93
94
|
ENV.fetch("PG_REPORTS_ALLOW_RAW_QUERY_EXECUTION", false)
|
|
94
95
|
)
|
|
96
|
+
# Migration creation defaults to Rails dev — preserves prior behavior —
|
|
97
|
+
# but is now explicitly togglable so it can be disabled even in dev.
|
|
98
|
+
env_override = ENV["PG_REPORTS_ALLOW_MIGRATION_CREATION"]
|
|
99
|
+
@allow_migration_creation = if env_override.nil?
|
|
100
|
+
defined?(Rails) && Rails.respond_to?(:env) && Rails.env.development?
|
|
101
|
+
else
|
|
102
|
+
ActiveModel::Type::Boolean.new.cast(env_override)
|
|
103
|
+
end
|
|
95
104
|
|
|
96
105
|
# Grafana / Prometheus exporter
|
|
97
106
|
@grafana_favorites = []
|
|
@@ -100,7 +109,28 @@ module PgReports
|
|
|
100
109
|
end
|
|
101
110
|
|
|
102
111
|
def connection
|
|
103
|
-
@connection_pool
|
|
112
|
+
return @connection_pool if @connection_pool
|
|
113
|
+
|
|
114
|
+
PgReports.connection_registry.current_connection
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Register an additional named target (besides the auto-detected :primary).
|
|
118
|
+
# Spec accepts the same keys as ActiveRecord::Base.establish_connection.
|
|
119
|
+
#
|
|
120
|
+
# Example:
|
|
121
|
+
# config.add_target :analytics,
|
|
122
|
+
# host: "...", user: "...", password: ENV["..."], database: "warehouse"
|
|
123
|
+
def add_target(name, spec)
|
|
124
|
+
PgReports.connection_registry.register(name, spec)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Override the default target name (default is :primary).
|
|
128
|
+
def default_target=(name)
|
|
129
|
+
PgReports.connection_registry.default_name = name
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def default_target
|
|
133
|
+
PgReports.connection_registry.default_name
|
|
104
134
|
end
|
|
105
135
|
|
|
106
136
|
def telegram_configured?
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgReports
|
|
4
|
+
module Connection
|
|
5
|
+
# Translates raw PG / ActiveRecord exceptions into human-readable messages
|
|
6
|
+
# with concrete remediation hints (typically a GRANT statement).
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# PgReports::Connection::ErrorTranslator.translate(error)
|
|
10
|
+
# # => { title: "...", detail: "...", hint: "GRANT ...", code: "42501" }
|
|
11
|
+
module ErrorTranslator
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Returns a Hash with :title, :detail, :hint, :code, :raw_message.
|
|
15
|
+
# The shape is suitable for rendering in the dashboard.
|
|
16
|
+
def translate(error)
|
|
17
|
+
sqlstate = sqlstate_for(error)
|
|
18
|
+
message = error.message.to_s
|
|
19
|
+
|
|
20
|
+
info = case sqlstate
|
|
21
|
+
when "42501" then permission_denied(message)
|
|
22
|
+
when "3D000" then database_does_not_exist(message)
|
|
23
|
+
when "28000", "28P01" then auth_failed(message)
|
|
24
|
+
when "08001", "08006", "08000", "08003", "08004" then connection_refused(message)
|
|
25
|
+
when "53300" then too_many_connections(message)
|
|
26
|
+
else generic(error)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
info.merge(code: sqlstate, raw_message: message)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def sqlstate_for(error)
|
|
33
|
+
case error
|
|
34
|
+
when PG::Error
|
|
35
|
+
error.result&.error_field(PG::Result::PG_DIAG_SQLSTATE)
|
|
36
|
+
when ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
|
|
37
|
+
sqlstate_for(error.cause) if error.cause && !error.cause.equal?(error)
|
|
38
|
+
end
|
|
39
|
+
rescue
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def permission_denied(message)
|
|
44
|
+
target = extract_object(message, /permission denied for (?:database|schema|table|relation|view) "?([\w.]+)"?/)
|
|
45
|
+
kind = extract_object(message, /permission denied for (database|schema|table|relation|view)/)
|
|
46
|
+
|
|
47
|
+
hint = if kind && target
|
|
48
|
+
case kind
|
|
49
|
+
when "database" then "GRANT CONNECT ON DATABASE #{target} TO <role>;"
|
|
50
|
+
when "schema" then "GRANT USAGE ON SCHEMA #{target} TO <role>;"
|
|
51
|
+
when "table", "relation", "view" then "GRANT SELECT ON #{target} TO <role>;"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
title: "Permission denied",
|
|
57
|
+
detail: (kind && target) ? "The connecting role does not have the required privilege on #{kind} \"#{target}\"." : "The connecting role lacks the required privilege.",
|
|
58
|
+
hint: hint
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def database_does_not_exist(message)
|
|
63
|
+
target = extract_object(message, /database "([^"]+)" does not exist/)
|
|
64
|
+
{
|
|
65
|
+
title: "Database not found",
|
|
66
|
+
detail: target ? "PostgreSQL has no database named \"#{target}\"." : "The requested database does not exist on this server.",
|
|
67
|
+
hint: nil
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def auth_failed(_message)
|
|
72
|
+
{
|
|
73
|
+
title: "Authentication failed",
|
|
74
|
+
detail: "PostgreSQL rejected the credentials for this target.",
|
|
75
|
+
hint: "Verify the username/password in the target configuration; check pg_hba.conf for the connecting host."
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def connection_refused(_message)
|
|
80
|
+
{
|
|
81
|
+
title: "Cannot reach PostgreSQL",
|
|
82
|
+
detail: "The server is unreachable or refused the connection.",
|
|
83
|
+
hint: "Check host/port, network reachability, and that PostgreSQL is accepting connections."
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def too_many_connections(_message)
|
|
88
|
+
{
|
|
89
|
+
title: "Too many connections",
|
|
90
|
+
detail: "PostgreSQL refused the connection because max_connections is reached.",
|
|
91
|
+
hint: "Wait, increase max_connections, or use a connection pooler (PgBouncer)."
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def generic(error)
|
|
96
|
+
{
|
|
97
|
+
title: error.class.name.split("::").last,
|
|
98
|
+
detail: error.message,
|
|
99
|
+
hint: nil
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def extract_object(message, regex)
|
|
104
|
+
match = message.match(regex)
|
|
105
|
+
match && match[1]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgReports
|
|
4
|
+
module Connection
|
|
5
|
+
# Registry of named PostgreSQL targets.
|
|
6
|
+
#
|
|
7
|
+
# In a Rails app the :primary target is auto-registered on first access from
|
|
8
|
+
# ActiveRecord::Base.connection_db_config — no configuration is required.
|
|
9
|
+
# Additional targets can be registered explicitly via Configuration#add_target
|
|
10
|
+
# for setups where the dashboard should reach databases the host app cannot.
|
|
11
|
+
#
|
|
12
|
+
# Current target/database is tracked in a Thread.current slot, switched via
|
|
13
|
+
# PgReports.with_target / PgReports.with_database for block-scoped usage.
|
|
14
|
+
class Registry
|
|
15
|
+
THREAD_KEY_TARGET = :pg_reports_current_target
|
|
16
|
+
THREAD_KEY_DATABASE = :pg_reports_current_database
|
|
17
|
+
|
|
18
|
+
class UnknownTarget < PgReports::Error; end
|
|
19
|
+
|
|
20
|
+
def initialize
|
|
21
|
+
@targets = {}
|
|
22
|
+
@default_name = :primary
|
|
23
|
+
@auto_registered = false
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
attr_reader :default_name
|
|
28
|
+
|
|
29
|
+
def default_name=(name)
|
|
30
|
+
@default_name = name.to_sym
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Register or overwrite a target.
|
|
34
|
+
def register(name, spec)
|
|
35
|
+
@targets[name.to_sym] = Target.new(name, spec)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# All known targets (auto-registers :primary if needed first).
|
|
39
|
+
def targets
|
|
40
|
+
ensure_default_registered!
|
|
41
|
+
@targets.values
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def target_names
|
|
45
|
+
targets.map(&:name)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def target?(name)
|
|
49
|
+
targets.any? { |t| t.name == name.to_sym }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def fetch(name = nil)
|
|
53
|
+
ensure_default_registered!
|
|
54
|
+
key = (name || current_name || @default_name).to_sym
|
|
55
|
+
@targets.fetch(key) { raise UnknownTarget, "Unknown target #{key.inspect}. Known: #{@targets.keys.inspect}" }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Resolve the AR connection to use right now (current target + database).
|
|
59
|
+
# Honors PgReports.with_target / with_database thread-local context.
|
|
60
|
+
def current_connection
|
|
61
|
+
target = fetch(current_name)
|
|
62
|
+
target.connection_for(current_database)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Returns the target that current_connection would resolve to.
|
|
66
|
+
def current_target
|
|
67
|
+
fetch(current_name)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The database name in effect for the current target.
|
|
71
|
+
def current_database_name
|
|
72
|
+
current_database || fetch(current_name).default_database
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def current_name
|
|
76
|
+
Thread.current[THREAD_KEY_TARGET]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def current_database
|
|
80
|
+
Thread.current[THREAD_KEY_DATABASE]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Switch target and/or database for the duration of the block. Semantics:
|
|
84
|
+
# - target given → switches target AND clears the database override
|
|
85
|
+
# (the previous database belongs to the previous
|
|
86
|
+
# target's cluster and would not be valid on a new
|
|
87
|
+
# one). Pass `database:` explicitly to override on
|
|
88
|
+
# the new target.
|
|
89
|
+
# - target nil → keeps the active target, switches only database.
|
|
90
|
+
# - database nil → uses the (possibly new) target's default database.
|
|
91
|
+
def with_context(target: nil, database: nil)
|
|
92
|
+
prev_target = Thread.current[THREAD_KEY_TARGET]
|
|
93
|
+
prev_database = Thread.current[THREAD_KEY_DATABASE]
|
|
94
|
+
|
|
95
|
+
if target
|
|
96
|
+
Thread.current[THREAD_KEY_TARGET] = target.to_sym
|
|
97
|
+
Thread.current[THREAD_KEY_DATABASE] = database&.to_s
|
|
98
|
+
elsif database
|
|
99
|
+
Thread.current[THREAD_KEY_DATABASE] = database.to_s
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
yield
|
|
103
|
+
ensure
|
|
104
|
+
Thread.current[THREAD_KEY_TARGET] = prev_target
|
|
105
|
+
Thread.current[THREAD_KEY_DATABASE] = prev_database
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# For tests / reload scenarios. Restores the registry to a fresh state —
|
|
109
|
+
# closes all derived pools, drops all registered targets, resets
|
|
110
|
+
# default_name back to :primary, and re-arms auto-registration.
|
|
111
|
+
def reset!
|
|
112
|
+
@mutex.synchronize do
|
|
113
|
+
@targets.each_value(&:disconnect!)
|
|
114
|
+
@targets.clear
|
|
115
|
+
@default_name = :primary
|
|
116
|
+
@auto_registered = false
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Auto-discover the :primary target from ActiveRecord on first need.
|
|
121
|
+
# Idempotent and thread-safe.
|
|
122
|
+
def ensure_default_registered!
|
|
123
|
+
return if @auto_registered
|
|
124
|
+
return unless defined?(ActiveRecord::Base)
|
|
125
|
+
|
|
126
|
+
@mutex.synchronize do
|
|
127
|
+
return if @auto_registered
|
|
128
|
+
|
|
129
|
+
unless @targets.key?(:primary)
|
|
130
|
+
spec = primary_spec_from_active_record
|
|
131
|
+
@targets[:primary] = Target.new(:primary, spec) if spec
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
@auto_registered = true
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def primary_spec_from_active_record
|
|
141
|
+
config = ActiveRecord::Base.connection_db_config
|
|
142
|
+
return nil unless config
|
|
143
|
+
|
|
144
|
+
config.configuration_hash.transform_keys(&:to_sym)
|
|
145
|
+
rescue ActiveRecord::ConnectionNotEstablished
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|