pg_reports 0.7.0 → 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 +19 -0
- data/README.md +23 -86
- data/app/controllers/pg_reports/dashboard_controller.rb +142 -2
- 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 +3 -0
- data/lib/pg_reports/configuration.rb +32 -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/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 +53 -0
- metadata +6 -11
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 70975bd7aa15cac037bc8ac3f658c7cabe1e7492a3c37a1db588c92bce3d0f3d
|
|
4
|
+
data.tar.gz: 587f0a5474d427303c483603467155c4e0602ff604d5628c39e45d7137d1354d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3174ba9362c38d77da9a41653453de23e6c462fac8be71ceacf82b645fffb68343eb09421df8cfbab9f1100d1ca70fbca6234bc3a4450e83e5be9c35079653da
|
|
7
|
+
data.tar.gz: 70f91b168cc0890414741f75de6aea545400d738ef2a00e5a664a7555aac9bcfd923ba53ce169ca2ffa610efe9dbd95ff26902c77dcbfcf79b872421cf63e644
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.8.0] - 2026-05-01
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Multi-database dashboard.** Switch between databases on the connected cluster from a dropdown in the dashboard header — no configuration required. The selected database persists across requests in the session and applies to every report on every page.
|
|
15
|
+
- New `PgReports::Connection::Registry` with auto-registered `:primary` target derived from `ActiveRecord::Base.connection_db_config`. Existing setups need no changes.
|
|
16
|
+
- New block-scoped APIs: `PgReports.with_target(:name, database: ...)`, `PgReports.with_database("name")`. Honored by `Executor` even when memoized inside modules — the connection is now resolved on every call rather than at construction.
|
|
17
|
+
- New helpers: `PgReports.list_databases`, `PgReports.list_targets`, `PgReports.current_target_name`, `PgReports.current_database_name`.
|
|
18
|
+
- New `Configuration#add_target(name, spec)` and `default_target=` for explicitly registering additional targets (host/port/user/database) when the dashboard should reach databases the host app cannot.
|
|
19
|
+
- Database switching opens an isolated AR connection pool per `(target, database)` so the host application's pool is never disturbed; the primary target's default database keeps using `ActiveRecord::Base` directly.
|
|
20
|
+
- **Human-readable connection errors.** `PgReports::Connection::ErrorTranslator` maps `PG::Error` SQLSTATEs (`42501`, `3D000`, `28P01`, `08006`, `53300`) and AR-wrapped variants into a `{ title, detail, hint, code }` hash. Permission errors include a concrete `GRANT ...` remediation hint. The dashboard renders the translation as a banner on the index when it can't list databases.
|
|
21
|
+
- **Schema Analysis category gated to the primary target.** When the dashboard is pointed at a non-primary database (where the host app's models don't apply), the Schema Analysis category is greyed out with an explanation, and direct URL access redirects to the index with a flash message. Same gating extends to the JSON endpoints (`run`, `download`, `send_to_telegram`).
|
|
22
|
+
- **Configuration reference** moved to [docs/configuration.md](docs/configuration.md).
|
|
23
|
+
|
|
24
|
+
### Security
|
|
25
|
+
|
|
26
|
+
- **CSRF protection** is now enforced on the dashboard controller (`protect_from_forgery with: :exception`). Previously the controller inherited from `ActionController::Base` without opting in — every state-changing endpoint (`switch_database`, `switch_target`, `execute_query`, `explain_analyze`, `create_migration`, `reset_statistics`, telegram delivery, query_monitor start/stop) was reachable cross-origin from any logged-in user's browser. The dashboard already shipped `authenticity_token` in forms and `X-CSRF-Token` in XHR — only the server enforcement was missing.
|
|
27
|
+
- **`create_migration` is now opt-in via `config.allow_migration_creation`** (default `Rails.env.development?` to preserve prior behavior; toggleable via `PG_REPORTS_ALLOW_MIGRATION_CREATION`). The endpoint writes Ruby code into `db/migrate/`. With CSRF fixed and a denied-by-default flag, a dashboard exposed without auth no longer trivially leads to RCE on next `rails db:migrate`. Combine with `dashboard_auth` for layered defense.
|
|
28
|
+
|
|
10
29
|
## [0.7.0] - 2026-04-26
|
|
11
30
|
|
|
12
31
|
### Added
|
data/README.md
CHANGED
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
[](https://rubyonrails.org/)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
7
|
|
|
8
|
-
A comprehensive PostgreSQL monitoring and analysis library for Rails applications. Get insights into query performance, index usage, table statistics, connection health, and more. Includes a beautiful web dashboard and Telegram
|
|
8
|
+
A comprehensive PostgreSQL monitoring and analysis library for Rails applications. Get insights into query performance, index usage, table statistics, connection health, and more — across **every database on the cluster**, switchable from the dashboard with no extra configuration. Includes a beautiful web dashboard, a Grafana / Prometheus exporter, and Telegram delivery.
|
|
9
9
|
|
|
10
10
|

|
|
11
11
|
|
|
12
12
|
## Features
|
|
13
13
|
|
|
14
|
+
- 🗄️ **Multi-database** - Auto-discovers every database on the cluster and lets you switch from a dropdown in the dashboard. No configuration required.
|
|
14
15
|
- 📊 **Query Analysis** - Identify slow, heavy, and expensive queries using `pg_stat_statements`
|
|
15
16
|
- 📇 **Index Analysis** - Find unused, duplicate, invalid, and missing indexes
|
|
16
17
|
- 📋 **Table Statistics** - Monitor table sizes, bloat, vacuum needs, and cache hit ratios
|
|
@@ -58,7 +59,7 @@ end
|
|
|
58
59
|
|
|
59
60
|
Visit `http://localhost:3000/pg_reports`.
|
|
60
61
|
|
|
61
|
-
For query analysis, also enable `pg_stat_statements` — see [setup](#pg_stat_statements-setup)
|
|
62
|
+
For query analysis, also enable `pg_stat_statements` — see [setup instructions in docs/configuration.md](docs/configuration.md#pg_stat_statements-setup).
|
|
62
63
|
|
|
63
64
|
## Usage
|
|
64
65
|
|
|
@@ -79,107 +80,43 @@ PgReports.slow_queries.send_to_telegram
|
|
|
79
80
|
|
|
80
81
|
**[Full list of reports →](docs/reports.md)**
|
|
81
82
|
|
|
83
|
+
## Multi-database
|
|
84
|
+
|
|
85
|
+
The dashboard auto-discovers every database on the cluster you're connected to and shows a dropdown next to the *Status* panel. Switching is zero-config — credentials and host come from your existing `database.yml`. Schema-analysis reports stay scoped to the primary database (they introspect the host app's models); the dropdown greys them out elsewhere.
|
|
86
|
+
|
|
87
|
+
Programmatic access:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
PgReports.with_database("logs") { PgReports.table_sizes }
|
|
91
|
+
PgReports.with_target(:analytics) { PgReports.slow_queries }
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
For multi-cluster setups (separate analytics warehouse, replica with different credentials, etc.), register additional targets explicitly. **[Multi-database reference in docs/configuration.md →](docs/configuration.md#multi-database-support)**
|
|
95
|
+
|
|
82
96
|
## Configuration
|
|
83
97
|
|
|
98
|
+
PgReports works out of the box once mounted. Common options:
|
|
99
|
+
|
|
84
100
|
```ruby
|
|
85
101
|
# config/initializers/pg_reports.rb
|
|
86
102
|
PgReports.configure do |config|
|
|
87
|
-
|
|
88
|
-
config.
|
|
89
|
-
config.telegram_chat_id = ENV["PG_REPORTS_TELEGRAM_CHAT_ID"]
|
|
90
|
-
|
|
91
|
-
# Thresholds
|
|
103
|
+
config.telegram_bot_token = ENV["PG_REPORTS_TELEGRAM_TOKEN"]
|
|
104
|
+
config.telegram_chat_id = ENV["PG_REPORTS_TELEGRAM_CHAT_ID"]
|
|
92
105
|
config.slow_query_threshold_ms = 100
|
|
93
|
-
config.heavy_query_threshold_calls = 1000
|
|
94
|
-
config.expensive_query_threshold_ms = 10_000
|
|
95
106
|
config.unused_index_threshold_scans = 50
|
|
96
107
|
config.bloat_threshold_percent = 20
|
|
97
|
-
config.dead_rows_threshold = 10_000
|
|
98
108
|
|
|
99
|
-
#
|
|
100
|
-
config.max_query_length = 200
|
|
101
|
-
|
|
102
|
-
# Auth (optional)
|
|
109
|
+
# Strongly recommended in production
|
|
103
110
|
config.dashboard_auth = -> {
|
|
104
111
|
authenticate_or_request_with_http_basic do |user, pass|
|
|
105
112
|
user == ENV["PG_REPORTS_USER"] && pass == ENV["PG_REPORTS_PASSWORD"]
|
|
106
113
|
end
|
|
107
114
|
}
|
|
108
|
-
|
|
109
|
-
# Google Fonts (default: false — no external requests)
|
|
110
|
-
config.load_external_fonts = false
|
|
111
115
|
end
|
|
112
116
|
```
|
|
113
117
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
PgReports follows your application's `I18n.locale`. Set it the way you set it for the rest of the app — there's no PgReports-specific knob. The dashboard supports `en`, `ru`, and `uk` out of the box.
|
|
118
|
-
|
|
119
|
-
</details>
|
|
120
|
-
|
|
121
|
-
<details>
|
|
122
|
-
<summary><strong>Raw query execution (EXPLAIN ANALYZE / Execute Query)</strong></summary>
|
|
123
|
-
|
|
124
|
-
⚠️ Disabled by default. The dashboard's "Execute Query" and "EXPLAIN ANALYZE" buttons require this opt-in.
|
|
125
|
-
|
|
126
|
-
```ruby
|
|
127
|
-
PgReports.configure do |config|
|
|
128
|
-
config.allow_raw_query_execution = Rails.env.development? || Rails.env.staging?
|
|
129
|
-
end
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
</details>
|
|
133
|
-
|
|
134
|
-
<details>
|
|
135
|
-
<summary><strong>Query source tracking (Rails query logs)</strong></summary>
|
|
136
|
-
|
|
137
|
-
PgReports parses query annotations to show **where queries originated**. On Rails 7.0+ use the built-in `ActiveRecord::QueryLogs` (no extra gem needed). On older Rails, install [Marginalia](https://github.com/basecamp/marginalia) — PgReports auto-detects both formats.
|
|
138
|
-
|
|
139
|
-
Minimal setup — adds controller/action:
|
|
140
|
-
|
|
141
|
-
```ruby
|
|
142
|
-
# config/application.rb
|
|
143
|
-
config.active_record.query_log_tags_enabled = true
|
|
144
|
-
config.active_record.query_log_tags = [:controller, :action]
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
To also surface **file path and line number** (so source links jump to the actual call site, not just the controller), add a custom `source_location` lambda that walks `caller_locations` and skips gem/framework frames:
|
|
148
|
-
|
|
149
|
-
```ruby
|
|
150
|
-
# config/application.rb
|
|
151
|
-
config.active_record.query_log_tags_enabled = true
|
|
152
|
-
config.active_record.query_log_tags = [
|
|
153
|
-
:controller,
|
|
154
|
-
:action,
|
|
155
|
-
:job,
|
|
156
|
-
{
|
|
157
|
-
source_location: -> {
|
|
158
|
-
ignore = %r{/(gems|active_record|active_support|active_model|railties|
|
|
159
|
-
action_controller|action_view|action_pack|action_dispatch|
|
|
160
|
-
rack|core_ext|relation|associations|scoping|connection_adapters)/}x
|
|
161
|
-
loc = caller_locations.find { |l| !l.path.match?(ignore) }
|
|
162
|
-
"#{loc.path}:#{loc.lineno}" if loc
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
]
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
PgReports recognizes the `source_location` tag and splits it into file and line for the **source** column.
|
|
169
|
-
|
|
170
|
-
</details>
|
|
171
|
-
|
|
172
|
-
## pg_stat_statements setup
|
|
173
|
-
|
|
174
|
-
1. Edit `postgresql.conf`:
|
|
175
|
-
```
|
|
176
|
-
shared_preload_libraries = 'pg_stat_statements'
|
|
177
|
-
pg_stat_statements.track = all
|
|
178
|
-
```
|
|
179
|
-
2. Restart PostgreSQL: `sudo systemctl restart postgresql`
|
|
180
|
-
3. Create the extension (via dashboard button or `PgReports.enable_pg_stat_statements!`).
|
|
181
|
-
|
|
182
|
-
> PgReports does **not** require the `pg_read_all_settings` role — extension availability is detected directly. Works with CloudnativePG, managed databases, and other restricted environments.
|
|
118
|
+
**Multi-database, thresholds, query monitor, Grafana, raw query execution, source tracking, locale, Telegram —**
|
|
119
|
+
**[full reference in docs/configuration.md →](docs/configuration.md)**
|
|
183
120
|
|
|
184
121
|
## Report object
|
|
185
122
|
|
|
@@ -4,14 +4,59 @@ module PgReports
|
|
|
4
4
|
class DashboardController < ActionController::Base
|
|
5
5
|
layout "pg_reports/application"
|
|
6
6
|
|
|
7
|
+
# CSRF protection. ActionController::Base does NOT enforce this by default;
|
|
8
|
+
# we opt in explicitly because every state-changing endpoint here
|
|
9
|
+
# (switch_*, execute_query, create_migration, reset_statistics, telegram
|
|
10
|
+
# delivery, query_monitor start/stop) is sensitive. The dashboard already
|
|
11
|
+
# ships authenticity_token in forms and X-CSRF-Token in XHR.
|
|
12
|
+
protect_from_forgery with: :exception
|
|
13
|
+
|
|
7
14
|
before_action :authenticate_dashboard!, if: -> { PgReports.config.dashboard_auth.present? }
|
|
8
15
|
before_action :set_categories
|
|
16
|
+
before_action :resolve_database_selection
|
|
17
|
+
around_action :within_selected_database
|
|
18
|
+
|
|
19
|
+
helper_method :category_disabled_reason, :category_disabled?
|
|
9
20
|
|
|
10
21
|
def index
|
|
11
22
|
@pg_stat_status = PgReports.pg_stat_statements_status
|
|
12
23
|
@current_database = PgReports.system.current_database
|
|
13
24
|
end
|
|
14
25
|
|
|
26
|
+
# POST /switch_database
|
|
27
|
+
# Persists the chosen database in session and redirects back. The actual
|
|
28
|
+
# connection switch happens on the next request via #within_selected_database.
|
|
29
|
+
def switch_database
|
|
30
|
+
requested = params[:database].to_s
|
|
31
|
+
|
|
32
|
+
if requested.empty?
|
|
33
|
+
session.delete(:pg_reports_database)
|
|
34
|
+
elsif valid_database?(requested)
|
|
35
|
+
session[:pg_reports_database] = requested
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
redirect_back fallback_location: root_path
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# POST /switch_target
|
|
42
|
+
# Persists the chosen target in session, clears the database choice (each
|
|
43
|
+
# target has its own list of databases), and redirects back.
|
|
44
|
+
def switch_target
|
|
45
|
+
requested = params[:target].to_s
|
|
46
|
+
|
|
47
|
+
if requested.empty?
|
|
48
|
+
session.delete(:pg_reports_target)
|
|
49
|
+
session.delete(:pg_reports_database)
|
|
50
|
+
elsif PgReports.connection_registry.target?(requested)
|
|
51
|
+
session[:pg_reports_target] = requested
|
|
52
|
+
# Database list is target-specific; reset so the next request picks the
|
|
53
|
+
# new target's default rather than carrying a stale name.
|
|
54
|
+
session.delete(:pg_reports_database)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
redirect_back fallback_location: root_path
|
|
58
|
+
end
|
|
59
|
+
|
|
15
60
|
def enable_pg_stat_statements
|
|
16
61
|
result = PgReports.enable_pg_stat_statements!
|
|
17
62
|
render json: result
|
|
@@ -72,6 +117,12 @@ module PgReports
|
|
|
72
117
|
return
|
|
73
118
|
end
|
|
74
119
|
|
|
120
|
+
reason = category_disabled_reason(@category)
|
|
121
|
+
if reason
|
|
122
|
+
redirect_to root_path, alert: reason
|
|
123
|
+
return
|
|
124
|
+
end
|
|
125
|
+
|
|
75
126
|
# Get documentation for the report
|
|
76
127
|
@documentation = Dashboard::ReportsRegistry.documentation(@report_key)
|
|
77
128
|
@thresholds = Dashboard::ReportsRegistry.thresholds(@report_key)
|
|
@@ -322,8 +373,7 @@ module PgReports
|
|
|
322
373
|
end
|
|
323
374
|
|
|
324
375
|
def create_migration
|
|
325
|
-
|
|
326
|
-
unless Rails.env.development?
|
|
376
|
+
unless PgReports.config.allow_migration_creation
|
|
327
377
|
render json: {
|
|
328
378
|
success: false,
|
|
329
379
|
error: I18n.t("pg_reports.ui.errors.migration_dev_only")
|
|
@@ -492,6 +542,68 @@ module PgReports
|
|
|
492
542
|
@categories = Dashboard::ReportsRegistry.all
|
|
493
543
|
end
|
|
494
544
|
|
|
545
|
+
# Resolves which target/database every action should run against, based on:
|
|
546
|
+
# 1. session[:pg_reports_target] (set by #switch_target)
|
|
547
|
+
# 2. session[:pg_reports_database] (set by #switch_database)
|
|
548
|
+
# Falls back to the registry's default target and that target's default
|
|
549
|
+
# database when either is missing or invalid.
|
|
550
|
+
#
|
|
551
|
+
# Exposes @selected_target / @available_targets / @selected_database /
|
|
552
|
+
# @available_databases / @target_default_database / @database_error for the
|
|
553
|
+
# layout to render the selectors. Connection errors are swallowed so the
|
|
554
|
+
# dashboard still renders with a banner instead of a 500.
|
|
555
|
+
def resolve_database_selection
|
|
556
|
+
registry = PgReports.connection_registry
|
|
557
|
+
|
|
558
|
+
# Target resolution (must come first — database list depends on it)
|
|
559
|
+
@available_targets = registry.targets
|
|
560
|
+
requested_target = session[:pg_reports_target].to_s
|
|
561
|
+
@selected_target = if requested_target.present? && registry.target?(requested_target)
|
|
562
|
+
requested_target.to_sym
|
|
563
|
+
else
|
|
564
|
+
registry.default_name
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
# Resolve database list under the chosen target so list_databases hits
|
|
568
|
+
# the right cluster.
|
|
569
|
+
@available_databases = []
|
|
570
|
+
@database_error = nil
|
|
571
|
+
@target_default_database = registry.fetch(@selected_target).default_database
|
|
572
|
+
|
|
573
|
+
begin
|
|
574
|
+
registry.with_context(target: @selected_target) do
|
|
575
|
+
@available_databases = registry.fetch(@selected_target).list_databases(current: @target_default_database)
|
|
576
|
+
end
|
|
577
|
+
rescue => e
|
|
578
|
+
@database_error = PgReports::Connection::ErrorTranslator.translate(e)
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
requested_database = session[:pg_reports_database].to_s
|
|
582
|
+
@selected_database = if requested_database.present? && @available_databases.any? { |d| d["name"] == requested_database }
|
|
583
|
+
requested_database
|
|
584
|
+
else
|
|
585
|
+
@target_default_database
|
|
586
|
+
end
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def within_selected_database
|
|
590
|
+
database_override = (@selected_database && @selected_database != @target_default_database) ? @selected_database : nil
|
|
591
|
+
target_override = (@selected_target && @selected_target != PgReports.connection_registry.default_name) ? @selected_target : nil
|
|
592
|
+
|
|
593
|
+
if target_override || database_override
|
|
594
|
+
PgReports.with_target(target_override || PgReports.connection_registry.default_name,
|
|
595
|
+
database: database_override) { yield }
|
|
596
|
+
else
|
|
597
|
+
yield
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def valid_database?(name)
|
|
602
|
+
# Reuse the list already fetched in #resolve_database_selection so the
|
|
603
|
+
# switch_database POST hits pg_database once per request, not twice.
|
|
604
|
+
Array(@available_databases).any? { |row| row["name"] == name }
|
|
605
|
+
end
|
|
606
|
+
|
|
495
607
|
def load_report_filters(category, report_key)
|
|
496
608
|
definition = ReportLoader.get(category.to_s, report_key.to_s)
|
|
497
609
|
return {} unless definition
|
|
@@ -530,6 +642,9 @@ module PgReports
|
|
|
530
642
|
end
|
|
531
643
|
|
|
532
644
|
def execute_report(category, report_key, **filter_params)
|
|
645
|
+
reason = category_disabled_reason(category)
|
|
646
|
+
raise ArgumentError, reason if reason
|
|
647
|
+
|
|
533
648
|
mod = case category
|
|
534
649
|
when :queries then Modules::Queries
|
|
535
650
|
when :indexes then Modules::Indexes
|
|
@@ -547,6 +662,31 @@ module PgReports
|
|
|
547
662
|
mod.public_send(report_key, **filter_params)
|
|
548
663
|
end
|
|
549
664
|
|
|
665
|
+
# nil if the category is available in the current target/database context,
|
|
666
|
+
# otherwise a localized string explaining why it is disabled. Exposed to
|
|
667
|
+
# views via helper_method.
|
|
668
|
+
def category_disabled_reason(category)
|
|
669
|
+
constraint = Dashboard::ReportsRegistry.target_constraint(category)
|
|
670
|
+
return nil unless constraint == :primary_default_database_only
|
|
671
|
+
return nil if on_primary_default_database?
|
|
672
|
+
|
|
673
|
+
I18n.t("pg_reports.ui.categories.primary_only_reason",
|
|
674
|
+
default: "This report category only runs on the primary database " \
|
|
675
|
+
"because it inspects the host application's models. Switch back to " \
|
|
676
|
+
"the default database to use it.")
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
def category_disabled?(category)
|
|
680
|
+
category_disabled_reason(category).present?
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
def on_primary_default_database?
|
|
684
|
+
return true if @target_default_database.nil?
|
|
685
|
+
|
|
686
|
+
@selected_target == PgReports.connection_registry.default_name &&
|
|
687
|
+
@selected_database == @target_default_database
|
|
688
|
+
end
|
|
689
|
+
|
|
550
690
|
def substitute_params(query, params_hash)
|
|
551
691
|
result = query.dup
|
|
552
692
|
|
|
@@ -633,6 +633,16 @@
|
|
|
633
633
|
font-size: 0.9rem;
|
|
634
634
|
}
|
|
635
635
|
|
|
636
|
+
.query-monitoring-scope {
|
|
637
|
+
font-weight: 400;
|
|
638
|
+
font-size: 0.75rem;
|
|
639
|
+
color: var(--text-muted);
|
|
640
|
+
background: var(--bg-tertiary);
|
|
641
|
+
padding: 0.125rem 0.5rem;
|
|
642
|
+
border-radius: 999px;
|
|
643
|
+
cursor: help;
|
|
644
|
+
}
|
|
645
|
+
|
|
636
646
|
.monitoring-indicator {
|
|
637
647
|
width: 7px;
|
|
638
648
|
height: 7px;
|
|
@@ -989,6 +999,121 @@
|
|
|
989
999
|
.modal-small {
|
|
990
1000
|
max-width: 360px;
|
|
991
1001
|
}
|
|
1002
|
+
|
|
1003
|
+
/* Database selector — used inside .live-monitoring-title and .breadcrumb.
|
|
1004
|
+
Sizes to fit its longest option (no artificial cap), capped at the
|
|
1005
|
+
parent's available width so it never overruns neighboring controls. */
|
|
1006
|
+
.db-selector-form {
|
|
1007
|
+
display: inline-flex;
|
|
1008
|
+
align-items: center;
|
|
1009
|
+
gap: 0.5rem;
|
|
1010
|
+
margin: 0;
|
|
1011
|
+
max-width: 100%;
|
|
1012
|
+
min-width: 0;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
.db-selector-label {
|
|
1016
|
+
font-size: 0.75rem;
|
|
1017
|
+
color: var(--text-secondary);
|
|
1018
|
+
text-transform: uppercase;
|
|
1019
|
+
letter-spacing: 0.5px;
|
|
1020
|
+
font-weight: 600;
|
|
1021
|
+
white-space: nowrap;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
.db-selector {
|
|
1025
|
+
background: var(--bg-card, rgba(255, 255, 255, 0.04));
|
|
1026
|
+
color: var(--text-primary);
|
|
1027
|
+
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
|
|
1028
|
+
border-radius: 6px;
|
|
1029
|
+
padding: 0.375rem 0.625rem;
|
|
1030
|
+
font-size: 0.875rem;
|
|
1031
|
+
cursor: pointer;
|
|
1032
|
+
width: auto;
|
|
1033
|
+
max-width: 100%;
|
|
1034
|
+
min-width: 0;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
.db-selector option.is-default {
|
|
1038
|
+
font-weight: 700;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
.db-selector:hover {
|
|
1042
|
+
border-color: var(--accent-blue, #6b9fe8);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
.db-selector:focus {
|
|
1046
|
+
outline: none;
|
|
1047
|
+
border-color: var(--accent-blue, #6b9fe8);
|
|
1048
|
+
box-shadow: 0 0 0 2px rgba(107, 159, 232, 0.2);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
.db-selector-static {
|
|
1052
|
+
display: inline-flex;
|
|
1053
|
+
align-items: center;
|
|
1054
|
+
gap: 0.5rem;
|
|
1055
|
+
font-size: 0.875rem;
|
|
1056
|
+
color: var(--text-secondary);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
.db-selector-static strong {
|
|
1060
|
+
color: var(--text-primary);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
.db-selector-error {
|
|
1064
|
+
display: inline-flex;
|
|
1065
|
+
align-items: center;
|
|
1066
|
+
gap: 0.375rem;
|
|
1067
|
+
padding: 0.375rem 0.625rem;
|
|
1068
|
+
background: rgba(232, 107, 107, 0.1);
|
|
1069
|
+
border: 1px solid rgba(232, 107, 107, 0.3);
|
|
1070
|
+
border-radius: 6px;
|
|
1071
|
+
font-size: 0.8125rem;
|
|
1072
|
+
color: var(--accent-red, #e86b6b);
|
|
1073
|
+
cursor: help;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
.breadcrumb-spacer {
|
|
1077
|
+
flex: 1;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
.breadcrumb {
|
|
1081
|
+
display: flex;
|
|
1082
|
+
align-items: center;
|
|
1083
|
+
gap: 0.5rem;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
.db-error-banner {
|
|
1087
|
+
background: rgba(232, 107, 107, 0.08);
|
|
1088
|
+
border: 1px solid rgba(232, 107, 107, 0.3);
|
|
1089
|
+
border-radius: 8px;
|
|
1090
|
+
padding: 0.875rem 1.125rem;
|
|
1091
|
+
margin-bottom: 1rem;
|
|
1092
|
+
display: flex;
|
|
1093
|
+
flex-direction: column;
|
|
1094
|
+
gap: 0.375rem;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
.db-error-banner strong {
|
|
1098
|
+
color: var(--accent-red, #e86b6b);
|
|
1099
|
+
font-size: 0.9375rem;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
.db-error-banner span {
|
|
1103
|
+
font-size: 0.875rem;
|
|
1104
|
+
color: var(--text-secondary);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
.db-error-banner code {
|
|
1108
|
+
font-family: ui-monospace, SFMono-Regular, monospace;
|
|
1109
|
+
font-size: 0.8125rem;
|
|
1110
|
+
background: rgba(0, 0, 0, 0.25);
|
|
1111
|
+
padding: 0.375rem 0.625rem;
|
|
1112
|
+
border-radius: 4px;
|
|
1113
|
+
color: var(--text-primary);
|
|
1114
|
+
display: inline-block;
|
|
1115
|
+
width: fit-content;
|
|
1116
|
+
}
|
|
992
1117
|
</style>
|
|
993
1118
|
</head>
|
|
994
1119
|
<body>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<%# locals: (show_label: true) %>
|
|
2
|
+
<% if @available_databases.present? && @available_databases.size > 1 %>
|
|
3
|
+
<form class="db-selector-form"
|
|
4
|
+
action="<%= switch_database_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-db-select">
|
|
10
|
+
<%= t("pg_reports.ui.database_selector.label", default: "Database") %>
|
|
11
|
+
</label>
|
|
12
|
+
<% end %>
|
|
13
|
+
<select id="pg-reports-db-select"
|
|
14
|
+
name="database"
|
|
15
|
+
class="db-selector"
|
|
16
|
+
onchange="this.form.submit()">
|
|
17
|
+
<% @available_databases.each do |row| %>
|
|
18
|
+
<option value="<%= row["name"] %>"
|
|
19
|
+
class="<%= "is-default" if row["name"] == @target_default_database %>"
|
|
20
|
+
<%= "selected" if row["name"] == @selected_database %>>
|
|
21
|
+
<%= row["name"] %><%= " (#{row["size"]})" if row["size"].present? %>
|
|
22
|
+
</option>
|
|
23
|
+
<% end %>
|
|
24
|
+
</select>
|
|
25
|
+
</form>
|
|
26
|
+
<% elsif @available_databases.present? && @available_databases.size == 1 %>
|
|
27
|
+
<span class="db-selector-static">
|
|
28
|
+
<% if local_assigns.fetch(:show_label, true) %>
|
|
29
|
+
<span class="db-selector-label">
|
|
30
|
+
<%= t("pg_reports.ui.database_selector.label", default: "Database") %>:
|
|
31
|
+
</span>
|
|
32
|
+
<% end %>
|
|
33
|
+
<strong><%= @available_databases.first["name"] %></strong>
|
|
34
|
+
</span>
|
|
35
|
+
<% elsif @database_error %>
|
|
36
|
+
<span class="db-selector-error" title="<%= @database_error[:detail] %>">
|
|
37
|
+
⚠ <%= @database_error[:title] %>
|
|
38
|
+
</span>
|
|
39
|
+
<% end %>
|
|
@@ -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 %>
|