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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +71 -86
- data/app/controllers/pg_reports/dashboard_controller.rb +142 -2
- data/app/controllers/pg_reports/metrics_controller.rb +27 -0
- data/app/views/layouts/pg_reports/application.html.erb +125 -0
- 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 +43 -29
- data/app/views/pg_reports/dashboard/show.html.erb +4 -0
- data/config/locales/en.yml +9 -2
- data/config/locales/ru.yml +9 -2
- data/config/locales/uk.yml +9 -2
- data/config/routes.rb +5 -0
- data/lib/pg_reports/configuration.rb +42 -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 +14 -5
- data/lib/pg_reports/grafana/dashboard_builder.rb +277 -0
- data/lib/pg_reports/grafana/exporter.rb +240 -0
- data/lib/pg_reports/module_generator.rb +29 -28
- data/lib/pg_reports/modules/schema_analysis.rb +1 -1
- data/lib/pg_reports/modules/system.rb +4 -1
- data/lib/pg_reports/query_monitor.rb +10 -7
- data/lib/pg_reports/version.rb +1 -1
- data/lib/pg_reports.rb +57 -0
- data/lib/tasks/pg_reports.rake +36 -0
- metadata +10 -1
|
@@ -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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
301
|
-
|
|
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
|
|
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
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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">
|
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"
|
|
@@ -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: "
|
|
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: "
|
|
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"
|
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: "Повторить"
|
|
@@ -598,7 +602,7 @@ ru:
|
|
|
598
602
|
extension_installed: "Расширение установлено, не предзагружено"
|
|
599
603
|
preloaded: "Предзагружено, расширение не создано"
|
|
600
604
|
not_configured: "Не настроено"
|
|
601
|
-
monitoring_unavailable: "
|
|
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: "
|
|
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: "📋 Что"
|
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: "Повторити"
|
|
@@ -598,7 +602,7 @@ uk:
|
|
|
598
602
|
extension_installed: "Розширення встановлено, не передзавантажено"
|
|
599
603
|
preloaded: "Передзавантажено, розширення не створено"
|
|
600
604
|
not_configured: "Не налаштовано"
|
|
601
|
-
monitoring_unavailable: "
|
|
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: "
|
|
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
|
|
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
|