pghero 3.0.1 → 3.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -187,7 +187,7 @@ hr {
187
187
  }
188
188
 
189
189
  #slider-container {
190
- padding: 6px 140px 20px 140px;
190
+ padding: 6px 8px 14px 8px;
191
191
  }
192
192
 
193
193
  .queries-table th a, .space-table th a {
@@ -195,7 +195,7 @@ hr {
195
195
  }
196
196
 
197
197
  #slider {
198
- margin-bottom: 20px;
198
+ margin: 0px 14px 18px 14px;
199
199
  }
200
200
 
201
201
  #range-start {
@@ -263,30 +263,57 @@ module PgHero
263
263
  end
264
264
 
265
265
  def explain
266
+ unless @explain_enabled
267
+ render_text "Explain not enabled", status: :bad_request
268
+ return
269
+ end
270
+
266
271
  @title = "Explain"
267
272
  @query = params[:query]
273
+ @explain_analyze_enabled = PgHero.explain_mode == "analyze"
274
+
268
275
  # TODO use get + token instead of post so users can share links
269
276
  # need to prevent CSRF and DoS
270
- if request.post? && @query
277
+ if request.post? && @query.present?
271
278
  begin
272
- prefix =
279
+ explain_options =
273
280
  case params[:commit]
274
281
  when "Analyze"
275
- "ANALYZE "
282
+ {analyze: true}
276
283
  when "Visualize"
277
- "(ANALYZE, COSTS, VERBOSE, BUFFERS, FORMAT JSON) "
284
+ if @explain_analyze_enabled
285
+ {analyze: true, costs: true, verbose: true, buffers: true, format: "json"}
286
+ else
287
+ {costs: true, verbose: true, format: "json"}
288
+ end
278
289
  else
279
- ""
290
+ {}
280
291
  end
281
- @explanation = @database.explain("#{prefix}#{@query}")
292
+
293
+ if explain_options[:analyze] && !@explain_analyze_enabled
294
+ render_text "Explain analyze not enabled", status: :bad_request
295
+ return
296
+ end
297
+
298
+ @explanation = @database.explain_v2(@query, **explain_options)
282
299
  @suggested_index = @database.suggested_indexes(queries: [@query]).first if @database.suggested_indexes_enabled?
283
300
  @visualize = params[:commit] == "Visualize"
284
301
  rescue ActiveRecord::StatementInvalid => e
285
- @error = e.message
286
-
287
- if @error.include?("bind message supplies 0 parameters")
288
- @error = "Can't explain queries with bind parameters"
289
- end
302
+ message = e.message
303
+ @error =
304
+ if message == "Unsafe statement"
305
+ "Unsafe statement"
306
+ elsif message.start_with?("PG::ProtocolViolation: ERROR: bind message supplies 0 parameters")
307
+ "Can't explain queries with bind parameters"
308
+ elsif message.start_with?("PG::SyntaxError")
309
+ "Syntax error with query"
310
+ elsif message.start_with?("PG::QueryCanceled")
311
+ "Query timed out"
312
+ else
313
+ # default to a generic message
314
+ # since data can be extracted through the Postgres error message
315
+ "Error explaining query"
316
+ end
290
317
  end
291
318
  end
292
319
  end
@@ -368,6 +395,7 @@ module PgHero
368
395
  redirect_backward alert: "The database user does not have permission to enable query stats"
369
396
  end
370
397
 
398
+ # TODO disable if historical query stats enabled?
371
399
  def reset_query_stats
372
400
  success =
373
401
  if @database.server_version_num >= 120000
@@ -409,6 +437,7 @@ module PgHero
409
437
  @query_stats_enabled = @database.query_stats_enabled?
410
438
  @system_stats_enabled = @database.system_stats_enabled?
411
439
  @replica = @database.replica?
440
+ @explain_enabled = PgHero.explain_enabled?
412
441
  end
413
442
 
414
443
  def set_suggested_indexes(min_average_time = 0, min_calls = 0)
@@ -48,7 +48,9 @@
48
48
  <% unless @database.replica? %>
49
49
  <li class="<%= controller.action_name == "maintenance" ? "active" : "" %>"><%= link_to "Maintenance", maintenance_path %></li>
50
50
  <% end %>
51
- <li class="<%= controller.action_name == "explain" ? "active" : "" %>"><%= link_to "Explain", explain_path %></li>
51
+ <% if @explain_enabled %>
52
+ <li class="<%= controller.action_name == "explain" ? "active" : "" %>"><%= link_to "Explain", explain_path %></li>
53
+ <% end %>
52
54
  <li class="<%= controller.action_name == "tune" ? "active" : "" %>"><%= link_to "Tune", tune_path %></li>
53
55
  </ul>
54
56
 
