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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 67cc51703472362dd188536abd8196660d5e4bb928d9f9ee53d7e2939290772e
4
- data.tar.gz: 99fe7733e13344ac1e6b0a9a981728525005437a36e6c17846da455de5365a9b
3
+ metadata.gz: 70975bd7aa15cac037bc8ac3f658c7cabe1e7492a3c37a1db588c92bce3d0f3d
4
+ data.tar.gz: 587f0a5474d427303c483603467155c4e0602ff604d5628c39e45d7137d1354d
5
5
  SHA512:
6
- metadata.gz: f408472b95f858f7770511b89885afd98b14074ee5fa3ed67c75ae36b29cb34a96adf1ac17ec0f6cf69bb9cbb51843def46483ff60ee121e2317d930da23deaf
7
- data.tar.gz: fb4760d0e84fbe5a29b157cb37ee765454e7a26863a01b64aee1ba4262d83b28272e16664b7e797981f72594deb192e7f1a22a0c93ef6fb1399d584932446d91
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
  [![Rails](https://img.shields.io/badge/Rails-5.0%2B-red.svg)](https://rubyonrails.org/)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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 integration for notifications.
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
  ![Dashboard Screenshot](docs/dashboard.png)
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) below.
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
- # Telegram (optional)
88
- config.telegram_bot_token = ENV["PG_REPORTS_TELEGRAM_TOKEN"]
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
- # Output
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
- <details>
115
- <summary><strong>Locale (EN / RU / UK)</strong></summary>
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
- # Only allow migration creation in development environment
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 %>