pghero 2.5.0 → 2.7.2

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of pghero might be problematic. Click here for more details.

@@ -508,3 +508,7 @@ body {
508
508
  .duplicate-indexes pre {
509
509
  margin-top: 10px;
510
510
  }
511
+
512
+ .no-outline:focus {
513
+ outline: none;
514
+ }
@@ -1,4 +1,4 @@
1
- /*! nouislider - 14.0.3 - 10/10/2019 */
1
+ /*! nouislider - 14.6.1 - 8/17/2020 */
2
2
  /* Functional styling;
3
3
  * These styles are required for noUiSlider to function.
4
4
  * You don't need to change these rules to apply your design.
@@ -18,7 +18,6 @@
18
18
  }
19
19
  .noUi-target {
20
20
  position: relative;
21
- direction: ltr;
22
21
  }
23
22
  .noUi-base,
24
23
  .noUi-connects {
@@ -39,7 +38,7 @@
39
38
  position: absolute;
40
39
  z-index: 1;
41
40
  top: 0;
42
- left: 0;
41
+ right: 0;
43
42
  -ms-transform-origin: 0 0;
44
43
  -webkit-transform-origin: 0 0;
45
44
  -webkit-transform-style: preserve-3d;
@@ -56,9 +55,9 @@
56
55
  }
57
56
  /* Offset direction
58
57
  */
59
- html:not([dir="rtl"]) .noUi-horizontal .noUi-origin {
60
- left: auto;
61
- right: 0;
58
+ .noUi-txt-dir-rtl.noUi-horizontal .noUi-origin {
59
+ left: 0;
60
+ right: auto;
62
61
  }
63
62
  /* Give origins 0 height/width so they don't interfere with clicking the
64
63
  * connect elements.
@@ -94,7 +93,7 @@ html:not([dir="rtl"]) .noUi-horizontal .noUi-origin {
94
93
  .noUi-horizontal .noUi-handle {
95
94
  width: 34px;
96
95
  height: 28px;
97
- left: -17px;
96
+ right: -17px;
98
97
  top: -6px;
99
98
  }
100
99
  .noUi-vertical {
@@ -103,12 +102,12 @@ html:not([dir="rtl"]) .noUi-horizontal .noUi-origin {
103
102
  .noUi-vertical .noUi-handle {
104
103
  width: 28px;
105
104
  height: 34px;
106
- left: -6px;
105
+ right: -6px;
107
106
  top: -17px;
108
107
  }
109
- html:not([dir="rtl"]) .noUi-horizontal .noUi-handle {
110
- right: -17px;
111
- left: auto;
108
+ .noUi-txt-dir-rtl.noUi-horizontal .noUi-handle {
109
+ left: -17px;
110
+ right: auto;
112
111
  }
113
112
  /* Styling;
114
113
  * Giving the connect element a border radius causes issues with using transform: scale
@@ -297,3 +296,15 @@ html:not([dir="rtl"]) .noUi-horizontal .noUi-handle {
297
296
  top: 50%;
298
297
  right: 120%;
299
298
  }
299
+ .noUi-horizontal .noUi-origin > .noUi-tooltip {
300
+ -webkit-transform: translate(50%, 0);
301
+ transform: translate(50%, 0);
302
+ left: auto;
303
+ bottom: 10px;
304
+ }
305
+ .noUi-vertical .noUi-origin > .noUi-tooltip {
306
+ -webkit-transform: translate(0, -18px);
307
+ transform: translate(0, -18px);
308
+ top: auto;
309
+ right: 28px;
310
+ }
@@ -2,7 +2,7 @@ module PgHero
2
2
  class HomeController < ActionController::Base
3
3
  layout "pg_hero/application"
4
4
 
5
- protect_from_forgery
5
+ protect_from_forgery with: :exception
6
6
 
7
7
  http_basic_authenticate_with name: PgHero.username, password: PgHero.password if PgHero.password
8
8
 
@@ -32,14 +32,14 @@ databases:
32
32
 
33
33
  # Basic authentication
34
34
  # username: admin
35
- # password: secret
35
+ # password: <%%= ENV["PGHERO_PASSWORD"] %>
36
36
 
37
37
  # Stats database URL (defaults to app database)
38
38
  # stats_database_url: <%%= ENV["PGHERO_STATS_DATABASE_URL"] %>
39
39
 
40
40
  # AWS configuration (defaults to app AWS config)
41
- # aws_access_key_id: ...
42
- # aws_secret_access_key: ...
41
+ # aws_access_key_id: <%%= ENV["AWS_ACCESS_KEY_ID"] %>
42
+ # aws_secret_access_key: <%%= ENV["AWS_SECRET_ACCESS_KEY"] %>
43
43
  # aws_region: us-east-1
44
44
 
45
45
  # Filter data from queries (experimental)
@@ -43,7 +43,7 @@ module PgHero
43
43
  self.long_running_query_sec = (ENV["PGHERO_LONG_RUNNING_QUERY_SEC"] || 60).to_i
44
44
  self.slow_query_ms = (ENV["PGHERO_SLOW_QUERY_MS"] || 20).to_i
45
45
  self.slow_query_calls = (ENV["PGHERO_SLOW_QUERY_CALLS"] || 100).to_i
46
- self.explain_timeout_sec = (ENV["PGHERO_EXPLAIN_TIMEOUT_SEC"] || 10).to_i
46
+ self.explain_timeout_sec = (ENV["PGHERO_EXPLAIN_TIMEOUT_SEC"] || 10).to_f
47
47
  self.total_connections_threshold = (ENV["PGHERO_TOTAL_CONNECTIONS_THRESHOLD"] || 500).to_i
48
48
  self.cache_hit_rate_threshold = 99
49
49
  self.env = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
@@ -119,11 +119,16 @@ module PgHero
119
119
 
120
120
  if databases.empty?
121
121
  databases["primary"] = {
122
- "url" => ENV["PGHERO_DATABASE_URL"] || ActiveRecord::Base.connection_config,
122
+ "url" => ENV["PGHERO_DATABASE_URL"] || ActiveRecord::Base.connection_config
123
+ }
124
+ end
125
+
126
+ if databases.size == 1
127
+ databases.values.first.merge!(
123
128
  "db_instance_identifier" => ENV["PGHERO_DB_INSTANCE_IDENTIFIER"],
124
129
  "gcp_database_id" => ENV["PGHERO_GCP_DATABASE_ID"],
125
130
  "azure_resource_id" => ENV["PGHERO_AZURE_RESOURCE_ID"]
126
- }
131
+ )
127
132
  end
128
133
 
129
134
  {
@@ -191,13 +196,13 @@ module PgHero
191
196
  # stats for old databases are not cleaned up since we can't use an index
192
197
  def clean_query_stats
193
198
  each_database do |database|
194
- PgHero::QueryStats.where(database: database.id).where("captured_at < ?", 14.days.ago).delete_all
199
+ database.clean_query_stats
195
200
  end
196
201
  end
197
202
 
198
203
  def clean_space_stats
199
204
  each_database do |database|
200
- PgHero::SpaceStats.where(database: database.id).where("captured_at < ?", 90.days.ago).delete_all
205
+ database.clean_space_stats
201
206
  end
202
207
  end
203
208
 
@@ -55,15 +55,16 @@ module PgHero
55
55
  end
56
56
 
57
57
  def explain_timeout_sec
58
- (config["explain_timeout_sec"] || PgHero.config["explain_timeout_sec"] || PgHero.explain_timeout_sec).to_i
58
+ (config["explain_timeout_sec"] || PgHero.config["explain_timeout_sec"] || PgHero.explain_timeout_sec).to_f
59
59
  end
60
60
 
61
61
  def long_running_query_sec
62
62
  (config["long_running_query_sec"] || PgHero.config["long_running_query_sec"] || PgHero.long_running_query_sec).to_i
63
63
  end
64
64
 
65
+ # defaults to 100 megabytes
65
66
  def index_bloat_bytes
66
- (config["index_bloat_bytes"] || PgHero.config["index_bloat_bytes"] || 100.megabytes).to_i
67
+ (config["index_bloat_bytes"] || PgHero.config["index_bloat_bytes"] || 104857600).to_i
67
68
  end
68
69
 
69
70
  def aws_access_key_id
@@ -18,6 +18,10 @@ module PgHero
18
18
  select_one("SELECT current_database()")
19
19
  end
20
20
 
21
+ def current_user
22
+ select_one("SELECT current_user")
23
+ end
24
+
21
25
  def server_version
22
26
  @server_version ||= select_one("SHOW server_version")
23
27
  end
@@ -38,7 +42,11 @@ module PgHero
38
42
  retries = 0
39
43
  begin
40
44
  result = conn.select_all(add_source(squish(sql)))
41
- result = result.map { |row| Hash[row.map { |col, val| [col.to_sym, result.column_types[col].send(:cast_value, val)] }] }
45
+ if ActiveRecord::VERSION::STRING.to_f >= 6.1
46
+ result = result.map(&:symbolize_keys)
47
+ else
48
+ result = result.map { |row| Hash[row.map { |col, val| [col.to_sym, result.column_types[col].send(:cast_value, val)] }] }
49
+ end
42
50
  if filter_data
43
51
  query_columns.each do |column|
44
52
  result.each do |row|
@@ -6,7 +6,7 @@ module PgHero
6
6
  explanation = nil
7
7
 
8
8
  # use transaction for safety
9
- with_transaction(statement_timeout: (explain_timeout_sec * 1000), rollback: true) do
9
+ with_transaction(statement_timeout: (explain_timeout_sec * 1000).round, rollback: true) do
10
10
  if (sql.sub(/;\z/, "").include?(";") || sql.upcase.include?("COMMIT")) && !explain_safe?
11
11
  raise ActiveRecord::StatementInvalid, "Unsafe statement"
12
12
  end
@@ -56,8 +56,46 @@ module PgHero
56
56
  true
57
57
  end
58
58
 
59
- def reset_query_stats(raise_errors: false)
60
- execute("SELECT pg_stat_statements_reset()")
59
+ # TODO scope by database in PgHero 3.0
60
+ # (add database: database_name to options)
61
+ def reset_query_stats(**options)
62
+ reset_instance_query_stats(**options)
63
+ end
64
+
65
+ # resets query stats for the entire instance
66
+ # it's possible to reset stats for a specific
67
+ # database, user or query hash in Postgres 12+
68
+ def reset_instance_query_stats(database: nil, user: nil, query_hash: nil, raise_errors: false)
69
+ if database || user || query_hash
70
+ raise PgHero::Error, "Requires PostgreSQL 12+" if server_version_num < 120000
71
+
72
+ if database
73
+ database_id = execute("SELECT oid FROM pg_database WHERE datname = #{quote(database)}").first.try(:[], "oid")
74
+ raise PgHero::Error, "Database not found: #{database}" unless database_id
75
+ else
76
+ database_id = 0
77
+ end
78
+
79
+ if user
80
+ user_id = execute("SELECT usesysid FROM pg_user WHERE usename = #{quote(user)}").first.try(:[], "usesysid")
81
+ raise PgHero::Error, "User not found: #{user}" unless user_id
82
+ else
83
+ user_id = 0
84
+ end
85
+
86
+ if query_hash
87
+ query_id = query_hash.to_i
88
+ # may not be needed
89
+ # but not intuitive that all query hashes are reset with 0
90
+ raise PgHero::Error, "Invalid query hash: #{query_hash}" if query_id == 0
91
+ else
92
+ query_id = 0
93
+ end
94
+
95
+ execute("SELECT pg_stat_statements_reset(#{quote(user_id.to_i)}, #{quote(database_id.to_i)}, #{quote(query_id.to_i)})")
96
+ else
97
+ execute("SELECT pg_stat_statements_reset()")
98
+ end
61
99
  true
62
100
  rescue ActiveRecord::StatementInvalid => e
63
101
  raise e if raise_errors
@@ -104,31 +142,32 @@ module PgHero
104
142
  query_stats[database_id] = query_stats(limit: 1000000, database: database_name)
105
143
  end
106
144
 
107
- supports_query_hash = supports_query_hash?
145
+ query_stats = query_stats.select { |_, v| v.any? }
108
146
 
109
- if query_stats.any? { |_, v| v.any? } && reset_query_stats(raise_errors: raise_errors)
147
+ # nothing to do
148
+ return if query_stats.empty?
149
+
150
+ # use mapping, not query stats here
151
+ # TODO add option for this, and make default in PgHero 3.0
152
+ if false # mapping.size == 1 && server_version_num >= 120000
110
153
  query_stats.each do |db_id, db_query_stats|
111
- if db_query_stats.any?
112
- values =
113
- db_query_stats.map do |qs|
114
- [
115
- db_id,
116
- qs[:query],
117
- qs[:total_minutes] * 60 * 1000,
118
- qs[:calls],
119
- now,
120
- supports_query_hash ? qs[:query_hash] : nil,
121
- qs[:user]
122
- ]
123
- end
124
-
125
- columns = %w[database query total_time calls captured_at query_hash user]
126
- insert_stats("pghero_query_stats", columns, values)
154
+ if reset_query_stats(database: mapping[db_id], raise_errors: raise_errors)
155
+ insert_query_stats(db_id, db_query_stats, now)
156
+ end
157
+ end
158
+ else
159
+ if reset_query_stats(raise_errors: raise_errors)
160
+ query_stats.each do |db_id, db_query_stats|
161
+ insert_query_stats(db_id, db_query_stats, now)
127
162
  end
128
163
  end
129
164
  end
130
165
  end
131
166
 
167
+ def clean_query_stats
168
+ PgHero::QueryStats.where(database: id).where("captured_at < ?", 14.days.ago).delete_all
169
+ end
170
+
132
171
  def slow_queries(query_stats: nil, **options)
133
172
  query_stats ||= self.query_stats(options)
134
173
  query_stats.select { |q| q[:calls].to_i >= slow_query_calls.to_i && q[:average_time].to_f >= slow_query_ms.to_f }
@@ -166,14 +205,15 @@ module PgHero
166
205
  if query_stats_enabled?
167
206
  limit ||= 100
168
207
  sort ||= "total_minutes"
208
+ total_time = server_version_num >= 130000 ? "(total_plan_time + total_exec_time)" : "total_time"
169
209
  query = <<-SQL
170
210
  WITH query_stats AS (
171
211
  SELECT
172
212
  LEFT(query, 10000) AS query,
173
213
  #{supports_query_hash? ? "queryid" : "md5(query)"} AS query_hash,
174
214
  rolname AS user,
175
- (total_time / 1000 / 60) AS total_minutes,
176
- (total_time / calls) AS average_time,
215
+ (#{total_time} / 1000 / 60) AS total_minutes,
216
+ (#{total_time} / calls) AS average_time,
177
217
  calls
178
218
  FROM
179
219
  pg_stat_statements
@@ -182,6 +222,7 @@ module PgHero
182
222
  INNER JOIN
183
223
  pg_roles ON pg_roles.oid = pg_stat_statements.userid
184
224
  WHERE
225
+ calls > 0 AND
185
226
  pg_database.datname = #{database ? quote(database) : "current_database()"}
186
227
  #{query_hash ? "AND queryid = #{quote(query_hash)}" : nil}
187
228
  )
@@ -285,6 +326,24 @@ module PgHero
285
326
  def normalize_query(query)
286
327
  squish(query.to_s.gsub(/\?(, ?\?)+/, "?").gsub(/\/\*.+?\*\//, ""))
287
328
  end
329
+
330
+ def insert_query_stats(db_id, db_query_stats, now)
331
+ values =
332
+ db_query_stats.map do |qs|
333
+ [
334
+ db_id,
335
+ qs[:query],
336
+ qs[:total_minutes] * 60 * 1000,
337
+ qs[:calls],
338
+ now,
339
+ supports_query_hash? ? qs[:query_hash] : nil,
340
+ qs[:user]
341
+ ]
342
+ end
343
+
344
+ columns = %w[database query total_time calls captured_at query_hash user]
345
+ insert_stats("pghero_query_stats", columns, values)
346
+ end
288
347
  end
289
348
  end
290
349
  end
@@ -129,6 +129,10 @@ module PgHero
129
129
  insert_stats("pghero_space_stats", columns, values) if values.any?
130
130
  end
131
131
 
132
+ def clean_space_stats
133
+ PgHero::SpaceStats.where(database: id).where("captured_at < ?", 90.days.ago).delete_all
134
+ end
135
+
132
136
  def space_stats_enabled?
133
137
  table_exists?("pghero_space_stats")
134
138
  end
@@ -5,7 +5,7 @@ module PgHero
5
5
  !system_stats_provider.nil?
6
6
  end
7
7
 
8
- # TODO require AWS 2+ automatically
8
+ # TODO remove defined checks in 3.0
9
9
  def system_stats_provider
10
10
  if aws_db_instance_identifier && (defined?(Aws) || defined?(AWS))
11
11
  :aws
@@ -133,7 +133,7 @@ module PgHero
133
133
  private
134
134
 
135
135
  def gcp_stats(metric_name, duration: nil, period: nil, offset: nil, series: false)
136
- require "google/cloud/monitoring"
136
+ require "google/cloud/monitoring/v3"
137
137
 
138
138
  # TODO DRY with RDS stats
139
139
  duration = (duration || 1.hour).to_i
@@ -142,30 +142,63 @@ module PgHero
142
142
  end_time = Time.at(((Time.now - offset).to_f / period).ceil * period)
143
143
  start_time = end_time - duration
144
144
 
145
- client = Google::Cloud::Monitoring::Metric.new
146
-
147
- interval = Google::Monitoring::V3::TimeInterval.new
148
- interval.end_time = Google::Protobuf::Timestamp.new(seconds: end_time.to_i)
149
- # subtract period to make sure we get first data point
150
- interval.start_time = Google::Protobuf::Timestamp.new(seconds: (start_time - period).to_i)
151
-
152
- aggregation = Google::Monitoring::V3::Aggregation.new
153
- # may be better to use ALIGN_NEXT_OLDER for space stats to show most recent data point
154
- # stick with average for now to match AWS
155
- aggregation.per_series_aligner = Google::Monitoring::V3::Aggregation::Aligner::ALIGN_MEAN
156
- aggregation.alignment_period = period
157
-
158
145
  # validate input since we need to interpolate below
159
146
  raise Error, "Invalid metric name" unless metric_name =~ /\A[a-z\/_]+\z/i
160
147
  raise Error, "Invalid database id" unless gcp_database_id =~ /\A[a-z\-:]+\z/i
161
148
 
162
- results = client.list_time_series(
163
- "projects/#{gcp_database_id.split(":").first}",
164
- "metric.type = \"cloudsql.googleapis.com/database/#{metric_name}\" AND resource.label.database_id = \"#{gcp_database_id}\"",
165
- interval,
166
- Google::Monitoring::V3::ListTimeSeriesRequest::TimeSeriesView::FULL,
167
- aggregation: aggregation
168
- )
149
+ # we handle three situations:
150
+ # 1. google-cloud-monitoring-v3
151
+ # 2. google-cloud-monitoring >= 1
152
+ # 3. google-cloud-monitoring < 1
153
+
154
+ # for situations 1 and 2
155
+ # Google::Cloud::Monitoring.metric_service is documented
156
+ # but doesn't work for situation 1
157
+ if defined?(Google::Cloud::Monitoring::V3::MetricService::Client)
158
+ client = Google::Cloud::Monitoring::V3::MetricService::Client.new
159
+
160
+ interval = Google::Cloud::Monitoring::V3::TimeInterval.new
161
+ interval.end_time = Google::Protobuf::Timestamp.new(seconds: end_time.to_i)
162
+ # subtract period to make sure we get first data point
163
+ interval.start_time = Google::Protobuf::Timestamp.new(seconds: (start_time - period).to_i)
164
+
165
+ aggregation = Google::Cloud::Monitoring::V3::Aggregation.new
166
+ # may be better to use ALIGN_NEXT_OLDER for space stats to show most recent data point
167
+ # stick with average for now to match AWS
168
+ aggregation.per_series_aligner = Google::Cloud::Monitoring::V3::Aggregation::Aligner::ALIGN_MEAN
169
+ aggregation.alignment_period = period
170
+
171
+ results = client.list_time_series({
172
+ name: "projects/#{gcp_database_id.split(":").first}",
173
+ filter: "metric.type = \"cloudsql.googleapis.com/database/#{metric_name}\" AND resource.label.database_id = \"#{gcp_database_id}\"",
174
+ interval: interval,
175
+ view: Google::Cloud::Monitoring::V3::ListTimeSeriesRequest::TimeSeriesView::FULL,
176
+ aggregation: aggregation
177
+ })
178
+ else
179
+ require "google/cloud/monitoring"
180
+
181
+ client = Google::Cloud::Monitoring::Metric.new
182
+
183
+ interval = Google::Monitoring::V3::TimeInterval.new
184
+ interval.end_time = Google::Protobuf::Timestamp.new(seconds: end_time.to_i)
185
+ # subtract period to make sure we get first data point
186
+ interval.start_time = Google::Protobuf::Timestamp.new(seconds: (start_time - period).to_i)
187
+
188
+ aggregation = Google::Monitoring::V3::Aggregation.new
189
+ # may be better to use ALIGN_NEXT_OLDER for space stats to show most recent data point
190
+ # stick with average for now to match AWS
191
+ aggregation.per_series_aligner = Google::Monitoring::V3::Aggregation::Aligner::ALIGN_MEAN
192
+ aggregation.alignment_period = period
193
+
194
+ results = client.list_time_series(
195
+ "projects/#{gcp_database_id.split(":").first}",
196
+ "metric.type = \"cloudsql.googleapis.com/database/#{metric_name}\" AND resource.label.database_id = \"#{gcp_database_id}\"",
197
+ interval,
198
+ Google::Monitoring::V3::ListTimeSeriesRequest::TimeSeriesView::FULL,
199
+ aggregation: aggregation
200
+ )
201
+ end
169
202
 
170
203
  data = {}
171
204
  result = results.first