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.
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
@@ -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: "pg_stat_statements ready"
633
- extension_installed: "Extension installed, not preloaded"
634
- preloaded: "Preloaded, extension not created"
635
- not_configured: "Not configured"
636
- monitoring_unavailable: "Live 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: "Live Monitoring"
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: "> 60s runtime"
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"
@@ -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: "pg_stat_statements готов"
598
- extension_installed: "Расширение установлено, не предзагружено"
599
- preloaded: "Предзагружено, расширение не создано"
600
- not_configured: "Не настроено"
601
- monitoring_unavailable: "Live-мониторинг недоступен"
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: "Live-мониторинг"
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: "> 60с выполнения"
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: "📋 Что"
@@ -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: "pg_stat_statements готовий"
598
- extension_installed: "Розширення встановлено, не передзавантажено"
599
- preloaded: "Передзавантажено, розширення не створено"
600
- not_configured: "Не налаштовано"
601
- monitoring_unavailable: "Live-моніторинг недоступний"
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: "Live-моніторинг"
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: "> 60с виконання"
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 || ActiveRecord::Base.connection
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