pg_reports 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -0
- data/README.md +36 -150
- data/app/controllers/pg_reports/dashboard_controller.rb +143 -3
- data/app/views/layouts/pg_reports/application.html.erb +213 -6
- 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 +143 -50
- data/app/views/pg_reports/dashboard/show.html.erb +4 -0
- data/bin/pg_reports +85 -0
- data/config/locales/en.yml +18 -7
- data/config/locales/ru.yml +18 -7
- data/config/locales/uk.yml +18 -7
- 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 +20 -6
- data/lib/pg_reports/modules/system.rb +32 -5
- data/lib/pg_reports/query_monitor.rb +10 -7
- data/lib/pg_reports/standalone.rb +152 -0
- data/lib/pg_reports/version.rb +1 -1
- data/lib/pg_reports.rb +57 -0
- metadata +11 -13
|
@@ -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, "PgReports").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
|
|
@@ -13,13 +17,23 @@ module PgReports
|
|
|
13
17
|
execute(sql, **params)
|
|
14
18
|
end
|
|
15
19
|
|
|
16
|
-
# 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.
|
|
17
26
|
def execute(sql, **params)
|
|
18
27
|
processed_sql = interpolate_params(sql, params)
|
|
19
|
-
result =
|
|
28
|
+
result = connection.exec_query(processed_sql, "PgReports")
|
|
20
29
|
result.to_a
|
|
21
30
|
end
|
|
22
31
|
|
|
32
|
+
# Resolved on every call: explicit override > thread-local > registry default.
|
|
33
|
+
def connection
|
|
34
|
+
@connection_override || PgReports.config.connection
|
|
35
|
+
end
|
|
36
|
+
|
|
23
37
|
private
|
|
24
38
|
|
|
25
39
|
# Simple parameter interpolation for SQL
|
|
@@ -40,11 +54,11 @@ module PgReports
|
|
|
40
54
|
when Integer, Float
|
|
41
55
|
value.to_s
|
|
42
56
|
when String
|
|
43
|
-
|
|
57
|
+
connection.quote(value)
|
|
44
58
|
when Array
|
|
45
59
|
"(#{value.map { |v| quote_value(v) }.join(", ")})"
|
|
46
60
|
else
|
|
47
|
-
|
|
61
|
+
connection.quote(value.to_s)
|
|
48
62
|
end
|
|
49
63
|
end
|
|
50
64
|
end
|
|
@@ -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
|
|
|
@@ -173,7 +197,10 @@ module PgReports
|
|
|
173
197
|
private
|
|
174
198
|
|
|
175
199
|
def pg_version
|
|
176
|
-
|
|
200
|
+
# Cache per-connection so switching targets/databases re-resolves the version.
|
|
201
|
+
cache = (Thread.current[:pg_reports_pg_version_cache] ||= {})
|
|
202
|
+
key = executor.connection.object_id
|
|
203
|
+
cache[key] ||= begin
|
|
177
204
|
result = executor.execute("SELECT current_setting('server_version_num')::int AS v")
|
|
178
205
|
result.first&.fetch("v", 0).to_i
|
|
179
206
|
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
|
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module PgReports
|
|
6
|
+
# Runs the dashboard as a self-contained application, without a host Rails app.
|
|
7
|
+
#
|
|
8
|
+
# It boots a minimal Rails::Application that mounts PgReports::Engine and points
|
|
9
|
+
# ActiveRecord::Base at a PostgreSQL database, then serves it over HTTP. This is
|
|
10
|
+
# what powers the `pg_reports server` executable and the `pg_reports:server`
|
|
11
|
+
# rake task, so the project can be launched straight from the gem's root folder.
|
|
12
|
+
#
|
|
13
|
+
# Dependency note: this relies only on gems already pulled in transitively by
|
|
14
|
+
# the gem's runtime deps (rack via actionpack, rackup via railties). The actual
|
|
15
|
+
# web server (puma / webrick) is resolved at run time and is NOT a hard
|
|
16
|
+
# dependency — installed-gem users bring their own.
|
|
17
|
+
module Standalone
|
|
18
|
+
extend self
|
|
19
|
+
|
|
20
|
+
DEFAULT_PORT = 4000
|
|
21
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
22
|
+
DEFAULT_MOUNT = "/"
|
|
23
|
+
|
|
24
|
+
# Rack handlers tried, in order, when none is named explicitly.
|
|
25
|
+
CANDIDATE_SERVERS = %w[puma webrick].freeze
|
|
26
|
+
|
|
27
|
+
class ServerUnavailable < PgReports::Error; end
|
|
28
|
+
|
|
29
|
+
# Boot the app and start a (blocking) web server.
|
|
30
|
+
#
|
|
31
|
+
# @param port [Integer]
|
|
32
|
+
# @param host [String]
|
|
33
|
+
# @param mount_path [String] where the engine is mounted (default "/")
|
|
34
|
+
# @param database_url [String, nil] explicit connection URL; otherwise resolved
|
|
35
|
+
# from DATABASE_URL or libpq-style PG* env vars
|
|
36
|
+
# @param server [String, nil] Rack handler name to force (e.g. "puma")
|
|
37
|
+
def run(port: DEFAULT_PORT, host: DEFAULT_HOST, mount_path: DEFAULT_MOUNT,
|
|
38
|
+
database_url: nil, server: nil)
|
|
39
|
+
# Rails' ActiveRecord railtie reads the connection from DATABASE_URL when no
|
|
40
|
+
# config/database.yml exists — so we route our resolved connection through
|
|
41
|
+
# it. The connection registry then auto-registers it as the :primary target,
|
|
42
|
+
# and database switching / multi-cluster all work unchanged.
|
|
43
|
+
ENV["DATABASE_URL"] = connection_url(database_url)
|
|
44
|
+
|
|
45
|
+
app = build_application(mount_path)
|
|
46
|
+
app.initialize!
|
|
47
|
+
verify_connection!
|
|
48
|
+
|
|
49
|
+
handler_name, handler = resolve_server(server)
|
|
50
|
+
banner(host: host, port: port, server: handler_name)
|
|
51
|
+
handler.run(app, Host: host, Port: port)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Resolve the connection URL. Priority: explicit url > DATABASE_URL >
|
|
55
|
+
# libpq-style PG* env vars (PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE).
|
|
56
|
+
def connection_url(explicit = nil)
|
|
57
|
+
return explicit if explicit && !explicit.empty?
|
|
58
|
+
return ENV["DATABASE_URL"] if ENV["DATABASE_URL"] && !ENV["DATABASE_URL"].empty?
|
|
59
|
+
|
|
60
|
+
require "erb"
|
|
61
|
+
user = ENV["PGUSER"] || ENV["USER"]
|
|
62
|
+
password = ENV["PGPASSWORD"]
|
|
63
|
+
host = ENV["PGHOST"] || "localhost"
|
|
64
|
+
port = ENV["PGPORT"] || 5432
|
|
65
|
+
database = ENV["PGDATABASE"] || "postgres"
|
|
66
|
+
|
|
67
|
+
userinfo = +""
|
|
68
|
+
if user && !user.empty?
|
|
69
|
+
userinfo << ERB::Util.url_encode(user)
|
|
70
|
+
userinfo << ":#{ERB::Util.url_encode(password)}" if password && !password.empty?
|
|
71
|
+
userinfo << "@"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
"postgresql://#{userinfo}#{host}:#{port}/#{ERB::Util.url_encode(database)}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Build (and register as Rails.application) a minimal Rails app that mounts
|
|
80
|
+
# the engine. Kept intentionally small: no asset pipeline (views are inline),
|
|
81
|
+
# cookie sessions for the dashboard's database selector + CSRF.
|
|
82
|
+
def build_application(mount_path)
|
|
83
|
+
require "rails"
|
|
84
|
+
require "action_controller/railtie"
|
|
85
|
+
require "active_record/railtie"
|
|
86
|
+
require "tmpdir"
|
|
87
|
+
# pg_reports.rb only requires the engine when Rails::Engine is already
|
|
88
|
+
# defined; when loaded outside a Rails app that guard was false, so load it
|
|
89
|
+
# now that the railties are present. This also registers its initializers.
|
|
90
|
+
require "pg_reports/engine"
|
|
91
|
+
|
|
92
|
+
target_mount = mount_path
|
|
93
|
+
# A throwaway, empty app root. We must NOT use the gem root here — Rails
|
|
94
|
+
# would then load the gem's engine config/routes.rb (and config/locales) as
|
|
95
|
+
# the *application's* own, which double-loads and breaks. The engine loads
|
|
96
|
+
# those itself relative to its own root; the app only needs the mount below.
|
|
97
|
+
app_root = Dir.mktmpdir("pg_reports-standalone")
|
|
98
|
+
at_exit { FileUtils.remove_entry(app_root, true) }
|
|
99
|
+
|
|
100
|
+
Class.new(Rails::Application) do
|
|
101
|
+
config.root = app_root
|
|
102
|
+
config.eager_load = false
|
|
103
|
+
config.consider_all_requests_local = true
|
|
104
|
+
config.secret_key_base = ENV["SECRET_KEY_BASE"] || SecureRandom.hex(64)
|
|
105
|
+
config.session_store :cookie_store, key: "_pg_reports_session"
|
|
106
|
+
config.hosts.clear # local tool: don't block by Host header
|
|
107
|
+
config.logger = ::Logger.new($stdout)
|
|
108
|
+
config.log_level = (ENV["LOG_LEVEL"] || "info").to_sym
|
|
109
|
+
config.active_support.report_deprecations = false
|
|
110
|
+
|
|
111
|
+
routes.append do
|
|
112
|
+
mount PgReports::Engine, at: target_mount, as: "pg_reports"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Force an actual connection so the user gets a clear error at startup rather
|
|
118
|
+
# than a 500 on the first request when the database is unreachable.
|
|
119
|
+
def verify_connection!
|
|
120
|
+
ActiveRecord::Base.connection
|
|
121
|
+
rescue => e
|
|
122
|
+
raise PgReports::Error, "Cannot connect to the database (#{ENV["DATABASE_URL"]}): #{e.message}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Find a usable Rack handler. Honors an explicit name, otherwise tries the
|
|
126
|
+
# candidates in order and uses the first that is installed.
|
|
127
|
+
def resolve_server(name)
|
|
128
|
+
require "rackup"
|
|
129
|
+
|
|
130
|
+
candidates = name ? [name] : CANDIDATE_SERVERS
|
|
131
|
+
candidates.each do |candidate|
|
|
132
|
+
handler = begin
|
|
133
|
+
Rackup::Handler.get(candidate)
|
|
134
|
+
rescue LoadError, NameError
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
return [candidate, handler] if handler
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
raise ServerUnavailable, <<~MSG.strip
|
|
141
|
+
No web server found (tried: #{candidates.join(", ")}).
|
|
142
|
+
Add one to run the standalone dashboard, e.g. `gem install puma`
|
|
143
|
+
(or add `gem "puma"` to your Gemfile).
|
|
144
|
+
MSG
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def banner(host:, port:, server:)
|
|
148
|
+
url_host = (host == "0.0.0.0") ? "localhost" : host
|
|
149
|
+
warn "pg_reports: serving dashboard via #{server} on http://#{url_host}:#{port} (Ctrl-C to stop)"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
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"
|
|
@@ -40,6 +43,10 @@ require_relative "pg_reports/grafana/dashboard_builder"
|
|
|
40
43
|
# Rails Engine
|
|
41
44
|
require_relative "pg_reports/engine" if defined?(Rails::Engine)
|
|
42
45
|
|
|
46
|
+
# Standalone runner (no host app). Only defines methods; the heavy Rails/web
|
|
47
|
+
# requires happen lazily inside PgReports::Standalone.run.
|
|
48
|
+
require_relative "pg_reports/standalone"
|
|
49
|
+
|
|
43
50
|
module PgReports
|
|
44
51
|
class << self
|
|
45
52
|
# Query analysis methods
|
|
@@ -138,6 +145,56 @@ module PgReports
|
|
|
138
145
|
ReportLoader.reload!
|
|
139
146
|
ModuleGenerator.generate!
|
|
140
147
|
end
|
|
148
|
+
|
|
149
|
+
# Connection registry — multi-target / multi-database support.
|
|
150
|
+
# The :primary target is auto-discovered from ActiveRecord on first access.
|
|
151
|
+
def connection_registry
|
|
152
|
+
@connection_registry ||= Connection::Registry.new
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Run a block against a specific target (and optionally a specific database
|
|
156
|
+
# on that target). Honored by Executor and any code routing through
|
|
157
|
+
# PgReports.config.connection.
|
|
158
|
+
#
|
|
159
|
+
# PgReports.with_target(:analytics) { PgReports.slow_queries }
|
|
160
|
+
# PgReports.with_target(:primary, database: "logs") { PgReports.table_sizes }
|
|
161
|
+
def with_target(name, database: nil, &block)
|
|
162
|
+
connection_registry.with_context(target: name, database: database, &block)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Switch only the database on whatever target is currently active
|
|
166
|
+
# (defaults to the registry's default target).
|
|
167
|
+
#
|
|
168
|
+
# PgReports.with_database("reporting") { PgReports.database_sizes }
|
|
169
|
+
def with_database(database, &block)
|
|
170
|
+
target = connection_registry.current_name || connection_registry.default_name
|
|
171
|
+
connection_registry.with_context(target: target, database: database, &block)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Name of the currently effective target (taking with_target into account).
|
|
175
|
+
def current_target_name
|
|
176
|
+
connection_registry.current_name || connection_registry.default_name
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Name of the currently effective database (taking with_database into account).
|
|
180
|
+
def current_database_name
|
|
181
|
+
connection_registry.current_database_name
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# List databases on the currently active target's cluster.
|
|
185
|
+
# Each row: { "name" => String, "size" => String, "current" => Boolean }
|
|
186
|
+
def list_databases
|
|
187
|
+
target = connection_registry.fetch
|
|
188
|
+
target.list_databases(current: current_database_name)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# List of registered targets, each as { name:, default_database:, current: }.
|
|
192
|
+
def list_targets
|
|
193
|
+
current = current_target_name
|
|
194
|
+
connection_registry.targets.map do |t|
|
|
195
|
+
{name: t.name, default_database: t.default_database, current: t.name == current}
|
|
196
|
+
end
|
|
197
|
+
end
|
|
141
198
|
end
|
|
142
199
|
end
|
|
143
200
|
|
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.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eldar Avatov
|
|
@@ -140,7 +140,8 @@ description: A comprehensive PostgreSQL monitoring and analysis library that pro
|
|
|
140
140
|
a beautiful web dashboard and Telegram notifications.
|
|
141
141
|
email:
|
|
142
142
|
- eldar.avatov@gmail.com
|
|
143
|
-
executables:
|
|
143
|
+
executables:
|
|
144
|
+
- pg_reports
|
|
144
145
|
extensions: []
|
|
145
146
|
extra_rdoc_files: []
|
|
146
147
|
files:
|
|
@@ -150,12 +151,15 @@ files:
|
|
|
150
151
|
- app/controllers/pg_reports/dashboard_controller.rb
|
|
151
152
|
- app/controllers/pg_reports/metrics_controller.rb
|
|
152
153
|
- app/views/layouts/pg_reports/application.html.erb
|
|
154
|
+
- app/views/pg_reports/dashboard/_database_selector.html.erb
|
|
153
155
|
- app/views/pg_reports/dashboard/_fake_source_data.html.erb
|
|
154
156
|
- app/views/pg_reports/dashboard/_show_modals.html.erb
|
|
155
157
|
- app/views/pg_reports/dashboard/_show_scripts.html.erb
|
|
156
158
|
- app/views/pg_reports/dashboard/_show_styles.html.erb
|
|
159
|
+
- app/views/pg_reports/dashboard/_target_selector.html.erb
|
|
157
160
|
- app/views/pg_reports/dashboard/index.html.erb
|
|
158
161
|
- app/views/pg_reports/dashboard/show.html.erb
|
|
162
|
+
- bin/pg_reports
|
|
159
163
|
- config/locales/en.yml
|
|
160
164
|
- config/locales/ru.yml
|
|
161
165
|
- config/locales/uk.yml
|
|
@@ -164,6 +168,9 @@ files:
|
|
|
164
168
|
- lib/pg_reports/annotation_parser.rb
|
|
165
169
|
- lib/pg_reports/compatibility.rb
|
|
166
170
|
- lib/pg_reports/configuration.rb
|
|
171
|
+
- lib/pg_reports/connection/error_translator.rb
|
|
172
|
+
- lib/pg_reports/connection/registry.rb
|
|
173
|
+
- lib/pg_reports/connection/target.rb
|
|
167
174
|
- lib/pg_reports/dashboard/reports_registry.rb
|
|
168
175
|
- lib/pg_reports/definitions/connections/active_connections.yml
|
|
169
176
|
- lib/pg_reports/definitions/connections/blocking_queries.yml
|
|
@@ -279,6 +286,7 @@ files:
|
|
|
279
286
|
- lib/pg_reports/sql/tables/update_hotspots.sql
|
|
280
287
|
- lib/pg_reports/sql/tables/vacuum_needed.sql
|
|
281
288
|
- lib/pg_reports/sql_loader.rb
|
|
289
|
+
- lib/pg_reports/standalone.rb
|
|
282
290
|
- lib/pg_reports/telegram_sender.rb
|
|
283
291
|
- lib/pg_reports/version.rb
|
|
284
292
|
- lib/tasks/pg_reports.rake
|
|
@@ -289,16 +297,6 @@ metadata:
|
|
|
289
297
|
homepage_uri: https://github.com/deadalice/pg_reports
|
|
290
298
|
source_code_uri: https://github.com/deadalice/pg_reports
|
|
291
299
|
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
300
|
rdoc_options: []
|
|
303
301
|
require_paths:
|
|
304
302
|
- lib
|
|
@@ -313,7 +311,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
313
311
|
- !ruby/object:Gem::Version
|
|
314
312
|
version: '0'
|
|
315
313
|
requirements: []
|
|
316
|
-
rubygems_version:
|
|
314
|
+
rubygems_version: 3.6.9
|
|
317
315
|
specification_version: 4
|
|
318
316
|
summary: PostgreSQL analysis and reporting tool with Telegram integration
|
|
319
317
|
test_files: []
|