pg_reports 0.6.2 → 0.8.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.
@@ -0,0 +1,27 @@
1
+ <%# locals: (show_label: true) %>
2
+ <% if @available_targets.present? && @available_targets.size > 1 %>
3
+ <form class="db-selector-form"
4
+ action="<%= switch_target_path %>"
5
+ method="post"
6
+ data-turbo="false">
7
+ <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
8
+ <% if local_assigns.fetch(:show_label, true) %>
9
+ <label class="db-selector-label" for="pg-reports-target-select">
10
+ <%= t("pg_reports.ui.target_selector.label", default: "Target") %>
11
+ </label>
12
+ <% end %>
13
+ <select id="pg-reports-target-select"
14
+ name="target"
15
+ class="db-selector"
16
+ onchange="this.form.submit()">
17
+ <% default_target_name = PgReports.connection_registry.default_name %>
18
+ <% @available_targets.each do |target| %>
19
+ <option value="<%= target.name %>"
20
+ class="<%= "is-default" if target.name == default_target_name %>"
21
+ <%= "selected" if target.name == @selected_target %>>
22
+ <%= target.name %><%= " (#{target.default_database})" if target.default_database.present? %>
23
+ </option>
24
+ <% end %>
25
+ </select>
26
+ </form>
27
+ <% end %>
@@ -1,5 +1,15 @@
1
1
  <%= csrf_meta_tags %>
2
2
 
3
+ <% if @database_error %>
4
+ <div class="db-error-banner">
5
+ <strong><%= @database_error[:title] %></strong>
6
+ <span><%= @database_error[:detail] %></span>
7
+ <% if @database_error[:hint] %>
8
+ <code><%= @database_error[:hint] %></code>
9
+ <% end %>
10
+ </div>
11
+ <% end %>
12
+
3
13
  <header class="header">
4
14
  <div class="logo">
5
15
  <div class="logo-icon">🐘</div>
@@ -138,9 +148,8 @@ pg_stat_statements.track = all</pre>
138
148
  <div class="live-monitoring-title">
139
149
  <span class="live-indicator"></span>
140
150
  <span><%= t("pg_reports.ui.monitoring.live_title") %></span>
141
- <span class="database-badge">
142
- <strong><%= @current_database %></strong>
143
- </span>
151
+ <%= render "target_selector", show_label: false %>
152
+ <%= render "database_selector", show_label: false %>
144
153
  </div>
145
154
  <div class="live-monitoring-controls">
146
155
  <span class="live-monitoring-interval"><%= t("pg_reports.ui.monitoring.update_interval") %></span>
@@ -258,6 +267,9 @@ pg_stat_statements.track = all</pre>
258
267
  <div class="query-monitoring-title">
259
268
  <span class="monitoring-indicator" id="monitor-indicator"></span>
260
269
  <span><%= t("pg_reports.ui.monitoring.query_monitor_title") %></span>
270
+ <span class="query-monitoring-scope" title="<%= t("pg_reports.ui.monitoring.scope_note_long", default: "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.") %>">
271
+ <%= t("pg_reports.ui.monitoring.scope_note_short", default: "scope: host application") %>
272
+ </span>
261
273
  <span class="session-badge" id="session-badge" style="display: none;">
262
274
  <%= t("pg_reports.ui.monitoring.session_label") %> <strong id="session-id"></strong>
263
275
  </span>
@@ -297,11 +309,20 @@ pg_stat_statements.track = all</pre>
297
309
 
298
310
  <div class="categories-grid">
299
311
  <% @categories.each do |category_key, category| %>
300
- <div class="category-card<%= ' disabled' if category_key == :queries && !@pg_stat_status[:ready] %>">
301
- <% if category_key == :queries && !@pg_stat_status[:ready] %>
312
+ <%
313
+ requires_pg_stat = (category_key == :queries && !@pg_stat_status[:ready])
314
+ target_disabled_reason = category_disabled_reason(category_key)
315
+ is_disabled = requires_pg_stat || target_disabled_reason.present?
316
+ %>
317
+ <div class="category-card<%= ' disabled' if is_disabled %>">
318
+ <% if requires_pg_stat %>
302
319
  <div class="category-warning">
