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
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgReports
|
|
4
|
+
module Connection
|
|
5
|
+
# Represents a single named PostgreSQL target (host+credentials).
|
|
6
|
+
# Holds a memoized AR-subclass-backed connection per database it has been
|
|
7
|
+
# asked for, so switching between databases on the same target reuses pools.
|
|
8
|
+
#
|
|
9
|
+
# The :primary target wraps ActiveRecord::Base directly when accessed at its
|
|
10
|
+
# default database — we don't open a parallel pool to the host app's DB.
|
|
11
|
+
# For non-default databases we create an isolated AR subclass that has its
|
|
12
|
+
# own pool, so the host application's pool is never affected.
|
|
13
|
+
class Target
|
|
14
|
+
class ConnectionFailed < PgReports::Error; end
|
|
15
|
+
|
|
16
|
+
attr_reader :name
|
|
17
|
+
|
|
18
|
+
def initialize(name, spec)
|
|
19
|
+
@name = name.to_sym
|
|
20
|
+
@spec = normalize_spec(spec)
|
|
21
|
+
@pools = {} # database (string) => AR class
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Configuration hash used as a template for derived databases.
|
|
25
|
+
def spec
|
|
26
|
+
@spec.dup
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def default_database
|
|
30
|
+
@spec[:database]&.to_s
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Resolve the AR class backing the connection for `database` (nil = default).
|
|
34
|
+
# Returns a class responding to `.connection` (ActiveRecord::Base or subclass).
|
|
35
|
+
def ar_class_for(database = nil)
|
|
36
|
+
db = (database || default_database).to_s
|
|
37
|
+
raise ArgumentError, "Cannot resolve connection: target #{name.inspect} has no default database and none was given" if db.empty?
|
|
38
|
+
|
|
39
|
+
@pools[db] ||= build_pool_class(db)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns the active PG connection for `database`, opening it if needed.
|
|
43
|
+
def connection_for(database = nil)
|
|
44
|
+
ar_class_for(database).connection
|
|
45
|
+
rescue ActiveRecord::NoDatabaseError, PG::ConnectionBad => e
|
|
46
|
+
raise ConnectionFailed, "Cannot connect to #{name}/#{database || default_database}: #{e.message}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# List all databases visible on this target's cluster (using pg_database).
|
|
50
|
+
# Result rows: { "name" => String, "size" => String, "current" => Boolean }
|
|
51
|
+
def list_databases(current: nil)
|
|
52
|
+
rows = connection_for.exec_query(<<~SQL).to_a
|
|
53
|
+
SELECT datname AS name,
|
|
54
|
+
pg_size_pretty(pg_database_size(datname)) AS size
|
|
55
|
+
FROM pg_database
|
|
56
|
+
WHERE datistemplate = false AND datallowconn = true
|
|
57
|
+
ORDER BY datname
|
|
58
|
+
SQL
|
|
59
|
+
current_db = (current || default_database).to_s
|
|
60
|
+
rows.each { |r| r["current"] = (r["name"].to_s == current_db) }
|
|
61
|
+
rows
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Close all derived pools we own. The :primary AR::Base pool is never touched.
|
|
65
|
+
def disconnect!
|
|
66
|
+
@pools.each_value do |klass|
|
|
67
|
+
next if klass.equal?(ActiveRecord::Base)
|
|
68
|
+
klass.connection_pool.disconnect! if klass.connection_pool.connected?
|
|
69
|
+
rescue
|
|
70
|
+
# Best-effort cleanup
|
|
71
|
+
end
|
|
72
|
+
@pools.clear
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def normalize_spec(spec)
|
|
78
|
+
hash = case spec
|
|
79
|
+
when Hash
|
|
80
|
+
spec.transform_keys(&:to_sym)
|
|
81
|
+
when ActiveRecord::DatabaseConfigurations::HashConfig
|
|
82
|
+
spec.configuration_hash.transform_keys(&:to_sym)
|
|
83
|
+
else
|
|
84
|
+
raise ArgumentError, "Unsupported spec type: #{spec.class}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Ensure adapter defaults to postgresql; we only support PG.
|
|
88
|
+
hash[:adapter] ||= "postgresql"
|
|
89
|
+
hash
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def build_pool_class(database)
|
|
93
|
+
# When asked for the primary target's default database, reuse AR::Base
|
|
94
|
+
# so we don't open a parallel pool to the same DB the host app uses.
|
|
95
|
+
if name == :primary && database == default_database
|
|
96
|
+
return ActiveRecord::Base
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
klass = Class.new(ActiveRecord::Base) { self.abstract_class = true }
|
|
100
|
+
const_name = "Pool_#{name}_#{database}".gsub(/\W/, "_")
|
|
101
|
+
if PgReports::Connection.const_defined?(const_name, false)
|
|
102
|
+
PgReports::Connection.send(:remove_const, const_name)
|
|
103
|
+
end
|
|
104
|
+
PgReports::Connection.const_set(const_name, klass)
|
|
105
|
+
|
|
106
|
+
klass.establish_connection(@spec.merge(database: database))
|
|
107
|
+
klass
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -277,8 +277,8 @@ module PgReports
|
|
|
277
277
|
seq_scans: {name: "Sequential Scans", description: "Tables with high sequential scans"},
|
|
278
278
|
tables_without_pk: {name: "No Primary Key", description: "Tables missing primary keys"},
|
|
279
279
|
recently_modified: {name: "Recently Modified", description: "Tables with recent activity"},
|
|
280
|
-
update_hotspots: {name: "Update Hotspots", description: "Same rows or indexed columns updated repeatedly"
|
|
281
|
-
unused_tables: {name: "Unused Tables", description: "Tables never queried since the last stats reset"
|
|
280
|
+
update_hotspots: {name: "Update Hotspots", description: "Same rows or indexed columns updated repeatedly"},
|
|
281
|
+
unused_tables: {name: "Unused Tables", description: "Tables never queried since the last stats reset"}
|
|
282
282
|
}
|
|
283
283
|
},
|
|
284
284
|
connections: {
|
|
@@ -316,14 +316,19 @@ module PgReports
|
|
|
316
316
|
name: "Schema Analysis",
|
|
317
317
|
icon: "🔍",
|
|
318
318
|
color: "#06b6d4",
|
|
319
|
+
# These reports introspect the host application's ActiveRecord models,
|
|
320
|
+
# which are bound to the default database. Running them against a
|
|
321
|
+
# different database in the cluster returns rows that may not map to
|
|
322
|
+
# any model. The dashboard greys the category out in that case.
|
|
323
|
+
target_constraint: :primary_default_database_only,
|
|
319
324
|
reports: {
|
|
320
325
|
missing_validations: {name: "Missing Validations", description: "Unique indexes without model validations"},
|
|
321
|
-
unused_columns: {name: "Unused Columns", description: "Columns that have only ever held a single value"
|
|
322
|
-
always_null_columns: {name: "Always-NULL Columns", description: "Nullable columns that contain only NULL"
|
|
323
|
-
polymorphic_without_index: {name: "Polymorphic Without Index", description: "Polymorphic associations missing composite index"
|
|
324
|
-
counter_cache_issues: {name: "Counter Cache Issues", description: "counter_cache declarations whose target column is missing"
|
|
325
|
-
soft_delete_without_scope: {name: "Soft Delete Without Scope", description: "Soft-delete columns with no model scope filtering them"
|
|
326
|
-
orphan_tables: {name: "Orphan Tables", description: "DB tables without a corresponding Rails model"
|
|
326
|
+
unused_columns: {name: "Unused Columns", description: "Columns that have only ever held a single value"},
|
|
327
|
+
always_null_columns: {name: "Always-NULL Columns", description: "Nullable columns that contain only NULL"},
|
|
328
|
+
polymorphic_without_index: {name: "Polymorphic Without Index", description: "Polymorphic associations missing composite index"},
|
|
329
|
+
counter_cache_issues: {name: "Counter Cache Issues", description: "counter_cache declarations whose target column is missing"},
|
|
330
|
+
soft_delete_without_scope: {name: "Soft Delete Without Scope", description: "Soft-delete columns with no model scope filtering them"},
|
|
331
|
+
orphan_tables: {name: "Orphan Tables", description: "DB tables without a corresponding Rails model"}
|
|
327
332
|
}
|
|
328
333
|
}
|
|
329
334
|
}.freeze
|
|
@@ -398,6 +403,15 @@ module PgReports
|
|
|
398
403
|
def self.problem_fields(report)
|
|
399
404
|
REPORT_CONFIG.dig(report.to_sym, :problem_fields) || []
|
|
400
405
|
end
|
|
406
|
+
|
|
407
|
+
# Returns the target constraint declared on a category, or nil.
|
|
408
|
+
# Currently the only constraint is :primary_default_database_only, which
|
|
409
|
+
# means "only meaningful when the dashboard is pointing at the host app's
|
|
410
|
+
# primary target on its default database" — used by Schema Analysis,
|
|
411
|
+
# which depends on ActiveRecord::Base.descendants of the host app.
|
|
412
|
+
def self.target_constraint(category)
|
|
413
|
+
REPORTS.dig(category.to_sym, :target_constraint)
|
|
414
|
+
end
|
|
401
415
|
end
|
|
402
416
|
end
|
|
403
417
|
end
|
data/lib/pg_reports/executor.rb
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PgReports
|
|
4
|
-
# Executes SQL queries and returns results
|
|
4
|
+
# Executes SQL queries and returns results.
|
|
5
|
+
#
|
|
6
|
+
# The connection is resolved lazily on every #execute call so that thread-local
|
|
7
|
+
# context set by PgReports.with_target / with_database is honored even when an
|
|
8
|
+
# Executor instance has been memoized at the module level.
|
|
5
9
|
class Executor
|
|
6
10
|
def initialize(connection: nil)
|
|
7
|
-
@
|
|
11
|
+
@connection_override = connection
|
|
8
12
|
end
|
|
9
13
|
|
|
10
14
|
# Execute SQL from a file and return results as array of hashes
|
|
@@ -16,10 +20,15 @@ module PgReports
|
|
|
16
20
|
# Execute raw SQL and return results as array of hashes
|
|
17
21
|
def execute(sql, **params)
|
|
18
22
|
processed_sql = interpolate_params(sql, params)
|
|
19
|
-
result =
|
|
23
|
+
result = connection.exec_query(processed_sql)
|
|
20
24
|
result.to_a
|
|
21
25
|
end
|
|
22
26
|
|
|
27
|
+
# Resolved on every call: explicit override > thread-local > registry default.
|
|
28
|
+
def connection
|
|
29
|
+
@connection_override || PgReports.config.connection
|
|
30
|
+
end
|
|
31
|
+
|
|
23
32
|
private
|
|
24
33
|
|
|
25
34
|
# Simple parameter interpolation for SQL
|
|
@@ -40,11 +49,11 @@ module PgReports
|
|
|
40
49
|
when Integer, Float
|
|
41
50
|
value.to_s
|
|
42
51
|
when String
|
|
43
|
-
|
|
52
|
+
connection.quote(value)
|
|
44
53
|
when Array
|
|
45
54
|
"(#{value.map { |v| quote_value(v) }.join(", ")})"
|
|
46
55
|
else
|
|
47
|
-
|
|
56
|
+
connection.quote(value.to_s)
|
|
48
57
|
end
|
|
49
58
|
end
|
|
50
59
|
end
|
|
@@ -173,7 +173,10 @@ module PgReports
|
|
|
173
173
|
private
|
|
174
174
|
|
|
175
175
|
def pg_version
|
|
176
|
-
|
|
176
|
+
# Cache per-connection so switching targets/databases re-resolves the version.
|
|
177
|
+
cache = (Thread.current[:pg_reports_pg_version_cache] ||= {})
|
|
178
|
+
key = executor.connection.object_id
|
|
179
|
+
cache[key] ||= begin
|
|
177
180
|
result = executor.execute("SELECT current_setting('server_version_num')::int AS v")
|
|
178
181
|
result.first&.fetch("v", 0).to_i
|
|
179
182
|
end
|
|
@@ -290,13 +290,16 @@ module PgReports
|
|
|
290
290
|
# when gem is installed from RubyGems
|
|
291
291
|
next if path.include?("/query_monitor.rb")
|
|
292
292
|
|
|
293
|
-
# Filter queries from pg_reports internal
|
|
294
|
-
# - Installed gem: /gems/pg_reports-X.Y.Z/lib/
|
|
295
|
-
# - Local gem: /pg_reports/lib/pg_reports/
|
|
296
|
-
#
|
|
297
|
-
#
|
|
298
|
-
|
|
299
|
-
|
|
293
|
+
# Filter queries from pg_reports internal code:
|
|
294
|
+
# - Installed gem: /gems/pg_reports-X.Y.Z/lib/ or /gems/pg_reports-X.Y.Z/app/
|
|
295
|
+
# - Local gem: /pg_reports/lib/pg_reports/ or /pg_reports/app/(controllers|views)/pg_reports/
|
|
296
|
+
# This includes the dashboard controller's own SQL execution endpoints
|
|
297
|
+
# (execute_query, explain_analyze) which call AR directly without going
|
|
298
|
+
# through Executor — without this match nothing in the caller stack
|
|
299
|
+
# would identify the query as ours.
|
|
300
|
+
path.match?(%r{/gems/pg_reports[-\d.]+/(lib|app)/}) ||
|
|
301
|
+
path.match?(%r{/pg_reports/lib/pg_reports/}) ||
|
|
302
|
+
path.match?(%r{/pg_reports/app/(controllers|views)/pg_reports/})
|
|
300
303
|
end
|
|
301
304
|
end
|
|
302
305
|
|
data/lib/pg_reports/version.rb
CHANGED
data/lib/pg_reports.rb
CHANGED
|
@@ -6,6 +6,9 @@ require "active_record"
|
|
|
6
6
|
|
|
7
7
|
require_relative "pg_reports/version"
|
|
8
8
|
require_relative "pg_reports/error"
|
|
9
|
+
require_relative "pg_reports/connection/target"
|
|
10
|
+
require_relative "pg_reports/connection/registry"
|
|
11
|
+
require_relative "pg_reports/connection/error_translator"
|
|
9
12
|
require_relative "pg_reports/compatibility"
|
|
10
13
|
require_relative "pg_reports/configuration"
|
|
11
14
|
require_relative "pg_reports/sql_loader"
|
|
@@ -138,6 +141,56 @@ module PgReports
|
|
|
138
141
|
ReportLoader.reload!
|
|
139
142
|
ModuleGenerator.generate!
|
|
140
143
|
end
|
|
144
|
+
|
|
145
|
+
# Connection registry — multi-target / multi-database support.
|
|
146
|
+
# The :primary target is auto-discovered from ActiveRecord on first access.
|
|
147
|
+
def connection_registry
|
|
148
|
+
@connection_registry ||= Connection::Registry.new
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Run a block against a specific target (and optionally a specific database
|
|
152
|
+
# on that target). Honored by Executor and any code routing through
|
|
153
|
+
# PgReports.config.connection.
|
|
154
|
+
#
|
|
155
|
+
# PgReports.with_target(:analytics) { PgReports.slow_queries }
|
|
156
|
+
# PgReports.with_target(:primary, database: "logs") { PgReports.table_sizes }
|
|
157
|
+
def with_target(name, database: nil, &block)
|
|
158
|
+
connection_registry.with_context(target: name, database: database, &block)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Switch only the database on whatever target is currently active
|
|
162
|
+
# (defaults to the registry's default target).
|
|
163
|
+
#
|
|
164
|
+
# PgReports.with_database("reporting") { PgReports.database_sizes }
|
|
165
|
+
def with_database(database, &block)
|
|
166
|
+
target = connection_registry.current_name || connection_registry.default_name
|
|
167
|
+
connection_registry.with_context(target: target, database: database, &block)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Name of the currently effective target (taking with_target into account).
|
|
171
|
+
def current_target_name
|
|
172
|
+
connection_registry.current_name || connection_registry.default_name
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Name of the currently effective database (taking with_database into account).
|
|
176
|
+
def current_database_name
|
|
177
|
+
connection_registry.current_database_name
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# List databases on the currently active target's cluster.
|
|
181
|
+
# Each row: { "name" => String, "size" => String, "current" => Boolean }
|
|
182
|
+
def list_databases
|
|
183
|
+
target = connection_registry.fetch
|
|
184
|
+
target.list_databases(current: current_database_name)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# List of registered targets, each as { name:, default_database:, current: }.
|
|
188
|
+
def list_targets
|
|
189
|
+
current = current_target_name
|
|
190
|
+
connection_registry.targets.map do |t|
|
|
191
|
+
{name: t.name, default_database: t.default_database, current: t.name == current}
|
|
192
|
+
end
|
|
193
|
+
end
|
|
141
194
|
end
|
|
142
195
|
end
|
|
143
196
|
|
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.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eldar Avatov
|
|
@@ -150,10 +150,12 @@ files:
|
|
|
150
150
|
- app/controllers/pg_reports/dashboard_controller.rb
|
|
151
151
|
- app/controllers/pg_reports/metrics_controller.rb
|
|
152
152
|
- app/views/layouts/pg_reports/application.html.erb
|
|
153
|
+
- app/views/pg_reports/dashboard/_database_selector.html.erb
|
|
153
154
|
- app/views/pg_reports/dashboard/_fake_source_data.html.erb
|
|
154
155
|
- app/views/pg_reports/dashboard/_show_modals.html.erb
|
|
155
156
|
- app/views/pg_reports/dashboard/_show_scripts.html.erb
|
|
156
157
|
- app/views/pg_reports/dashboard/_show_styles.html.erb
|
|
158
|
+
- app/views/pg_reports/dashboard/_target_selector.html.erb
|
|
157
159
|
- app/views/pg_reports/dashboard/index.html.erb
|
|
158
160
|
- app/views/pg_reports/dashboard/show.html.erb
|
|
159
161
|
- config/locales/en.yml
|
|
@@ -164,6 +166,9 @@ files:
|
|
|
164
166
|
- lib/pg_reports/annotation_parser.rb
|
|
165
167
|
- lib/pg_reports/compatibility.rb
|
|
166
168
|
- lib/pg_reports/configuration.rb
|
|
169
|
+
- lib/pg_reports/connection/error_translator.rb
|
|
170
|
+
- lib/pg_reports/connection/registry.rb
|
|
171
|
+
- lib/pg_reports/connection/target.rb
|
|
167
172
|
- lib/pg_reports/dashboard/reports_registry.rb
|
|
168
173
|
- lib/pg_reports/definitions/connections/active_connections.yml
|
|
169
174
|
- lib/pg_reports/definitions/connections/blocking_queries.yml
|
|
@@ -289,16 +294,6 @@ metadata:
|
|
|
289
294
|
homepage_uri: https://github.com/deadalice/pg_reports
|
|
290
295
|
source_code_uri: https://github.com/deadalice/pg_reports
|
|
291
296
|
changelog_uri: https://github.com/deadalice/pg_reports/blob/main/CHANGELOG.md
|
|
292
|
-
post_install_message: |
|
|
293
|
-
Thanks for installing pg_reports v0.7.0!
|
|
294
|
-
|
|
295
|
-
New in 0.7.0 — experimental Grafana / Prometheus support
|
|
296
|
-
────────────────────────────────────────────────────────
|
|
297
|
-
Expose selected reports at <mount_point>/metrics in Prometheus exposition
|
|
298
|
-
format and import a ready-to-use Grafana dashboard:
|
|
299
|
-
|
|
300
|
-
Setup guide:
|
|
301
|
-
https://github.com/deadalice/pg_reports/blob/main/docs/grafana.md
|
|
302
297
|
rdoc_options: []
|
|
303
298
|
require_paths:
|
|
304
299
|
- lib
|