@@ -5,7 +5,9 @@
5
5
  <div class="field"><%= text_area_tag :query, @query, placeholder: "Enter a SQL query" %></div>
6
6
  <p>
7
7
  <%= submit_tag "Explain", class: "btn btn-info", style: "margin-right: 10px;" %>
8
- <%= submit_tag "Analyze", class: "btn btn-danger", style: "margin-right: 10px;" %>
8
+ <% if @explain_analyze_enabled %>
9
+ <%= submit_tag "Analyze", class: "btn btn-danger", style: "margin-right: 10px;" %>
10
+ <% end %>
9
11
  <%= submit_tag "Visualize", class: "btn btn-danger" %>
10
12
  </p>
11
13
  <% end %>
@@ -1,9 +1,11 @@
1
1
  <div class="content">
2
- <% if @query_stats_enabled %>
2
+ <% if @query_stats_enabled && !@historical_query_stats_enabled %>
3
3
  <%= button_to "Reset", reset_query_stats_path, class: "btn btn-danger", style: "float: right;" %>
4
4
  <% end %>
5
5
 
6
- <h1 style="float: left;">Queries</h1>
6
+ <% if !@historical_query_stats_enabled %>
7
+ <h1 style="float: left;">Queries</h1>
8
+ <% end %>
7
9
 
8
10
  <% if @historical_query_stats_enabled %>
9
11
  <%= render partial: "query_stats_slider" %>
@@ -4,7 +4,7 @@
4
4
  highlightQueries()
5
5
  </script>
6
6
 
7
- <% if @explainable_query %>
7
+ <% if @explain_enabled && @explainable_query %>
8
8
  <p>
9
9
  <%= button_to "Explain", explain_path, params: {query: @explainable_query}, form: {target: "_blank"}, class: "btn btn-info" %>
10
10
  </p>
@@ -24,6 +24,9 @@ databases:
24
24
  # Minimum connections for high connections warning
25
25
  # total_connections_threshold: 500
26
26
 
27
+ # Explain functionality
28
+ # explain: true / false / analyze
29
+
27
30
  # Statement timeout for explain
28
31
  # explain_timeout_sec: 10
29
32
 
@@ -1,6 +1,8 @@
1
1
  module PgHero
2
2
  module Methods
3
3
  module Explain
4
+ # TODO remove in 4.0
5
+ # note: this method is not affected by the explain option
4
6
  def explain(sql)
5
7
  sql = squish(sql)
6
8
  explanation = nil
@@ -16,6 +18,23 @@ module PgHero
16
18
  explanation
17
19
  end
18
20
 
21
+ # TODO rename to explain in 4.0
22
+ # note: this method is not affected by the explain option
23
+ def explain_v2(sql, analyze: nil, verbose: nil, costs: nil, settings: nil, buffers: nil, wal: nil, timing: nil, summary: nil, format: "text")
24
+ options = []
25
+ add_explain_option(options, "ANALYZE", analyze)
26
+ add_explain_option(options, "VERBOSE", verbose)
27
+ add_explain_option(options, "SETTINGS", settings)
28
+ add_explain_option(options, "COSTS", costs)
29
+ add_explain_option(options, "BUFFERS", buffers)
30
+ add_explain_option(options, "WAL", wal)
31
+ add_explain_option(options, "TIMING", timing)
32
+ add_explain_option(options, "SUMMARY", summary)
33
+ options << "FORMAT #{explain_format(format)}"
34
+
35
+ explain("(#{options.join(", ")}) #{sql}")
36
+ end
37
+
19
38
  private
20
39
 
21
40
  def explain_safe?
@@ -24,6 +43,21 @@ module PgHero
24
43
  rescue ActiveRecord::StatementInvalid
25
44
  true
26
45
  end
46
+
47
+ def add_explain_option(options, name, value)
48
+ unless value.nil?
49
+ options << "#{name}#{value ? "" : " FALSE"}"
50
+ end
51
+ end
52
+
53
+ # important! validate format to prevent injection
54
+ def explain_format(format)
55
+ if ["text", "xml", "json", "yaml"].include?(format)
56
+ format.upcase
57
+ else
58
+ raise ArgumentError, "Unknown format"
59
+ end
60
+ end
27
61
  end
28
62
  end
29
63
  end
@@ -162,8 +162,9 @@ module PgHero
162
162
  end
163
163
  end
164
164
 
165
- def clean_query_stats
166
- PgHero::QueryStats.where(database: id).where("captured_at < ?", 14.days.ago).delete_all
165
+ def clean_query_stats(before: nil)
166
+ before ||= 14.days.ago
167
+ PgHero::QueryStats.where(database: id).where("captured_at < ?", before).delete_all
167
168
  end
168
169
 
169
170
  def slow_queries(query_stats: nil, **options)
