pg_reports 0.8.1 → 0.9.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 +50 -0
- data/README.md +23 -2
- data/app/controllers/pg_reports/dashboard_controller.rb +241 -24
- data/app/views/layouts/pg_reports/application.html.erb +616 -201
- data/app/views/pg_reports/dashboard/_database_selector.html.erb +2 -1
- data/app/views/pg_reports/dashboard/_show_modals.html.erb +10 -7
- data/app/views/pg_reports/dashboard/_show_scripts.html.erb +100 -51
- data/app/views/pg_reports/dashboard/_show_styles.html.erb +392 -476
- data/app/views/pg_reports/dashboard/index.html.erb +426 -335
- data/app/views/pg_reports/dashboard/show.html.erb +13 -11
- data/bin/pg_reports +28 -2
- data/config/brakeman.ignore +97 -0
- data/config/locales/en.yml +62 -47
- data/config/locales/ru.yml +62 -47
- data/config/locales/uk.yml +62 -47
- data/config/routes.rb +1 -0
- data/lib/pg_reports/configuration.rb +8 -0
- data/lib/pg_reports/connection/error_translator.rb +43 -0
- data/lib/pg_reports/dashboard/reports_registry.rb +10 -0
- data/lib/pg_reports/explain_analyzer.rb +0 -3
- data/lib/pg_reports/grafana/exporter.rb +5 -1
- data/lib/pg_reports/query_monitor.rb +15 -3
- data/lib/pg_reports/standalone.rb +59 -1
- data/lib/pg_reports/version.rb +1 -1
- data/lib/pg_reports.rb +5 -0
- metadata +2 -1
data/config/locales/uk.yml
CHANGED
|
@@ -556,7 +556,7 @@ uk:
|
|
|
556
556
|
page_title: "PgReports — Дашборд"
|
|
557
557
|
navigation:
|
|
558
558
|
dashboard: "Дашборд"
|
|
559
|
-
back: "
|
|
559
|
+
back: "Назад"
|
|
560
560
|
database_selector:
|
|
561
561
|
label: "База даних"
|
|
562
562
|
target_selector:
|
|
@@ -564,39 +564,41 @@ uk:
|
|
|
564
564
|
actions:
|
|
565
565
|
cancel: "Скасувати"
|
|
566
566
|
retry: "Повторити"
|
|
567
|
-
copy: "
|
|
568
|
-
copy_query: "
|
|
569
|
-
copy_code: "
|
|
567
|
+
copy: "Копіювати"
|
|
568
|
+
copy_query: "Копіювати запит"
|
|
569
|
+
copy_code: "Копіювати код"
|
|
570
570
|
copy_to_clipboard_title: "Скопіювати в буфер обміну"
|
|
571
|
-
copied_feedback: "
|
|
571
|
+
copied_feedback: "Скопійовано!"
|
|
572
572
|
clear_all: "Очистити все"
|
|
573
|
-
run_report: "
|
|
574
|
-
export: "
|
|
575
|
-
download_text: "
|
|
576
|
-
download_csv: "
|
|
577
|
-
download_json: "
|
|
578
|
-
download: "
|
|
573
|
+
run_report: "Запустити звіт"
|
|
574
|
+
export: "Експорт"
|
|
575
|
+
download_text: "Текст (.txt)"
|
|
576
|
+
download_csv: "CSV (.csv)"
|
|
577
|
+
download_json: "JSON (.json)"
|
|
578
|
+
download: "Завантажити"
|
|
579
579
|
copy_ai_prompt: "Копіювати промпт"
|
|
580
|
-
send_telegram: "
|
|
580
|
+
send_telegram: "Telegram"
|
|
581
581
|
sending: "Надсилання..."
|
|
582
|
-
reset_statistics: "
|
|
582
|
+
reset_statistics: "Скинути статистику"
|
|
583
583
|
resetting: "Скидання..."
|
|
584
584
|
confirm_reset: "Так, скинути"
|
|
585
|
-
create_extension: "
|
|
585
|
+
create_extension: "Створити розширення"
|
|
586
586
|
creating: "Створення..."
|
|
587
587
|
ide_settings_button_title: "Налаштування IDE"
|
|
588
|
-
explain_analyze: "
|
|
589
|
-
execute_query: "
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
588
|
+
explain_analyze: "EXPLAIN ANALYZE"
|
|
589
|
+
execute_query: "Виконати запит"
|
|
590
|
+
run_query: "SQL-консоль"
|
|
591
|
+
create_migration_file: "Створити файл і відкрити в IDE"
|
|
592
|
+
start_monitoring: "Запустити моніторинг"
|
|
593
|
+
stop_monitoring: "Зупинити моніторинг"
|
|
593
594
|
starting: "Запуск..."
|
|
594
595
|
stopping: "Зупинка..."
|
|
595
|
-
load_history: "
|
|
596
|
+
load_history: "Завантажити історію"
|
|
597
|
+
load_history_unavailable_title: "Збереженої історії немає — задайте config.query_monitor_log_file, щоб запити зберігалися між перезапусками"
|
|
596
598
|
loading: "Завантаження..."
|
|
597
599
|
running: "Виконання..."
|
|
598
|
-
save_for_comparison: "
|
|
599
|
-
saved_marker: "
|
|
600
|
+
save_for_comparison: "Зберегти для порівняння"
|
|
601
|
+
saved_marker: "Збережено"
|
|
600
602
|
status:
|
|
601
603
|
pg_stat_ready: "Активно"
|
|
602
604
|
not_preloaded: "Потрібне передзавантаження"
|
|
@@ -614,20 +616,24 @@ uk:
|
|
|
614
616
|
create_extension_title: "Створення розширення pg_stat_statements"
|
|
615
617
|
create_extension_intro: "Розширення pg_stat_statements ще не створено в цій базі даних. Натисніть, щоб створити його:"
|
|
616
618
|
create_extension_note: "Якщо бібліотеку не передзавантажено, її також потрібно додати до shared_preload_libraries та перезапустити PostgreSQL."
|
|
617
|
-
reset_stats_title: "
|
|
619
|
+
reset_stats_title: "Скидання статистики"
|
|
618
620
|
reset_stats_confirm: "Ви впевнені, що хочете скинути статистику pg_stat_statements?"
|
|
619
621
|
reset_stats_warning: "Ця дія очистить усю зібрану статистику запитів і не може бути скасована."
|
|
620
|
-
ide_settings_title: "
|
|
621
|
-
problem_detected_title: "
|
|
622
|
-
query_analyzer_title: "
|
|
622
|
+
ide_settings_title: "Налаштування IDE"
|
|
623
|
+
problem_detected_title: "Виявлено проблему"
|
|
624
|
+
query_analyzer_title: "Аналізатор запиту"
|
|
623
625
|
query_label: "Запит:"
|
|
624
626
|
parameters_label: "Параметри:"
|
|
625
|
-
migration_title: "
|
|
627
|
+
migration_title: "Міграція видалення індексу"
|
|
626
628
|
migration_subtitle: "Згенерована міграція для видалення індексу:"
|
|
627
629
|
migration_warning: "Створення міграції згенерує файл міграції у вашому проєкті. Запуск цієї міграції видалить індекс із БД, що може значно вплинути на продуктивність застосунку."
|
|
628
630
|
migration_warning_dev_only: "Цю операцію слід виконувати лише в локальному dev-середовищі."
|
|
629
|
-
query_execution_disabled_title: "
|
|
631
|
+
query_execution_disabled_title: "Виконання запитів вимкнено"
|
|
632
|
+
migration_disabled_title: "Створення міграцій вимкнено"
|
|
630
633
|
query_execution_disabled_intro: "Щоб увімкнути цю функцію, додайте до конфігурації:"
|
|
634
|
+
query_execution_disabled_env_note: "...або замість цього встановіть змінну середовища:"
|
|
635
|
+
run_query_title: "SQL-консоль"
|
|
636
|
+
run_query_placeholder: "SELECT * FROM users LIMIT 10;"
|
|
631
637
|
settings:
|
|
632
638
|
default_ide_label: "IDE за замовчуванням для посилань на джерела:"
|
|
633
639
|
ide_show_menu: "Показувати меню (за замовчуванням)"
|
|
@@ -666,24 +672,27 @@ uk:
|
|
|
666
672
|
waiting_for_locks: "чекають блокувань"
|
|
667
673
|
percent_used_suffix: "% використано"
|
|
668
674
|
categories:
|
|
669
|
-
requires_pg_stat: "
|
|
675
|
+
requires_pg_stat: "Потрібен pg_stat_statements"
|
|
676
|
+
pg_stat_missing_reason: "Потрібен pg_stat_statements, але розширення не встановлено в базі «%{database}». Створіть його там або перемкніться на базу, де воно є."
|
|
677
|
+
pg_stat_not_preloaded_reason: "Потрібен pg_stat_statements. Розширення є в базі «%{database}», але відсутнє в shared_preload_libraries — даних не буде, доки PostgreSQL не перезапустять із ним."
|
|
670
678
|
reports_count_suffix: "звітів"
|
|
671
|
-
primary_only_reason: "
|
|
679
|
+
primary_only_reason: "Ця категорія працює лише на первинній базі — вона перевіряє моделі хост-застосунку. Щоб користуватися, перемкніться на базу за замовчуванням."
|
|
680
|
+
standalone_no_host_app_reason: "Недоступно в автономному режимі — ця категорія перевіряє моделі хост-застосунку, а при самостійному запуску дашборда хост-застосунку немає."
|
|
672
681
|
documentation:
|
|
673
|
-
toggle_title: "
|
|
674
|
-
what_section: "
|
|
675
|
-
why_section: "
|
|
676
|
-
nuances_section: "
|
|
677
|
-
thresholds_section: "
|
|
678
|
-
threshold_warning_label: "
|
|
679
|
-
threshold_critical_label: "
|
|
682
|
+
toggle_title: "Що показує цей звіт?"
|
|
683
|
+
what_section: "Що"
|
|
684
|
+
why_section: "Чому це важливо"
|
|
685
|
+
nuances_section: "Нюанси"
|
|
686
|
+
thresholds_section: "Пороги"
|
|
687
|
+
threshold_warning_label: "Warning:"
|
|
688
|
+
threshold_critical_label: "Critical:"
|
|
680
689
|
threshold_inverted_note: "(менше — гірше)"
|
|
681
690
|
filters:
|
|
682
|
-
title: "
|
|
691
|
+
title: "Параметри фільтрації"
|
|
683
692
|
current_value: "зараз"
|
|
684
693
|
saved:
|
|
685
|
-
title: "
|
|
686
|
-
saved_at_prefix: "
|
|
694
|
+
title: "Збережено для порівняння"
|
|
695
|
+
saved_at_prefix: "Збережено:"
|
|
687
696
|
click_to_expand: "Натисніть, щоб розгорнути"
|
|
688
697
|
confirm_clear_all: "Видалити всі збережені записи для цього звіту?"
|
|
689
698
|
remove_title: "Видалити"
|
|
@@ -697,9 +706,9 @@ uk:
|
|
|
697
706
|
execution_time_label: "Час виконання:"
|
|
698
707
|
null_placeholder: "<null>"
|
|
699
708
|
sections:
|
|
700
|
-
recommendation: "
|
|
701
|
-
detected_issues: "
|
|
702
|
-
execution_plan: "
|
|
709
|
+
recommendation: "Рекомендація"
|
|
710
|
+
detected_issues: "Виявлені проблеми"
|
|
711
|
+
execution_plan: "План виконання"
|
|
703
712
|
line_label: "Рядок"
|
|
704
713
|
current_label: "Поточне:"
|
|
705
714
|
threshold_label: "Поріг:"
|
|
@@ -707,8 +716,8 @@ uk:
|
|
|
707
716
|
warning_eq: "warning"
|
|
708
717
|
critical_eq: "critical"
|
|
709
718
|
levels:
|
|
710
|
-
critical: "
|
|
711
|
-
warning: "
|
|
719
|
+
critical: "Критично"
|
|
720
|
+
warning: "Увага"
|
|
712
721
|
errors:
|
|
713
722
|
error_prefix: "Помилка:"
|
|
714
723
|
unable_fetch_metrics: "Не вдалося отримати статистику БД."
|
|
@@ -733,13 +742,19 @@ uk:
|
|
|
733
742
|
decode_query_failed: "Не вдалося декодувати запит"
|
|
734
743
|
explain_analyze_failed: "Не вдалося виконати EXPLAIN ANALYZE"
|
|
735
744
|
execute_query_failed: "Не вдалося виконати запит"
|
|
745
|
+
run_query_failed: "Не вдалося виконати запит"
|
|
746
|
+
query_required: "Введіть запит"
|
|
736
747
|
create_migration_failed: "Не вдалося створити міграцію"
|
|
737
|
-
explain_disabled_toast: "
|
|
738
|
-
execute_disabled_toast: "
|
|
748
|
+
explain_disabled_toast: "EXPLAIN ANALYZE вимкнено. Увімкніть у конфігурації: config.allow_raw_query_execution = true"
|
|
749
|
+
execute_disabled_toast: "Виконання запитів вимкнено. Увімкніть у конфігурації: config.allow_raw_query_execution = true"
|
|
750
|
+
migration_disabled_toast: "Створення міграцій вимкнено. Увімкніть у конфігурації: config.allow_raw_query_execution = true"
|
|
739
751
|
query_monitoring_error: "Помилка моніторингу запитів"
|
|
740
752
|
query_hash_required: "Потрібен хеш запиту"
|
|
741
753
|
query_execution_disabled: "Виконання запитів із дашборду вимкнено. Увімкніть у конфігурації: 'config.allow_raw_query_execution = true'"
|
|
742
754
|
query_not_found_expired: "Запит не знайдено або термін дії минув. Оновіть сторінку."
|
|
755
|
+
query_monitor_unavailable_standalone: "SQL Query Monitor недоступний в автономному режимі — немає процесу основного застосунку для спостереження."
|
|
756
|
+
query_timed_out: "Перевищено час очікування запиту (%{ms} мс, statement_timeout). Звузьте запит або збільшіть config.raw_query_statement_timeout_ms."
|
|
757
|
+
rate_limit_exceeded: "Забагато запитів. Зачекайте трохи і спробуйте знову."
|
|
743
758
|
security_violation_prefix: "Порушення безпеки:"
|
|
744
759
|
trigger_variables_not_allowed: "Не можна виконати EXPLAIN ANALYZE для запитів із тригерними змінними (NEW, OLD). Вони доступні лише в контексті тригерних функцій."
|
|
745
760
|
missing_parameter_values: "Вкажіть значення для всіх плейсхолдерів параметрів ($1, $2 тощо)"
|
data/config/routes.rb
CHANGED
|
@@ -14,6 +14,7 @@ PgReports::Engine.routes.draw do
|
|
|
14
14
|
post "reset_statistics", to: "dashboard#reset_statistics", as: :reset_statistics
|
|
15
15
|
post "explain_analyze", to: "dashboard#explain_analyze", as: :explain_analyze
|
|
16
16
|
post "execute_query", to: "dashboard#execute_query", as: :execute_query
|
|
17
|
+
post "run_query", to: "dashboard#run_query", as: :run_query
|
|
17
18
|
post "create_migration", to: "dashboard#create_migration", as: :create_migration
|
|
18
19
|
|
|
19
20
|
# Query monitoring
|
|
@@ -27,6 +27,7 @@ module PgReports
|
|
|
27
27
|
|
|
28
28
|
# Dashboard settings
|
|
29
29
|
attr_accessor :dashboard_auth # Proc for dashboard authentication
|
|
30
|
+
attr_accessor :standalone # True when running via PgReports::Standalone (no host app)
|
|
30
31
|
|
|
31
32
|
# Assets / privacy settings
|
|
32
33
|
attr_accessor :load_external_fonts # When true, loads Google Fonts in the dashboard layout
|
|
@@ -42,6 +43,9 @@ module PgReports
|
|
|
42
43
|
# Security settings
|
|
43
44
|
attr_accessor :allow_raw_query_execution # Allow execute_query and explain_analyze from dashboard
|
|
44
45
|
attr_accessor :allow_migration_creation # Allow dashboard's "Generate Migration" button to write files into db/migrate/
|
|
46
|
+
attr_accessor :raw_query_statement_timeout_ms # Postgres statement_timeout (ms) applied to Execute Query / EXPLAIN ANALYZE / SQL Console. 0 disables it.
|
|
47
|
+
attr_accessor :raw_query_rate_limit # Max privileged requests (raw query execution / Generate Migration) per client IP per window. nil disables rate limiting.
|
|
48
|
+
attr_accessor :raw_query_rate_limit_window_seconds # Window size (seconds) for raw_query_rate_limit
|
|
45
49
|
|
|
46
50
|
# Grafana / Prometheus exporter settings
|
|
47
51
|
attr_accessor :grafana_favorites # Reports exposed at /metrics (Array of keys or Hash with per-report opts)
|
|
@@ -74,6 +78,7 @@ module PgReports
|
|
|
74
78
|
|
|
75
79
|
# Dashboard
|
|
76
80
|
@dashboard_auth = nil
|
|
81
|
+
@standalone = false
|
|
77
82
|
|
|
78
83
|
# Assets / privacy
|
|
79
84
|
@load_external_fonts = ActiveModel::Type::Boolean.new.cast(ENV.fetch("PG_REPORTS_LOAD_EXTERNAL_FONTS", false))
|
|
@@ -101,6 +106,9 @@ module PgReports
|
|
|
101
106
|
else
|
|
102
107
|
ActiveModel::Type::Boolean.new.cast(env_override)
|
|
103
108
|
end
|
|
109
|
+
@raw_query_statement_timeout_ms = ENV.fetch("PG_REPORTS_RAW_QUERY_STATEMENT_TIMEOUT_MS", 5_000).to_i
|
|
110
|
+
@raw_query_rate_limit = ENV.fetch("PG_REPORTS_RAW_QUERY_RATE_LIMIT", 30).to_i
|
|
111
|
+
@raw_query_rate_limit_window_seconds = ENV.fetch("PG_REPORTS_RAW_QUERY_RATE_LIMIT_WINDOW_SECONDS", 60).to_i
|
|
104
112
|
|
|
105
113
|
# Grafana / Prometheus exporter
|
|
106
114
|
@grafana_favorites = []
|
|
@@ -9,6 +9,16 @@ module PgReports
|
|
|
9
9
|
# PgReports::Connection::ErrorTranslator.translate(error)
|
|
10
10
|
# # => { title: "...", detail: "...", hint: "GRANT ...", code: "42501" }
|
|
11
11
|
module ErrorTranslator
|
|
12
|
+
# Relations the reports read that are supplied by an extension rather than
|
|
13
|
+
# by core PostgreSQL. Used to turn "relation X does not exist" into the
|
|
14
|
+
# actionable "extension Y isn't installed here".
|
|
15
|
+
EXTENSION_RELATIONS = {
|
|
16
|
+
"pg_stat_statements" => "pg_stat_statements",
|
|
17
|
+
"pg_stat_statements_info" => "pg_stat_statements",
|
|
18
|
+
"pgstattuple" => "pgstattuple",
|
|
19
|
+
"pg_buffercache" => "pg_buffercache"
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
12
22
|
module_function
|
|
13
23
|
|
|
14
24
|
# Returns a Hash with :title, :detail, :hint, :code, :raw_message.
|
|
@@ -23,6 +33,8 @@ module PgReports
|
|
|
23
33
|
when "28000", "28P01" then auth_failed(message)
|
|
24
34
|
when "08001", "08006", "08000", "08003", "08004" then connection_refused(message)
|
|
25
35
|
when "53300" then too_many_connections(message)
|
|
36
|
+
when "42P01" then undefined_table(message)
|
|
37
|
+
when "42883" then undefined_function(message)
|
|
26
38
|
else generic(error)
|
|
27
39
|
end
|
|
28
40
|
|
|
@@ -92,6 +104,37 @@ module PgReports
|
|
|
92
104
|
}
|
|
93
105
|
end
|
|
94
106
|
|
|
107
|
+
# A report asked for a relation this database doesn't have. Almost always an
|
|
108
|
+
# extension that exists on one database in the cluster but not the one
|
|
109
|
+
# currently selected — extensions are per-database, the dashboard is not.
|
|
110
|
+
def undefined_table(message)
|
|
111
|
+
target = extract_object(message, /relation "([^"]+)" does not exist/)
|
|
112
|
+
|
|
113
|
+
if target && EXTENSION_RELATIONS.key?(target)
|
|
114
|
+
extension = EXTENSION_RELATIONS.fetch(target)
|
|
115
|
+
return {
|
|
116
|
+
title: "#{extension} not available",
|
|
117
|
+
detail: "This report reads \"#{target}\", which does not exist on the selected database.",
|
|
118
|
+
hint: "CREATE EXTENSION IF NOT EXISTS #{extension};"
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
{
|
|
123
|
+
title: "Relation not found",
|
|
124
|
+
detail: target ? "The selected database has no relation named \"#{target}\"." : "The report referenced a relation that does not exist on the selected database.",
|
|
125
|
+
hint: nil
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def undefined_function(message)
|
|
130
|
+
target = extract_object(message, /function ([\w.]+)\(/)
|
|
131
|
+
{
|
|
132
|
+
title: "Function not found",
|
|
133
|
+
detail: target ? "The selected database has no function named \"#{target}\"." : "The report called a function that does not exist on the selected database.",
|
|
134
|
+
hint: "It is usually provided by an extension that is not installed on this database."
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
95
138
|
def generic(error)
|
|
96
139
|
{
|
|
97
140
|
title: error.class.name.split("::").last,
|
|
@@ -237,6 +237,10 @@ module PgReports
|
|
|
237
237
|
name: "Queries",
|
|
238
238
|
icon: "⚡",
|
|
239
239
|
color: "#6366f1",
|
|
240
|
+
# Every report here reads the pg_stat_statements view. The extension is
|
|
241
|
+
# created per-database, so availability has to be re-checked against
|
|
242
|
+
# whichever database the dashboard is pointed at — not once at boot.
|
|
243
|
+
requires: :pg_stat_statements,
|
|
240
244
|
reports: {
|
|
241
245
|
slow_queries: {name: "Slow Queries", description: "Queries with high mean execution time"},
|
|
242
246
|
heavy_queries: {name: "Heavy Queries", description: "Most frequently called queries"},
|
|
@@ -412,6 +416,12 @@ module PgReports
|
|
|
412
416
|
def self.target_constraint(category)
|
|
413
417
|
REPORTS.dig(category.to_sym, :target_constraint)
|
|
414
418
|
end
|
|
419
|
+
|
|
420
|
+
# A database-level dependency the whole category needs, or nil. Currently
|
|
421
|
+
# only :pg_stat_statements — the caller resolves what "available" means.
|
|
422
|
+
def self.requires(category)
|
|
423
|
+
REPORTS.dig(category.to_sym, :requires)
|
|
424
|
+
end
|
|
415
425
|
end
|
|
416
426
|
end
|
|
417
427
|
end
|
|
@@ -313,15 +313,12 @@ module PgReports
|
|
|
313
313
|
if @summary[:critical_problems] > 0
|
|
314
314
|
@summary[:status] = "critical"
|
|
315
315
|
@summary[:status_text] = "Critical issues detected"
|
|
316
|
-
@summary[:status_icon] = "🔴"
|
|
317
316
|
elsif @summary[:warnings] > 0
|
|
318
317
|
@summary[:status] = "warning"
|
|
319
318
|
@summary[:status_text] = "Potential issues detected"
|
|
320
|
-
@summary[:status_icon] = "🟡"
|
|
321
319
|
else
|
|
322
320
|
@summary[:status] = "good"
|
|
323
321
|
@summary[:status_text] = "No issues detected"
|
|
324
|
-
@summary[:status_icon] = "🟢"
|
|
325
322
|
end
|
|
326
323
|
|
|
327
324
|
# Group problems by type for summary
|
|
@@ -105,7 +105,11 @@ module PgReports
|
|
|
105
105
|
mod = module_for(key) or raise ArgumentError, "Unknown report: #{key}"
|
|
106
106
|
|
|
107
107
|
started = @clock.now
|
|
108
|
-
|
|
108
|
+
args = report_args(opts)
|
|
109
|
+
# Call with no arguments when there are no kwargs to forward. On Ruby 2.7
|
|
110
|
+
# `public_send(key, **{})` does not reliably elide to a no-arg call, which
|
|
111
|
+
# breaks report methods (and `have_received(...).with(no_args)` matchers).
|
|
112
|
+
report = args.empty? ? mod.public_send(key) : mod.public_send(key, **args)
|
|
109
113
|
finished = @clock.now
|
|
110
114
|
|
|
111
115
|
{
|
|
@@ -106,10 +106,18 @@ module PgReports
|
|
|
106
106
|
{
|
|
107
107
|
enabled: enabled,
|
|
108
108
|
session_id: session_id,
|
|
109
|
-
query_count: @queries.size
|
|
109
|
+
query_count: @queries.size,
|
|
110
|
+
history_available: history_available?
|
|
110
111
|
}
|
|
111
112
|
end
|
|
112
113
|
|
|
114
|
+
# Whether #load_from_log has anything to return. History is persisted only
|
|
115
|
+
# when a log file is configured, and the file exists only once something has
|
|
116
|
+
# been written to it — without both, "Load history" can only ever no-op.
|
|
117
|
+
def history_available?
|
|
118
|
+
log_file_enabled? && File.exist?(log_file_path)
|
|
119
|
+
end
|
|
120
|
+
|
|
113
121
|
def queries(limit: nil, session_id: nil)
|
|
114
122
|
result = @queries.dup
|
|
115
123
|
|
|
@@ -348,6 +356,10 @@ module PgReports
|
|
|
348
356
|
end
|
|
349
357
|
end
|
|
350
358
|
|
|
359
|
+
# JSON.generate, not #to_json: ActiveSupport overrides #to_json and (up to
|
|
360
|
+
# Rails 7.0) passes json's removed `quirks_mode` option, so on json >= 3 it
|
|
361
|
+
# raises ArgumentError. The rescue below would swallow that and leave the
|
|
362
|
+
# log silently empty. json's own generator takes no such option.
|
|
351
363
|
def write_session_marker(marker_type, sid = @session_id)
|
|
352
364
|
return unless log_file_enabled?
|
|
353
365
|
|
|
@@ -358,7 +370,7 @@ module PgReports
|
|
|
358
370
|
}
|
|
359
371
|
|
|
360
372
|
File.open(log_file_path, "a") do |f|
|
|
361
|
-
f.puts marker
|
|
373
|
+
f.puts JSON.generate(marker)
|
|
362
374
|
end
|
|
363
375
|
rescue => e
|
|
364
376
|
# Silently fail - don't break monitoring if file write fails
|
|
@@ -371,7 +383,7 @@ module PgReports
|
|
|
371
383
|
|
|
372
384
|
File.open(log_file_path, "a") do |f|
|
|
373
385
|
@queries.each do |query|
|
|
374
|
-
f.puts query
|
|
386
|
+
f.puts JSON.generate(query)
|
|
375
387
|
end
|
|
376
388
|
end
|
|
377
389
|
rescue => e
|
|
@@ -24,6 +24,10 @@ module PgReports
|
|
|
24
24
|
# Rack handlers tried, in order, when none is named explicitly.
|
|
25
25
|
CANDIDATE_SERVERS = %w[puma webrick].freeze
|
|
26
26
|
|
|
27
|
+
# Config files auto-loaded (first that exists), relative to the working
|
|
28
|
+
# directory, when no explicit --config path is given.
|
|
29
|
+
DEFAULT_CONFIG_FILES = %w[pg_reports.rb config/pg_reports.rb].freeze
|
|
30
|
+
|
|
27
31
|
class ServerUnavailable < PgReports::Error; end
|
|
28
32
|
|
|
29
33
|
# Boot the app and start a (blocking) web server.
|
|
@@ -34,14 +38,27 @@ module PgReports
|
|
|
34
38
|
# @param database_url [String, nil] explicit connection URL; otherwise resolved
|
|
35
39
|
# from DATABASE_URL or libpq-style PG* env vars
|
|
36
40
|
# @param server [String, nil] Rack handler name to force (e.g. "puma")
|
|
41
|
+
# @param config_file [String, nil] path to a Ruby config file that calls
|
|
42
|
+
# `PgReports.configure`; auto-detected from DEFAULT_CONFIG_FILES otherwise
|
|
43
|
+
# @param overrides [Hash] per-setting overrides applied last (from CLI flags);
|
|
44
|
+
# nil values are ignored. Keys are Configuration attribute names.
|
|
37
45
|
def run(port: DEFAULT_PORT, host: DEFAULT_HOST, mount_path: DEFAULT_MOUNT,
|
|
38
|
-
database_url: nil, server: nil)
|
|
46
|
+
database_url: nil, server: nil, config_file: nil, overrides: {})
|
|
39
47
|
# Rails' ActiveRecord railtie reads the connection from DATABASE_URL when no
|
|
40
48
|
# config/database.yml exists — so we route our resolved connection through
|
|
41
49
|
# it. The connection registry then auto-registers it as the :primary target,
|
|
42
50
|
# and database switching / multi-cluster all work unchanged.
|
|
43
51
|
ENV["DATABASE_URL"] = connection_url(database_url)
|
|
44
52
|
|
|
53
|
+
# Mark this process as standalone so the dashboard can hide reports that
|
|
54
|
+
# only make sense with a host app (e.g. Schema Analysis, which introspects
|
|
55
|
+
# the host application's ActiveRecord models — there are none here).
|
|
56
|
+
PgReports.config.standalone = true
|
|
57
|
+
|
|
58
|
+
# Layer settings on top of the ENV-derived defaults: config file first, then
|
|
59
|
+
# explicit CLI overrides win.
|
|
60
|
+
apply_configuration(config_file: config_file, overrides: overrides)
|
|
61
|
+
|
|
45
62
|
app = build_application(mount_path)
|
|
46
63
|
app.initialize!
|
|
47
64
|
verify_connection!
|
|
@@ -76,6 +93,47 @@ module PgReports
|
|
|
76
93
|
|
|
77
94
|
private
|
|
78
95
|
|
|
96
|
+
# Apply configuration in increasing order of precedence:
|
|
97
|
+
# ENV vars (already read when Configuration was built)
|
|
98
|
+
# < config file (full Ruby, calls PgReports.configure)
|
|
99
|
+
# < CLI overrides (individual flags)
|
|
100
|
+
#
|
|
101
|
+
# The config file is the escape hatch for every setting that has no dedicated
|
|
102
|
+
# flag — thresholds, Telegram, Grafana favorites, even the dashboard_auth
|
|
103
|
+
# proc. CLI flags cover only the handful of common security toggles.
|
|
104
|
+
def apply_configuration(config_file:, overrides:)
|
|
105
|
+
path = config_file || detect_config_file
|
|
106
|
+
load_config_file(path) if path
|
|
107
|
+
|
|
108
|
+
overrides.each do |key, value|
|
|
109
|
+
next if value.nil? # unset flag — leave the file/ENV value in place
|
|
110
|
+
PgReports.config.public_send(:"#{key}=", value)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# First existing DEFAULT_CONFIG_FILES entry (relative to the working dir), or
|
|
115
|
+
# nil when the user keeps no config file.
|
|
116
|
+
def detect_config_file
|
|
117
|
+
DEFAULT_CONFIG_FILES
|
|
118
|
+
.map { |f| File.expand_path(f, Dir.pwd) }
|
|
119
|
+
.find { |f| File.file?(f) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Evaluate a Ruby config file. A missing explicit path is an error (the user
|
|
123
|
+
# asked for it); a broken file is reported with context rather than a raw
|
|
124
|
+
# backtrace.
|
|
125
|
+
def load_config_file(path)
|
|
126
|
+
full = File.expand_path(path)
|
|
127
|
+
raise PgReports::Error, "Config file not found: #{path}" unless File.file?(full)
|
|
128
|
+
|
|
129
|
+
warn "pg_reports: loading config from #{full}"
|
|
130
|
+
load full
|
|
131
|
+
rescue PgReports::Error
|
|
132
|
+
raise
|
|
133
|
+
rescue => e
|
|
134
|
+
raise PgReports::Error, "Failed to load config file #{full}: #{e.message}"
|
|
135
|
+
end
|
|
136
|
+
|
|
79
137
|
# Build (and register as Rails.application) a minimal Rails app that mounts
|
|
80
138
|
# the engine. Kept intentionally small: no asset pipeline (views are inline),
|
|
81
139
|
# cookie sessions for the dashboard's database selector + CSRF.
|
data/lib/pg_reports/version.rb
CHANGED
data/lib/pg_reports.rb
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# ActiveSupport < 7.1 relies on Ruby's `logger` being loaded implicitly, which
|
|
4
|
+
# concurrent-ruby dropped in 1.3.5. Require it up front so pg_reports loads on
|
|
5
|
+
# Rails 6.1/7.0 (and any bundle with a modern concurrent-ruby). Harmless on
|
|
6
|
+
# newer Rails, which require it themselves.
|
|
7
|
+
require "logger"
|
|
3
8
|
require "active_support"
|
|
4
9
|
require "active_support/core_ext"
|
|
5
10
|
require "active_record"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pg_reports
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eldar Avatov
|
|
@@ -160,6 +160,7 @@ files:
|
|
|
160
160
|
- app/views/pg_reports/dashboard/index.html.erb
|
|
161
161
|
- app/views/pg_reports/dashboard/show.html.erb
|
|
162
162
|
- bin/pg_reports
|
|
163
|
+
- config/brakeman.ignore
|
|
163
164
|
- config/locales/en.yml
|
|
164
165
|
- config/locales/ru.yml
|
|
165
166
|
- config/locales/uk.yml
|