303
320
  <%= t("pg_reports.ui.categories.requires_pg_stat") %>
304
321
  </div>
322
+ <% elsif target_disabled_reason %>
323
+ <div class="category-warning">
324
+ <%= target_disabled_reason %>
325
+ </div>
305
326
  <% end %>
306
327
  <div class="category-header">
307
328
  <div class="category-icon" style="background: <%= category[:color] %>20; color: <%= category[:color] %>;">
@@ -313,7 +334,7 @@ pg_stat_statements.track = all</pre>
313
334
 
314
335
  <div class="reports-list">
315
336
  <% category[:reports].each do |report_key, report| %>
316
- <% if category_key == :queries && !@pg_stat_status[:ready] %>
337
+ <% if is_disabled %>
317
338
  <div class="report-link disabled">
318
339
  <div class="report-link-info">
319
340
  <span class="report-link-name">
@@ -613,22 +634,6 @@ pg_stat_statements.track = all</pre>
613
634
  font-size: 1rem;
614
635
  }
615
636
 
616
- .database-badge {
617
- margin-left: 0.5rem;
618
- padding: 0.375rem 0.75rem;
619
- background: var(--bg-tertiary);
620
- border: 1px solid var(--border-color);
621
- border-radius: 8px;
622
- font-size: 0.75rem;
623
- font-weight: 500;
624
- color: var(--text-secondary);
625
- }
626
-
627
- .database-badge strong {
628
- color: var(--text-primary);
629
- font-weight: 600;
630
- }
631
-
632
637
  .live-indicator {
633
638
  width: 8px;
634
639
  height: 8px;
@@ -990,13 +995,12 @@ pg_stat_statements.track = all</pre>
990
995
  if (!panel) return;
991
996
 
992
997
  panel.style.display = 'block';
993
- panel.innerHTML = `
994
- <div class="live-monitoring-header">
995
- <div class="live-monitoring-title">
996
- <span class="badge-dot error"></span>
997
- <span>${PG_REPORTS_I18N.status.monitoring_unavailable}</span>
998
- </div>
999
- </div>
998
+
999
+ // Replace only the metrics grid; preserve the header (which holds the
1000
+ // database selector) so the user can still switch databases when metrics
1001
+ // are unavailable on the current one.
1002
+ const grid = panel.querySelector('.live-metrics-grid');
1003
+ const fallbackHTML = `
1000
1004
  <div style="padding: 2rem; text-align: center; color: var(--text-muted);">
1001
1005
  <p style="margin-bottom: 1rem;">${PG_REPORTS_I18N.errors.unable_fetch_metrics}</p>
1002
1006
  <p style="font-size: 0.875rem;">${PG_REPORTS_I18N.errors.possible_causes}</p>
@@ -1010,6 +1014,16 @@ pg_stat_statements.track = all</pre>
1010
1014
  </button>
1011
1015
  </div>
1012
1016
  `;
1017
+
1018
+ if (grid) {
1019
+ grid.outerHTML = fallbackHTML;
1020
+ } else {
1021
+ panel.insertAdjacentHTML('beforeend', fallbackHTML);
1022
+ }
1023
+
1024
+ // Surface the unavailable status next to the live indicator.
1025
+ const indicator = panel.querySelector('.live-indicator');
1026
+ if (indicator) indicator.classList.add('error');
1013
1027
  }
1014
1028
 
1015
1029
  function startPolling() {
@@ -7,6 +7,10 @@
7
7
  <span><%= @categories[@category][:name] %></span>
8
8
  <span>/</span>
9
9
  <span><%= @report_info[:name] %></span>
10
+
11
+ <span class="breadcrumb-spacer"></span>
12
+ <%= render "target_selector" %>
13
+ <%= render "database_selector" %>
10
14
  </nav>
11
15
 
12
16
  <div class="report-header">
@@ -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"
@@ -633,7 +637,7 @@ en:
633
637
  extension_installed: "Extension installed, not preloaded"
634
638
  preloaded: "Preloaded, extension not created"
635
639
  not_configured: "Not configured"
636
- monitoring_unavailable: "Live Monitoring Unavailable"
640
+ monitoring_unavailable: "Status Unavailable"
637
641
  modals:
638
642
  enable_pg_stat_title: "Enable pg_stat_statements"
639
643
  enable_pg_stat_intro: "To enable pg_stat_statements, follow these steps:"
@@ -665,7 +669,7 @@ en:
665
669
  ide_cursor_wsl: "Cursor (WSL)"
666
670
  ide_cursor: "Cursor"
667
671
  monitoring:
668
- live_title: "Live Monitoring"
672
+ live_title: "Status"
669
673
  update_interval: "Updates every 5s"
670
674
  toggle_title: "Toggle live monitoring"
671
675
  query_monitor_title: "SQL Query Monitor"
@@ -675,6 +679,8 @@ en:
675
679
  feed_no_queries: "No queries captured yet..."
676
680
  unknown_source: "Unknown source"
677
681
  expand_collapse_title: "Expand/Collapse"
682
+ scope_note_short: "scope: host application"
683
+ 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
684
  metrics:
679
685
  connections_label: "Connections"
680
686
  tps_label: "TPS"
@@ -693,6 +699,7 @@ en:
693
699
  categories:
694
700
  requires_pg_stat: "🔒 Requires pg_stat_statements"
695
701
  reports_count_suffix: "reports"
702
+ 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
703
  documentation:
697
704
  toggle_title: "📖 What does this report show?"
698
705
  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: "Повторить"
@@ -598,7 +602,7 @@ ru:
598
602
  extension_installed: "Расширение установлено, не предзагружено"
599
603
  preloaded: "Предзагружено, расширение не создано"
600
604
  not_configured: "Не настроено"
601
- monitoring_unavailable: "Live-мониторинг недоступен"
605
+ monitoring_unavailable: "Статус недоступен"
602
606
  modals:
603
607
  enable_pg_stat_title: "Включение pg_stat_statements"
604
608
  enable_pg_stat_intro: "Чтобы включить pg_stat_statements, выполните следующие шаги:"
@@ -630,7 +634,7 @@ ru:
630
634
  ide_cursor_wsl: "Cursor (WSL)"
631
635
  ide_cursor: "Cursor"
632
636
  monitoring:
633
- live_title: "Live-мониторинг"
637
+ live_title: "Статус"
634
638
  update_interval: "Обновление каждые 5с"
635
639
  toggle_title: "Переключить live-мониторинг"
636
640
  query_monitor_title: "Монитор SQL-запросов"
@@ -640,6 +644,8 @@ ru:
640
644
  feed_no_queries: "Запросы пока не захвачены..."
641
645
  unknown_source: "Источник неизвестен"
642
646
  expand_collapse_title: "Развернуть / свернуть"
647
+ scope_note_short: "область: хост-приложение"
648
+ scope_note_long: "Монитор SQL-запросов подписан на ActiveSupport::Notifications в процессе хост-приложения и захватывает каждый SQL, который выполняет приложение, в той базе, к которой приложение подключено. Селектор базы данных в дашборде на эту область не влияет."
643
649
  metrics:
644
650
  connections_label: "Соединения"
645
651
  tps_label: "TPS"
@@ -658,6 +664,7 @@ ru:
658
664
  categories:
659
665
  requires_pg_stat: "🔒 Требуется pg_stat_statements"
660
666
  reports_count_suffix: "отчётов"
667
+ primary_only_reason: "🔒 Эта категория работает только на первичной базе — она проверяет модели хост-приложения. Чтобы пользоваться, переключитесь обратно на базу по умолчанию."
661
668
  documentation:
662
669
  toggle_title: "📖 Что показывает этот отчёт?"
663
670
  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: "Повторити"
@@ -598,7 +602,7 @@ uk:
598
602
  extension_installed: "Розширення встановлено, не передзавантажено"
599
603
  preloaded: "Передзавантажено, розширення не створено"
600
604
  not_configured: "Не налаштовано"
601
- monitoring_unavailable: "Live-моніторинг недоступний"
605
+ monitoring_unavailable: "Статус недоступний"
602
606
  modals:
603
607
  enable_pg_stat_title: "Увімкнення pg_stat_statements"
604
608
  enable_pg_stat_intro: "Щоб увімкнути pg_stat_statements, виконайте такі кроки:"
@@ -630,7 +634,7 @@ uk:
630
634
  ide_cursor_wsl: "Cursor (WSL)"
631
635
  ide_cursor: "Cursor"
632
636
  monitoring:
633
- live_title: "Live-моніторинг"
637
+ live_title: "Статус"
634
638
  update_interval: "Оновлення кожні 5с"
635
639
  toggle_title: "Перемкнути live-моніторинг"
636
640
  query_monitor_title: "Монітор SQL-запитів"
@@ -640,6 +644,8 @@ uk:
640
644
  feed_no_queries: "Запитів поки не захоплено..."
641
645
  unknown_source: "Джерело невідоме"
642
646
  expand_collapse_title: "Розгорнути / згорнути"
647
+ scope_note_short: "область: хост-застосунок"
648
+ scope_note_long: "Монітор SQL-запитів підписаний на ActiveSupport::Notifications у процесі хост-застосунку та захоплює кожен SQL, який виконує застосунок, у тій базі, до якої він підключений. Селектор бази даних у дашборді на цю область не впливає."
643
649
  metrics:
644
650
  connections_label: "З'єднання"
645
651
  tps_label: "TPS"
@@ -658,6 +664,7 @@ uk:
658
664
  categories:
659
665
  requires_pg_stat: "🔒 Потрібен pg_stat_statements"
660
666
  reports_count_suffix: "звітів"
667
+ primary_only_reason: "🔒 Ця категорія працює лише на первинній базі — вона перевіряє моделі хост-застосунку. Щоб користуватися, перемкніться на базу за замовчуванням."
661
668
  documentation:
662
669
  toggle_title: "📖 Що показує цей звіт?"
663
670
  what_section: "📋 Що"
data/config/routes.rb CHANGED
@@ -3,8 +3,13 @@
3
3
  PgReports::Engine.routes.draw do
4
4
  root to: "dashboard#index"
5
5
 
6
+ get "metrics", to: "metrics#show", as: :metrics
7
+
6
8
  get "live_metrics", to: "dashboard#live_metrics", as: :live_metrics
7
9
 
10
+ post "switch_database", to: "dashboard#switch_database", as: :switch_database
11
+ post "switch_target", to: "dashboard#switch_target", as: :switch_target
12
+
8
13
  post "enable_pg_stat_statements", to: "dashboard#enable_pg_stat_statements", as: :enable_pg_stat_statements
9
14
  post "reset_statistics", to: "dashboard#reset_statistics", as: :reset_statistics
10
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,12 @@ 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/
45
+
46
+ # Grafana / Prometheus exporter settings
47
+ attr_accessor :grafana_favorites # Reports exposed at /metrics (Array of keys or Hash with per-report opts)
48
+ attr_accessor :grafana_metrics_token # Bearer token required to access /metrics (nil = no auth)
49
+ attr_accessor :grafana_cache_ttl # Default cache TTL for collected reports, seconds
44
50
 
45
51
  def initialize
46
52
  # Telegram
@@ -87,10 +93,44 @@ module PgReports
87
93
  @allow_raw_query_execution = ActiveModel::Type::Boolean.new.cast(
88
94
  ENV.fetch("PG_REPORTS_ALLOW_RAW_QUERY_EXECUTION", false)
89
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
104
+
105
+ # Grafana / Prometheus exporter
106
+ @grafana_favorites = []
107
+ @grafana_metrics_token = ENV.fetch("PG_REPORTS_METRICS_TOKEN", nil)
108
+ @grafana_cache_ttl = 60
90
109
  end
91
110
 
92
111
  def connection
93
- @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
94
134
  end
95
135
 
96
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