@@ -226,6 +227,7 @@ module PgHero
226
227
  )
227
228
  SELECT
228
229
  query,
230
+ query AS explainable_query,
229
231
  query_hash,
230
232
  query_stats.user,
231
233
  total_minutes,
@@ -243,7 +245,7 @@ module PgHero
243
245
  # we may be able to skip query_columns
244
246
  # in more recent versions of Postgres
245
247
  # as pg_stat_statements should be already normalized
246
- select_all(query, query_columns: [:query])
248
+ select_all(query, query_columns: [:query, :explainable_query])
247
249
  else
248
250
  raise NotEnabled, "Query stats not enabled"
249
251
  end
@@ -129,8 +129,9 @@ 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
132
+ def clean_space_stats(before: nil)
133
+ before ||= 90.days.ago
134
+ PgHero::SpaceStats.where(database: id).where("captured_at < ?", before).delete_all
134
135
  end
135
136
 
136
137
  def space_stats_enabled?
@@ -286,20 +286,24 @@ module PgHero
286
286
  else
287
287
  raise "Not Implemented"
288
288
  end
289
- elsif aexpr && ["=", "<>", ">", ">=", "<", "<=", "~~", "~~*", "BETWEEN"].include?(aexpr.name.first.string.str)
290
- [{column: aexpr.lexpr.column_ref.fields.last.string.str, op: aexpr.name.first.string.str}]
289
+ elsif aexpr && ["=", "<>", ">", ">=", "<", "<=", "~~", "~~*", "BETWEEN"].include?(aexpr.name.first.string.send(str_method))
290
+ [{column: aexpr.lexpr.column_ref.fields.last.string.send(str_method), op: aexpr.name.first.string.send(str_method)}]
291
291
  elsif tree.null_test
292
292
  op = tree.null_test.nulltesttype == :IS_NOT_NULL ? "not_null" : "null"
293
- [{column: tree.null_test.arg.column_ref.fields.last.string.str, op: op}]
293
+ [{column: tree.null_test.arg.column_ref.fields.last.string.send(str_method), op: op}]
294
294
  else
295
295
  raise "Not Implemented"
296
296
  end
297
297
  end
298
298
 
299
+ def str_method
300
+ @str_method ||= Gem::Version.new(PgQuery::VERSION) >= Gem::Version.new("4") ? :sval : :str
301
+ end
302
+
299
303
  def parse_sort(sort_clause)
300
304
  sort_clause.map do |v|
301
305
  {
302
- column: v.sort_by.node.column_ref.fields.last.string.str,
306
+ column: v.sort_by.node.column_ref.fields.last.string.send(str_method),
303
307
  direction: v.sort_by.sortby_dir == :SORTBY_DESC ? "desc" : "asc"
304
308
  }
305
309
  end
@@ -1,3 +1,3 @@
1
1
  module PgHero
2
- VERSION = "3.0.1"
2
+ VERSION = "3.2.0"
3
3
  end
data/lib/pghero.rb CHANGED
@@ -3,27 +3,27 @@ require "active_support"
3
3
  require "forwardable"
4
4
 
5
5
  # methods
6
- require "pghero/methods/basic"
7
- require "pghero/methods/connections"
8
- require "pghero/methods/constraints"
9
- require "pghero/methods/explain"
10
- require "pghero/methods/indexes"
11
- require "pghero/methods/kill"
12
- require "pghero/methods/maintenance"
13
- require "pghero/methods/queries"
14
- require "pghero/methods/query_stats"
15
- require "pghero/methods/replication"
16
- require "pghero/methods/sequences"
17
- require "pghero/methods/settings"
18
- require "pghero/methods/space"
19
- require "pghero/methods/suggested_indexes"
20
- require "pghero/methods/system"
21
- require "pghero/methods/tables"
22
- require "pghero/methods/users"
23
-
24
- require "pghero/database"
25
- require "pghero/engine" if defined?(Rails)
26
- require "pghero/version"
6
+ require_relative "pghero/methods/basic"
7
+ require_relative "pghero/methods/connections"
8
+ require_relative "pghero/methods/constraints"
9
+ require_relative "pghero/methods/explain"
10
+ require_relative "pghero/methods/indexes"
11
+ require_relative "pghero/methods/kill"
12
+ require_relative "pghero/methods/maintenance"
13
+ require_relative "pghero/methods/queries"
14
+ require_relative "pghero/methods/query_stats"
15
+ require_relative "pghero/methods/replication"
16
+ require_relative "pghero/methods/sequences"
17
+ require_relative "pghero/methods/settings"
18
+ require_relative "pghero/methods/space"
19
+ require_relative "pghero/methods/suggested_indexes"
20
+ require_relative "pghero/methods/system"
21
+ require_relative "pghero/methods/tables"
22
+ require_relative "pghero/methods/users"
23
+
24
+ require_relative "pghero/database"
25
+ require_relative "pghero/engine" if defined?(Rails)
26
+ require_relative "pghero/version"
27
27
 
