pg_reports 0.8.0 → 0.8.2
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 +43 -0
- data/README.md +40 -70
- data/app/controllers/pg_reports/dashboard_controller.rb +185 -19
- data/app/views/layouts/pg_reports/application.html.erb +88 -6
- data/app/views/pg_reports/dashboard/index.html.erb +391 -23
- data/bin/pg_reports +111 -0
- data/config/brakeman.ignore +74 -0
- data/config/locales/en.yml +19 -5
- data/config/locales/ru.yml +19 -5
- data/config/locales/uk.yml +19 -5
- data/config/routes.rb +1 -0
- data/lib/pg_reports/configuration.rb +8 -0
- data/lib/pg_reports/connection/target.rb +1 -1
- data/lib/pg_reports/executor.rb +7 -2
- data/lib/pg_reports/grafana/exporter.rb +5 -1
- data/lib/pg_reports/modules/system.rb +28 -4
- data/lib/pg_reports/standalone.rb +210 -0
- data/lib/pg_reports/version.rb +1 -1
- data/lib/pg_reports.rb +9 -0
- metadata +7 -3
data/bin/pg_reports
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
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
|
+
config_file: nil
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# Per-setting overrides from flags. nil means "unset" — the config file / ENV
|
|
31
|
+
# value is kept. Populated below and forwarded to Standalone.run(overrides:).
|
|
32
|
+
overrides = {
|
|
33
|
+
allow_raw_query_execution: nil,
|
|
34
|
+
allow_migration_creation: nil,
|
|
35
|
+
load_external_fonts: nil
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
banner = <<~BANNER
|
|
39
|
+
pg_reports — self-contained PostgreSQL insights dashboard
|
|
40
|
+
|
|
41
|
+
Usage:
|
|
42
|
+
pg_reports server [options]
|
|
43
|
+
|
|
44
|
+
Connection is resolved from --database-url, else DATABASE_URL, else the
|
|
45
|
+
standard libpq env vars (PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE).
|
|
46
|
+
|
|
47
|
+
Configuration is layered, lowest to highest precedence:
|
|
48
|
+
PG_REPORTS_* env vars < config file < the flags below.
|
|
49
|
+
A config file (--config, else ./pg_reports.rb or config/pg_reports.rb) is
|
|
50
|
+
plain Ruby calling `PgReports.configure` — the way to set every option that
|
|
51
|
+
has no flag (thresholds, Telegram, Grafana, dashboard_auth, …).
|
|
52
|
+
|
|
53
|
+
Options:
|
|
54
|
+
BANNER
|
|
55
|
+
|
|
56
|
+
parser = OptionParser.new do |o|
|
|
57
|
+
o.banner = banner
|
|
58
|
+
o.on("-p", "--port PORT", Integer, "Port to listen on (default: 4000)") { |v| options[:port] = v }
|
|
59
|
+
o.on("-b", "--host HOST", "Host/interface to bind (default: 127.0.0.1)") { |v| options[:host] = v }
|
|
60
|
+
o.on("-m", "--mount PATH", "Path to mount the dashboard at (default: /)") { |v| options[:mount] = v }
|
|
61
|
+
o.on("-d", "--database-url URL", "PostgreSQL connection URL") { |v| options[:database_url] = v }
|
|
62
|
+
o.on("-s", "--server NAME", "Web server to use (e.g. puma, webrick)") { |v| options[:server] = v }
|
|
63
|
+
o.on("-c", "--config PATH", "Ruby config file to load (default: ./pg_reports.rb or config/pg_reports.rb)") { |v| options[:config_file] = v }
|
|
64
|
+
o.on("--[no-]allow-raw-query-execution", "Allow running raw SQL / EXPLAIN ANALYZE from the dashboard") { |v| overrides[:allow_raw_query_execution] = v }
|
|
65
|
+
o.on("--[no-]allow-migration-creation", "Allow the dashboard to write migration files to db/migrate/") { |v| overrides[:allow_migration_creation] = v }
|
|
66
|
+
o.on("--[no-]external-fonts", "Load Google Fonts in the dashboard (off by default)") { |v| overrides[:load_external_fonts] = v }
|
|
67
|
+
o.on("-h", "--help", "Show this help") do
|
|
68
|
+
puts o
|
|
69
|
+
exit
|
|
70
|
+
end
|
|
71
|
+
o.on("-v", "--version", "Show version") do
|
|
72
|
+
require "pg_reports/version"
|
|
73
|
+
puts PgReports::VERSION
|
|
74
|
+
exit
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# parse! permutes, so options are recognized before or after the subcommand
|
|
79
|
+
# (e.g. `pg_reports server --port 4055`).
|
|
80
|
+
parser.parse!(ARGV)
|
|
81
|
+
command = ARGV.shift || "server"
|
|
82
|
+
|
|
83
|
+
case command
|
|
84
|
+
when "server"
|
|
85
|
+
require "pg_reports"
|
|
86
|
+
begin
|
|
87
|
+
PgReports::Standalone.run(
|
|
88
|
+
port: options[:port],
|
|
89
|
+
host: options[:host],
|
|
90
|
+
mount_path: options[:mount],
|
|
91
|
+
database_url: options[:database_url],
|
|
92
|
+
server: options[:server],
|
|
93
|
+
config_file: options[:config_file],
|
|
94
|
+
overrides: overrides
|
|
95
|
+
)
|
|
96
|
+
rescue PgReports::Standalone::ServerUnavailable => e
|
|
97
|
+
warn e.message
|
|
98
|
+
exit 1
|
|
99
|
+
rescue PgReports::Error => e
|
|
100
|
+
# Config-file and connection problems surface as PgReports::Error with a
|
|
101
|
+
# human-readable message — print it, not a backtrace.
|
|
102
|
+
warn "pg_reports: #{e.message}"
|
|
103
|
+
exit 1
|
|
104
|
+
rescue Interrupt
|
|
105
|
+
warn "\npg_reports: stopped"
|
|
106
|
+
end
|
|
107
|
+
else
|
|
108
|
+
warn "Unknown command: #{command.inspect}"
|
|
109
|
+
warn parser.help
|
|
110
|
+
exit 1
|
|
111
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"ignored_warnings": [
|
|
3
|
+
{
|
|
4
|
+
"warning_type": "SQL Injection",
|
|
5
|
+
"warning_code": 0,
|
|
6
|
+
"fingerprint": "5209397462e689c349f77e534f897c12a8550b9a013bd535153a929c12b944e2",
|
|
7
|
+
"check_name": "SQL",
|
|
8
|
+
"message": "Possible SQL injection",
|
|
9
|
+
"file": "app/controllers/pg_reports/dashboard_controller.rb",
|
|
10
|
+
"line": 357,
|
|
11
|
+
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
|
|
12
|
+
"code": "ActiveRecord::Base.connection.execute(\"SELECT COUNT(*) FROM (#{substitute_params(retrieve_query_by_hash(params[:query_hash]), (params[:params] or {}))}) AS count_query\")",
|
|
13
|
+
"render_path": null,
|
|
14
|
+
"location": {
|
|
15
|
+
"type": "method",
|
|
16
|
+
"class": "PgReports::DashboardController",
|
|
17
|
+
"method": "execute_query"
|
|
18
|
+
},
|
|
19
|
+
"user_input": "params[:params]",
|
|
20
|
+
"confidence": "High",
|
|
21
|
+
"cwe_id": [
|
|
22
|
+
89
|
|
23
|
+
],
|
|
24
|
+
"note": "Reviewed & accepted: user-query execution feature (execute_query count). Gated by config.allow_raw_query_execution; the query is fetched by hash from an internal registry (retrieve_query_by_hash), parameters are validated/substituted, and SecurityError checks reject dangerous input. Not arbitrary attacker-controlled SQL."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"warning_type": "Cross-Site Request Forgery",
|
|
28
|
+
"warning_code": 7,
|
|
29
|
+
"fingerprint": "b9682cc0e0a1cd86fddef96834d4f71c3558989c2136fe58b5ff51c75b83c8b4",
|
|
30
|
+
"check_name": "ForgerySetting",
|
|
31
|
+
"message": "`protect_from_forgery` should be called in `PgReports::MetricsController`",
|
|
32
|
+
"file": "app/controllers/pg_reports/metrics_controller.rb",
|
|
33
|
+
"line": 6,
|
|
34
|
+
"link": "https://brakemanscanner.org/docs/warning_types/cross-site_request_forgery/",
|
|
35
|
+
"code": null,
|
|
36
|
+
"render_path": null,
|
|
37
|
+
"location": {
|
|
38
|
+
"type": "controller",
|
|
39
|
+
"controller": "PgReports::MetricsController"
|
|
40
|
+
},
|
|
41
|
+
"user_input": null,
|
|
42
|
+
"confidence": "High",
|
|
43
|
+
"cwe_id": [
|
|
44
|
+
352
|
|
45
|
+
],
|
|
46
|
+
"note": "Reviewed & accepted: read-only Prometheus /metrics endpoint. Uses bearer-token auth (config.grafana_metrics_token) with constant-time compare; CSRF protection is not applicable to a GET scrape endpoint."
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"warning_type": "SQL Injection",
|
|
50
|
+
"warning_code": 0,
|
|
51
|
+
"fingerprint": "f133378f2542f3c09b527aebcc0939a1d227c46b0ab124be613e1e5aac829c79",
|
|
52
|
+
"check_name": "SQL",
|
|
53
|
+
"message": "Possible SQL injection",
|
|
54
|
+
"file": "app/controllers/pg_reports/dashboard_controller.rb",
|
|
55
|
+
"line": 278,
|
|
56
|
+
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
|
|
57
|
+
"code": "ActiveRecord::Base.connection.execute(\"EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) #{substitute_params(retrieve_query_by_hash(params[:query_hash]), (params[:params] or {}))}\")",
|
|
58
|
+
"render_path": null,
|
|
59
|
+
"location": {
|
|
60
|
+
"type": "method",
|
|
61
|
+
"class": "PgReports::DashboardController",
|
|
62
|
+
"method": "explain_analyze"
|
|
63
|
+
},
|
|
64
|
+
"user_input": "params[:params]",
|
|
65
|
+
"confidence": "High",
|
|
66
|
+
"cwe_id": [
|
|
67
|
+
89
|
|
68
|
+
],
|
|
69
|
+
"note": "Reviewed & accepted: EXPLAIN ANALYZE feature. Same safeguards as execute_query - query resolved by hash from internal registry, params validated, SecurityError guards. Intentional DBA tooling."
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
"updated": "2026-07-04 20:27:49 +0300",
|
|
73
|
+
"brakeman_version": "8.0.5"
|
|
74
|
+
}
|
data/config/locales/en.yml
CHANGED
|
@@ -622,6 +622,7 @@ en:
|
|
|
622
622
|
ide_settings_button_title: "IDE Settings"
|
|
623
623
|
explain_analyze: "📊 EXPLAIN ANALYZE"
|
|
624
624
|
execute_query: "▶ Execute Query"
|
|
625
|
+
run_query: "SQL Console"
|
|
625
626
|
create_migration_file: "📁 Create File & Open in IDE"
|
|
626
627
|
start_monitoring: "▶ Start Monitoring"
|
|
627
628
|
stop_monitoring: "⏹ Stop Monitoring"
|
|
@@ -633,11 +634,12 @@ en:
|
|
|
633
634
|
save_for_comparison: "📌 Save for Comparison"
|
|
634
635
|
saved_marker: "📌 Saved"
|
|
635
636
|
status:
|
|
636
|
-
pg_stat_ready: "
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
637
|
+
pg_stat_ready: "Active"
|
|
638
|
+
not_preloaded: "Preload required"
|
|
639
|
+
extension_missing: "Extension required"
|
|
640
|
+
disconnected: "No connection"
|
|
640
641
|
monitoring_unavailable: "Status Unavailable"
|
|
642
|
+
click_for_details: "Click to see how to enable it"
|
|
641
643
|
modals:
|
|
642
644
|
enable_pg_stat_title: "Enable pg_stat_statements"
|
|
643
645
|
enable_pg_stat_intro: "To enable pg_stat_statements, follow these steps:"
|
|
@@ -645,6 +647,9 @@ en:
|
|
|
645
647
|
restart_postgresql: "Restart PostgreSQL:"
|
|
646
648
|
create_extension_step: "Create extension:"
|
|
647
649
|
enable_button_note: "Or click \"Enable\" button after restart."
|
|
650
|
+
create_extension_title: "Create pg_stat_statements extension"
|
|
651
|
+
create_extension_intro: "The pg_stat_statements extension isn't created in this database yet. Click below to create it:"
|
|
652
|
+
create_extension_note: "If the library isn't preloaded, you'll also need to add it to shared_preload_libraries and restart PostgreSQL."
|
|
648
653
|
reset_stats_title: "⚠️ Reset Statistics"
|
|
649
654
|
reset_stats_confirm: "Are you sure you want to reset pg_stat_statements statistics?"
|
|
650
655
|
reset_stats_warning: "This action will clear all collected query statistics and cannot be undone."
|
|
@@ -659,6 +664,9 @@ en:
|
|
|
659
664
|
migration_warning_dev_only: "This operation should only be performed in a local development environment."
|
|
660
665
|
query_execution_disabled_title: "⚠️ Query execution is disabled"
|
|
661
666
|
query_execution_disabled_intro: "To enable this feature, add to your configuration:"
|
|
667
|
+
query_execution_disabled_env_note: "...or set the following environment variable instead:"
|
|
668
|
+
run_query_title: "SQL Console"
|
|
669
|
+
run_query_placeholder: "SELECT * FROM users LIMIT 10;"
|
|
662
670
|
settings:
|
|
663
671
|
default_ide_label: "Default IDE for source links:"
|
|
664
672
|
ide_show_menu: "Show menu (default)"
|
|
@@ -691,7 +699,7 @@ en:
|
|
|
691
699
|
cache_hit_detail: "heap blocks from cache"
|
|
692
700
|
long_queries_label: "Long Queries"
|
|
693
701
|
queries_unit: "queries"
|
|
694
|
-
long_running_threshold: ">
|
|
702
|
+
long_running_threshold: "> 5s runtime"
|
|
695
703
|
blocked_label: "Blocked"
|
|
696
704
|
processes_unit: "processes"
|
|
697
705
|
waiting_for_locks: "waiting for locks"
|
|
@@ -700,6 +708,7 @@ en:
|
|
|
700
708
|
requires_pg_stat: "🔒 Requires pg_stat_statements"
|
|
701
709
|
reports_count_suffix: "reports"
|
|
702
710
|
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."
|
|
711
|
+
standalone_no_host_app_reason: "🔒 Not available in standalone mode — this category inspects the host application's models, and there is no host app when running the dashboard on its own."
|
|
703
712
|
documentation:
|
|
704
713
|
toggle_title: "📖 What does this report show?"
|
|
705
714
|
what_section: "📋 What"
|
|
@@ -764,6 +773,8 @@ en:
|
|
|
764
773
|
decode_query_failed: "Failed to decode query"
|
|
765
774
|
explain_analyze_failed: "Failed to run EXPLAIN ANALYZE"
|
|
766
775
|
execute_query_failed: "Failed to execute query"
|
|
776
|
+
run_query_failed: "Failed to run query"
|
|
777
|
+
query_required: "Please enter a query"
|
|
767
778
|
create_migration_failed: "Failed to create migration"
|
|
768
779
|
explain_disabled_toast: "⚠️ EXPLAIN ANALYZE is disabled. Enable in configuration: config.allow_raw_query_execution = true"
|
|
769
780
|
execute_disabled_toast: "⚠️ Query execution is disabled. Enable in configuration: config.allow_raw_query_execution = true"
|
|
@@ -771,6 +782,9 @@ en:
|
|
|
771
782
|
query_hash_required: "Query hash is required"
|
|
772
783
|
query_execution_disabled: "Query execution from dashboard is disabled. Enable it in configuration with 'config.allow_raw_query_execution = true'"
|
|
773
784
|
query_not_found_expired: "Query not found or expired. Please refresh the page."
|
|
785
|
+
query_monitor_unavailable_standalone: "SQL Query Monitor isn't available in standalone mode — there is no host application process to observe."
|
|
786
|
+
query_timed_out: "Query timed out after %{ms}ms (statement_timeout). Try narrowing the query or increase config.raw_query_statement_timeout_ms."
|
|
787
|
+
rate_limit_exceeded: "Too many requests. Please wait a bit before trying again."
|
|
774
788
|
security_violation_prefix: "Security violation:"
|
|
775
789
|
trigger_variables_not_allowed: "Cannot EXPLAIN ANALYZE queries with trigger variables (NEW, OLD). These are only available within trigger functions."
|
|
776
790
|
missing_parameter_values: "Please provide values for all parameter placeholders ($1, $2, etc.)"
|
data/config/locales/ru.yml
CHANGED
|
@@ -587,6 +587,7 @@ ru:
|
|
|
587
587
|
ide_settings_button_title: "Настройки IDE"
|
|
588
588
|
explain_analyze: "📊 EXPLAIN ANALYZE"
|
|
589
589
|
execute_query: "▶ Выполнить запрос"
|
|
590
|
+
run_query: "SQL-консоль"
|
|
590
591
|
create_migration_file: "📁 Создать файл и открыть в IDE"
|
|
591
592
|
start_monitoring: "▶ Запустить мониторинг"
|
|
592
593
|
stop_monitoring: "⏹ Остановить мониторинг"
|
|
@@ -598,11 +599,12 @@ ru:
|
|
|
598
599
|
save_for_comparison: "📌 Сохранить для сравнения"
|
|
599
600
|
saved_marker: "📌 Сохранено"
|
|
600
601
|
status:
|
|
601
|
-
pg_stat_ready: "
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
602
|
+
pg_stat_ready: "Активно"
|
|
603
|
+
not_preloaded: "Требуется предзагрузка"
|
|
604
|
+
extension_missing: "Требуется расширение"
|
|
605
|
+
disconnected: "Нет соединения"
|
|
605
606
|
monitoring_unavailable: "Статус недоступен"
|
|
607
|
+
click_for_details: "Нажмите, чтобы узнать, как включить"
|
|
606
608
|
modals:
|
|
607
609
|
enable_pg_stat_title: "Включение pg_stat_statements"
|
|
608
610
|
enable_pg_stat_intro: "Чтобы включить pg_stat_statements, выполните следующие шаги:"
|
|
@@ -610,6 +612,9 @@ ru:
|
|
|
610
612
|
restart_postgresql: "Перезапустите PostgreSQL:"
|
|
611
613
|
create_extension_step: "Создайте расширение:"
|
|
612
614
|
enable_button_note: "Или нажмите кнопку «Создать расширение» после перезапуска."
|
|
615
|
+
create_extension_title: "Создание расширения pg_stat_statements"
|
|
616
|
+
create_extension_intro: "Расширение pg_stat_statements ещё не создано в этой базе данных. Нажмите, чтобы создать его:"
|
|
617
|
+
create_extension_note: "Если библиотека не предзагружена, её также нужно добавить в shared_preload_libraries и перезапустить PostgreSQL."
|
|
613
618
|
reset_stats_title: "⚠️ Сброс статистики"
|
|
614
619
|
reset_stats_confirm: "Вы уверены, что хотите сбросить статистику pg_stat_statements?"
|
|
615
620
|
reset_stats_warning: "Это действие очистит всю собранную статистику запросов и не может быть отменено."
|
|
@@ -624,6 +629,9 @@ ru:
|
|
|
624
629
|
migration_warning_dev_only: "Эту операцию следует выполнять только в локальном dev-окружении."
|
|
625
630
|
query_execution_disabled_title: "⚠️ Выполнение запросов отключено"
|
|
626
631
|
query_execution_disabled_intro: "Чтобы включить эту функцию, добавьте в конфигурацию:"
|
|
632
|
+
query_execution_disabled_env_note: "...или вместо этого установите переменную окружения:"
|
|
633
|
+
run_query_title: "SQL-консоль"
|
|
634
|
+
run_query_placeholder: "SELECT * FROM users LIMIT 10;"
|
|
627
635
|
settings:
|
|
628
636
|
default_ide_label: "IDE по умолчанию для ссылок на исходники:"
|
|
629
637
|
ide_show_menu: "Показывать меню (по умолчанию)"
|
|
@@ -656,7 +664,7 @@ ru:
|
|
|
656
664
|
cache_hit_detail: "блоки heap из кэша"
|
|
657
665
|
long_queries_label: "Долгие запросы"
|
|
658
666
|
queries_unit: "запросов"
|
|
659
|
-
long_running_threshold: ">
|
|
667
|
+
long_running_threshold: "> 5с выполнения"
|
|
660
668
|
blocked_label: "Заблокировано"
|
|
661
669
|
processes_unit: "процессов"
|
|
662
670
|
waiting_for_locks: "ждут блокировок"
|
|
@@ -665,6 +673,7 @@ ru:
|
|
|
665
673
|
requires_pg_stat: "🔒 Требуется pg_stat_statements"
|
|
666
674
|
reports_count_suffix: "отчётов"
|
|
667
675
|
primary_only_reason: "🔒 Эта категория работает только на первичной базе — она проверяет модели хост-приложения. Чтобы пользоваться, переключитесь обратно на базу по умолчанию."
|
|
676
|
+
standalone_no_host_app_reason: "🔒 Недоступно в автономном режиме — эта категория проверяет модели хост-приложения, а при самостоятельном запуске дашборда хост-приложения нет."
|
|
668
677
|
documentation:
|
|
669
678
|
toggle_title: "📖 Что показывает этот отчёт?"
|
|
670
679
|
what_section: "📋 Что"
|
|
@@ -729,6 +738,8 @@ ru:
|
|
|
729
738
|
decode_query_failed: "Не удалось декодировать запрос"
|
|
730
739
|
explain_analyze_failed: "Не удалось выполнить EXPLAIN ANALYZE"
|
|
731
740
|
execute_query_failed: "Не удалось выполнить запрос"
|
|
741
|
+
run_query_failed: "Не удалось выполнить запрос"
|
|
742
|
+
query_required: "Введите запрос"
|
|
732
743
|
create_migration_failed: "Не удалось создать миграцию"
|
|
733
744
|
explain_disabled_toast: "⚠️ EXPLAIN ANALYZE отключен. Включите в конфигурации: config.allow_raw_query_execution = true"
|
|
734
745
|
execute_disabled_toast: "⚠️ Выполнение запросов отключено. Включите в конфигурации: config.allow_raw_query_execution = true"
|
|
@@ -736,6 +747,9 @@ ru:
|
|
|
736
747
|
query_hash_required: "Требуется хэш запроса"
|
|
737
748
|
query_execution_disabled: "Выполнение запросов из дашборда отключено. Включите в конфигурации: 'config.allow_raw_query_execution = true'"
|
|
738
749
|
query_not_found_expired: "Запрос не найден или истёк срок действия. Обновите страницу."
|
|
750
|
+
query_monitor_unavailable_standalone: "SQL Query Monitor недоступен в автономном режиме — нет процесса основного приложения для наблюдения."
|
|
751
|
+
query_timed_out: "Превышено время ожидания запроса (%{ms} мс, statement_timeout). Сузьте запрос или увеличьте config.raw_query_statement_timeout_ms."
|
|
752
|
+
rate_limit_exceeded: "Слишком много запросов. Подождите немного и попробуйте снова."
|
|
739
753
|
security_violation_prefix: "Нарушение безопасности:"
|
|
740
754
|
trigger_variables_not_allowed: "Нельзя выполнить EXPLAIN ANALYZE для запросов с триггерными переменными (NEW, OLD). Они доступны только в контексте триггерных функций."
|
|
741
755
|
missing_parameter_values: "Укажите значения для всех плейсхолдеров параметров ($1, $2 и т.д.)"
|
data/config/locales/uk.yml
CHANGED
|
@@ -587,6 +587,7 @@ uk:
|
|
|
587
587
|
ide_settings_button_title: "Налаштування IDE"
|
|
588
588
|
explain_analyze: "📊 EXPLAIN ANALYZE"
|
|
589
589
|
execute_query: "▶ Виконати запит"
|
|
590
|
+
run_query: "SQL-консоль"
|
|
590
591
|
create_migration_file: "📁 Створити файл і відкрити в IDE"
|
|
591
592
|
start_monitoring: "▶ Запустити моніторинг"
|
|
592
593
|
stop_monitoring: "⏹ Зупинити моніторинг"
|
|
@@ -598,11 +599,12 @@ uk:
|
|
|
598
599
|
save_for_comparison: "📌 Зберегти для порівняння"
|
|
599
600
|
saved_marker: "📌 Збережено"
|
|
600
601
|
status:
|
|
601
|
-
pg_stat_ready: "
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
602
|
+
pg_stat_ready: "Активно"
|
|
603
|
+
not_preloaded: "Потрібне передзавантаження"
|
|
604
|
+
extension_missing: "Потрібне розширення"
|
|
605
|
+
disconnected: "Немає з'єднання"
|
|
605
606
|
monitoring_unavailable: "Статус недоступний"
|
|
607
|
+
click_for_details: "Натисніть, щоб дізнатися, як увімкнути"
|
|
606
608
|
modals:
|
|
607
609
|
enable_pg_stat_title: "Увімкнення pg_stat_statements"
|
|
608
610
|
enable_pg_stat_intro: "Щоб увімкнути pg_stat_statements, виконайте такі кроки:"
|
|
@@ -610,6 +612,9 @@ uk:
|
|
|
610
612
|
restart_postgresql: "Перезапустіть PostgreSQL:"
|
|
611
613
|
create_extension_step: "Створіть розширення:"
|
|
612
614
|
enable_button_note: "Або натисніть кнопку «Створити розширення» після перезапуску."
|
|
615
|
+
create_extension_title: "Створення розширення pg_stat_statements"
|
|
616
|
+
create_extension_intro: "Розширення pg_stat_statements ще не створено в цій базі даних. Натисніть, щоб створити його:"
|
|
617
|
+
create_extension_note: "Якщо бібліотеку не передзавантажено, її також потрібно додати до shared_preload_libraries та перезапустити PostgreSQL."
|
|
613
618
|
reset_stats_title: "⚠️ Скидання статистики"
|
|
614
619
|
reset_stats_confirm: "Ви впевнені, що хочете скинути статистику pg_stat_statements?"
|
|
615
620
|
reset_stats_warning: "Ця дія очистить усю зібрану статистику запитів і не може бути скасована."
|
|
@@ -624,6 +629,9 @@ uk:
|
|
|
624
629
|
migration_warning_dev_only: "Цю операцію слід виконувати лише в локальному dev-середовищі."
|
|
625
630
|
query_execution_disabled_title: "⚠️ Виконання запитів вимкнено"
|
|
626
631
|
query_execution_disabled_intro: "Щоб увімкнути цю функцію, додайте до конфігурації:"
|
|
632
|
+
query_execution_disabled_env_note: "...або замість цього встановіть змінну середовища:"
|
|
633
|
+
run_query_title: "SQL-консоль"
|
|
634
|
+
run_query_placeholder: "SELECT * FROM users LIMIT 10;"
|
|
627
635
|
settings:
|
|
628
636
|
default_ide_label: "IDE за замовчуванням для посилань на джерела:"
|
|
629
637
|
ide_show_menu: "Показувати меню (за замовчуванням)"
|
|
@@ -656,7 +664,7 @@ uk:
|
|
|
656
664
|
cache_hit_detail: "блоки heap із кешу"
|
|
657
665
|
long_queries_label: "Довгі запити"
|
|
658
666
|
queries_unit: "запитів"
|
|
659
|
-
long_running_threshold: ">
|
|
667
|
+
long_running_threshold: "> 5с виконання"
|
|
660
668
|
blocked_label: "Заблоковано"
|
|
661
669
|
processes_unit: "процесів"
|
|
662
670
|
waiting_for_locks: "чекають блокувань"
|
|
@@ -665,6 +673,7 @@ uk:
|
|
|
665
673
|
requires_pg_stat: "🔒 Потрібен pg_stat_statements"
|
|
666
674
|
reports_count_suffix: "звітів"
|
|
667
675
|
primary_only_reason: "🔒 Ця категорія працює лише на первинній базі — вона перевіряє моделі хост-застосунку. Щоб користуватися, перемкніться на базу за замовчуванням."
|
|
676
|
+
standalone_no_host_app_reason: "🔒 Недоступно в автономному режимі — ця категорія перевіряє моделі хост-застосунку, а при самостійному запуску дашборда хост-застосунку немає."
|
|
668
677
|
documentation:
|
|
669
678
|
toggle_title: "📖 Що показує цей звіт?"
|
|
670
679
|
what_section: "📋 Що"
|
|
@@ -729,6 +738,8 @@ uk:
|
|
|
729
738
|
decode_query_failed: "Не вдалося декодувати запит"
|
|
730
739
|
explain_analyze_failed: "Не вдалося виконати EXPLAIN ANALYZE"
|
|
731
740
|
execute_query_failed: "Не вдалося виконати запит"
|
|
741
|
+
run_query_failed: "Не вдалося виконати запит"
|
|
742
|
+
query_required: "Введіть запит"
|
|
732
743
|
create_migration_failed: "Не вдалося створити міграцію"
|
|
733
744
|
explain_disabled_toast: "⚠️ EXPLAIN ANALYZE вимкнено. Увімкніть у конфігурації: config.allow_raw_query_execution = true"
|
|
734
745
|
execute_disabled_toast: "⚠️ Виконання запитів вимкнено. Увімкніть у конфігурації: config.allow_raw_query_execution = true"
|
|
@@ -736,6 +747,9 @@ uk:
|
|
|
736
747
|
query_hash_required: "Потрібен хеш запиту"
|
|
737
748
|
query_execution_disabled: "Виконання запитів із дашборду вимкнено. Увімкніть у конфігурації: 'config.allow_raw_query_execution = true'"
|
|
738
749
|
query_not_found_expired: "Запит не знайдено або термін дії минув. Оновіть сторінку."
|
|
750
|
+
query_monitor_unavailable_standalone: "SQL Query Monitor недоступний в автономному режимі — немає процесу основного застосунку для спостереження."
|
|
751
|
+
query_timed_out: "Перевищено час очікування запиту (%{ms} мс, statement_timeout). Звузьте запит або збільшіть config.raw_query_statement_timeout_ms."
|
|
752
|
+
rate_limit_exceeded: "Забагато запитів. Зачекайте трохи і спробуйте знову."
|
|
739
753
|
security_violation_prefix: "Порушення безпеки:"
|
|
740
754
|
trigger_variables_not_allowed: "Не можна виконати EXPLAIN ANALYZE для запитів із тригерними змінними (NEW, OLD). Вони доступні лише в контексті тригерних функцій."
|
|
741
755
|
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 = []
|
|
@@ -49,7 +49,7 @@ module PgReports
|
|
|
49
49
|
# List all databases visible on this target's cluster (using pg_database).
|
|
50
50
|
# Result rows: { "name" => String, "size" => String, "current" => Boolean }
|
|
51
51
|
def list_databases(current: nil)
|
|
52
|
-
rows = connection_for.exec_query(<<~SQL).to_a
|
|
52
|
+
rows = connection_for.exec_query(<<~SQL, "PgReports").to_a
|
|
53
53
|
SELECT datname AS name,
|
|
54
54
|
pg_size_pretty(pg_database_size(datname)) AS size
|
|
55
55
|
FROM pg_database
|
data/lib/pg_reports/executor.rb
CHANGED
|
@@ -17,10 +17,15 @@ module PgReports
|
|
|
17
17
|
execute(sql, **params)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
# Execute raw SQL and return results as array of hashes
|
|
20
|
+
# Execute raw SQL and return results as array of hashes.
|
|
21
|
+
#
|
|
22
|
+
# Every query is tagged with the "PgReports" AR statement name so the Query
|
|
23
|
+
# Monitor can skip our own queries by name (see QueryMonitor#should_skip?),
|
|
24
|
+
# reliably and independent of backtrace depth — the internal live_metrics /
|
|
25
|
+
# status polling would otherwise leak into the monitor's history.
|
|
21
26
|
def execute(sql, **params)
|
|
22
27
|
processed_sql = interpolate_params(sql, params)
|
|
23
|
-
result = connection.exec_query(processed_sql)
|
|
28
|
+
result = connection.exec_query(processed_sql, "PgReports")
|
|
24
29
|
result.to_a
|
|
25
30
|
end
|
|
26
31
|
|
|
@@ -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
|
{
|
|
@@ -44,13 +44,37 @@ module PgReports
|
|
|
44
44
|
false
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
#
|
|
47
|
+
# Whether the database connection can execute a basic query.
|
|
48
|
+
# Used to tell "the connection itself is down" apart from
|
|
49
|
+
# "connected, but pg_stat_statements isn't set up yet".
|
|
50
|
+
# @return [Boolean]
|
|
51
|
+
def connected?
|
|
52
|
+
executor.execute("SELECT 1")
|
|
53
|
+
true
|
|
54
|
+
rescue
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Get pg_stat_statements status details.
|
|
59
|
+
#
|
|
60
|
+
# Note: whether pg_stat_statements is in shared_preload_libraries cannot be
|
|
61
|
+
# read by a plain monitoring role (that requires the pg_read_all_settings
|
|
62
|
+
# role), so we never look at the setting. Instead we derive the state from
|
|
63
|
+
# signals every role can observe: can we run a query at all, does the
|
|
64
|
+
# extension exist in pg_extension, and is its view queryable.
|
|
65
|
+
#
|
|
48
66
|
# @return [Hash] Status information
|
|
49
67
|
def pg_stat_statements_status
|
|
68
|
+
unless connected?
|
|
69
|
+
return {connected: false, extension_installed: false, preloaded: false, ready: false}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
installed = pg_stat_statements_available?
|
|
50
73
|
{
|
|
51
|
-
|
|
74
|
+
connected: true,
|
|
75
|
+
extension_installed: installed,
|
|
52
76
|
preloaded: pg_stat_statements_preloaded?,
|
|
53
|
-
ready:
|
|
77
|
+
ready: installed && pg_stat_statements_preloaded?
|
|
54
78
|
}
|
|
55
79
|
end
|
|
56
80
|
|
|
@@ -58,7 +82,7 @@ module PgReports
|
|
|
58
82
|
# @param long_query_threshold [Integer] Threshold in seconds for long queries
|
|
59
83
|
# @return [Hash] Metrics data
|
|
60
84
|
# @raise [StandardError] If no data is returned
|
|
61
|
-
def live_metrics(long_query_threshold:
|
|
85
|
+
def live_metrics(long_query_threshold: 5)
|
|
62
86
|
data = executor.execute_from_file(:system, :live_metrics,
|
|
63
87
|
long_query_threshold: long_query_threshold)
|
|
64
88
|
|