28
28
  module PgHero
29
29
  autoload :Connection, "pghero/connection"
@@ -91,6 +91,16 @@ module PgHero
91
91
  @stats_database_url ||= (file_config || {})["stats_database_url"] || ENV["PGHERO_STATS_DATABASE_URL"]
92
92
  end
93
93
 
94
+ # private
95
+ def explain_enabled?
96
+ explain_mode.nil? || explain_mode == true || explain_mode == "analyze"
97
+ end
98
+
99
+ # private
100
+ def explain_mode
101
+ @config["explain"]
102
+ end
103
+
94
104
  def visualize_url
95
105
  @visualize_url ||= config["visualize_url"] || ENV["PGHERO_VISUALIZE_URL"] || "https://tatiyants.com/pev/#/plans/new"
96
106
  end
@@ -109,7 +119,7 @@ module PgHero
109
119
 
110
120
  config_file_exists = File.exist?(path)
111
121
 
112
- config = YAML.load(ERB.new(File.read(path)).result) if config_file_exists
122
+ config = YAML.safe_load(ERB.new(File.read(path)).result) if config_file_exists
113
123
  config ||= {}
114
124
 
115
125
  @file_config =
@@ -212,15 +222,15 @@ module PgHero
212
222
  # delete previous stats
213
223
  # go database by database to use an index
214
224
  # stats for old databases are not cleaned up since we can't use an index
215
- def clean_query_stats
225
+ def clean_query_stats(before: nil)
216
226
  each_database do |database|
217
- database.clean_query_stats
227
+ database.clean_query_stats(before: before)
218
228
  end
219
229
  end
220
230
 
221
- def clean_space_stats
231
+ def clean_space_stats(before: nil)
222
232
  each_database do |database|
223
- database.clean_space_stats
233
+ database.clean_space_stats(before: before)
224
234
  end
225
235
  end
226
236
 
@@ -22,6 +22,16 @@ namespace :pghero do
22
22
  desc "Remove old query stats"
23
23
  task clean_query_stats: :environment do
24
24
  puts "Deleting old query stats..."
25
- PgHero.clean_query_stats
25
+ options = {}
26
+ options[:before] = Float(ENV["KEEP_DAYS"]).days.ago if ENV["KEEP_DAYS"].present?
27
+ PgHero.clean_query_stats(**options)
28
+ end
29
+
30
+ desc "Remove old space stats"
31
+ task clean_space_stats: :environment do
32
+ puts "Deleting old space stats..."
33
+ options = {}
34
+ options[:before] = Float(ENV["KEEP_DAYS"]).days.ago if ENV["KEEP_DAYS"].present?
35
+ PgHero.clean_space_stats(**options)
26
36
  end
27
37
  end
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2014-2021 Chart.js Contributors
3
+ Copyright (c) 2014-2022 Chart.js Contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
6
 
@@ -1,20 +1,21 @@
1
- Copyright (C) 2020 Sasha Koss and Lesha Koss
2
-
3
- # License
4
-
5
- date-fns is licensed under the [MIT license](http://kossnocorp.mit-license.org).
6
- Read more about MIT at [TLDRLegal](https://tldrlegal.com/license/mit-license).
7
-
8
- ---
9
-
10
- Text from http://kossnocorp.mit-license.org
11
-
12
- The MIT License (MIT)
13
-
14
- Copyright © 2021 Sasha Koss
15
-
16
- Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
17
-
18
- The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
19
-
20
- THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2021 Sasha Koss and Lesha Koss https://kossnocorp.mit-license.org
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,9 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2018-2021 Jukka Kurkela
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pghero
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.1
4
+ version: 3.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kane
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-10-10 00:00:00.000000000 Z
11
+ date: 2023-02-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -103,6 +103,7 @@ files:
103
103
  - licenses/LICENSE-date-fns.txt
104
104
  - licenses/LICENSE-highlight.js.txt
105
105
  - licenses/LICENSE-jquery.txt
106
+ - licenses/LICENSE-kurkle-color.txt
106
107
  - licenses/LICENSE-nouislider.txt
107
108
  homepage: https://github.com/ankane/pghero
108
109
  licenses:
@@ -123,7 +124,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
123
124
  - !ruby/object:Gem::Version
124
125
  version: '0'
125
126
  requirements: []
126
- rubygems_version: 3.3.7
127
+ rubygems_version: 3.4.6
127
128
  signing_key:
128
129
  specification_version: 4
129
130
  summary: A performance dashboard for Postgres