pghero 4.0.1 → 4.1.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: 9a4cfe44cedeb0150487120c8cb8ffa17b319fe1950a0140cfab35b64e00a52b
4
- data.tar.gz: ae1b6e6380230a166ccce42f9da6143364bf4ab58f041de2944e1a80dfc2f403
3
+ metadata.gz: 3acbc4629f0e8c8b468669dbcbeeb01ef5e8b3827f891772412a19d92d1c3b76
4
+ data.tar.gz: d9f2f710437de7b76e0d6757be81c36ca68f2469ee9c44ec751972a55d00e20f
5
5
  SHA512:
6
- metadata.gz: d951a347d8018e5a04aafc24bf724774c6882e88e6b084756eda02d30b49c9cdea013b223c17c401dbe5d342f42b18687c1d332ac982e766efae5296ca4de359
7
- data.tar.gz: 2d20b1f83a9d7d13e38f632f8f30d8c69c83a1e4d82351d51e3b8351dc0fc53bcad10202c9a7c88532457deeec8fa30bfef7dfc8b9c95f6d6311e87350593f98
6
+ metadata.gz: eecc0a4dce22f7203e7f18928ea9537cb35e4f9b7814ed68cf216b669795bfaa85ec33bd31b94e86c523564d1965058d19fed919471e3882cccd6762be6c7a15
7
+ data.tar.gz: 64d275ba685349df7be06a829927f5a711a36c33afffc4af43bc3bb916b819b3978e0f51fa01c7085d57406dc9fff7aeadd8796c2d772c2c38790259879a87b1
data/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 4.1.0 (2026-10-20)
2
+
3
+ - Restricted killing queries to current database and certain backend types - [more info](https://github.com/ankane/pghero/security/advisories/GHSA-5287-hmjh-qmqq)
4
+ - Added grouping for parallel queries
5
+ - Fixed error when index is dropped during indexes query
6
+ - Fixed `unable to filter data` messages
7
+ - Removed replication backend types from live queries page
8
+
1
9
  ## 4.0.1 (2026-08-15)
2
10
 
3
11
  - Added sequences from identity columns
@@ -36,8 +36,7 @@ module PgHero
36
36
  @inactive_replication_slots = @database.replication_slots.select { |r| !r[:active] }
37
37
  end
38
38
 
39
- @walsender_queries, long_running_queries = @database.long_running_queries.partition { |q| q[:backend_type] == "walsender" }
40
- @autovacuum_queries, @long_running_queries = long_running_queries.partition { |q| q[:query].starts_with?("autovacuum:") }
39
+ @autovacuum_queries, @long_running_queries = @database.long_running_queries.partition { |q| q[:backend_type] == "autovacuum worker" }
41
40
 
42
41
  connection_states = @database.connection_states
43
42
  @total_connections = connection_states.values.sum
@@ -416,21 +415,27 @@ module PgHero
416
415
  end
417
416
 
418
417
  def kill
419
- if @database.kill(params[:pid])
418
+ if @database.kill(params[:pid], key: params[:key].to_i)
420
419
  redirect_backward notice: "Query killed"
421
420
  else
422
421
  redirect_backward notice: "Query no longer running"
423
422
  end
423
+ rescue ActiveRecord::StatementInvalid
424
+ redirect_backward alert: "The database user does not have permission to kill this query"
424
425
  end
425
426
 
426
427
  def kill_long_running_queries
427
428
  @database.kill_long_running_queries
428
429
  redirect_backward notice: "Queries killed"
430
+ rescue ActiveRecord::StatementInvalid
431
+ redirect_backward alert: "The database user does not have permission to kill some queries"
429
432
  end
430
433
 
431
434
  def kill_all
432
435
  @database.kill_all
433
436
  redirect_backward notice: "Connections killed"
437
+ rescue ActiveRecord::StatementInvalid
438
+ redirect_backward alert: "The database user does not have permission to kill some connections"
434
439
  end
435
440
 
436
441
  def enable_query_stats
@@ -528,7 +533,7 @@ module PgHero
528
533
  end
529
534
 
530
535
  def group_connections_by_key(connections, key)
531
- group_connections(connections, [key]).map { |v| [v[key], v[:total_connections]] }.to_h
536
+ group_connections(connections, [key]).to_h { |v| [v[key], v[:total_connections]] }
532
537
  end
533
538
 
534
539
  def check_api
@@ -10,7 +10,7 @@
10
10
  <tbody>
11
11
  <% queries.reverse.each do |query| %>
12
12
  <tr>
13
- <td><%= query[:pid] %></td>
13
+ <td><%= ([query[:pid]] + query[:worker_pids]).join(", ") %></td>
14
14
  <td>
15
15
  <% if query[:duration_ms] %>
16
16
  <% sec = query[:duration_ms] / 1000.0 %>
@@ -32,11 +32,11 @@
32
32
  <% end %>
33
33
  </td>
34
34
  <td class="text-right">
35
- <% unless @database.filter_data %>
35
+ <% if @explain_enabled && (!@database.filter_data || @database.server_version_num >= 160000) %>
36
36
  <%= button_to "Explain", explain_path, params: {query: query[:query]}, form: {target: "_blank"}, class: "btn btn-info" %>
37
37
  <% end %>
38
- <% if @kill_enabled %>
39
- <%= button_to "Kill", kill_path(pid: query[:pid]), class: "btn btn-danger" %>
38
+ <% if @kill_enabled && ["client backend", "autovacuum worker"].include?(query[:backend_type]) %>
39
+ <%= button_to "Kill", kill_path(pid: query[:pid], key: query[:started_at].usec), class: "btn btn-danger" %>
40
40
  <% end %>
41
41
  </td>
42
42
  </tr>
@@ -46,7 +46,7 @@
46
46
  <span class="user">
47
47
  <% if query[:user] %>
48
48
  <%= @link_user ? link_to(query[:user], {user: query[:user]}.merge(@link_options)) : query[:user] %>
49
- <% if @show_details %>
49
+ <% if @show_details && query[:query_hash] %>
50
50
  &middot;
51
51
  <% end %>
52
52
  <% end %>
@@ -61,7 +61,7 @@
61
61
  <td colspan="3" class="query-row">
62
62
  <pre><code class="query-code language-pgsql"><%= query[:query] %></code></pre>
63
63
  <% if query[:query] == "<insufficient privilege>" %>
64
- <p class="text-muted">For security reasons, only superusers can see queries executed by other users.</p>
64
+ <p class="text-muted">Add the <code>pg_read_all_stats</code> role to see queries executed by other users.</p>
65
65
  <% end %>
66
66
  <% if local_assigns[:suggested_indexes] != false && (details = @suggested_indexes_by_query[query[:query]]) && (details[:index] || @debug) %>
67
67
  <%= render partial: "suggested_index", locals: {index: details[:index], details: details} %>
@@ -26,9 +26,6 @@
26
26
  <% if @autovacuum_queries.any? %>
27
27
  <span class="tiny"><%= @autovacuum_queries.size %> autovacuum</span>
28
28
  <% end %>
29
- <% if @walsender_queries.any? %>
30
- <span class="tiny"><%= @walsender_queries.size %> walsender</span>
31
- <% end %>
32
29
  </div>
33
30
  <% if @extended %>
34
31
  <div class="alert alert-<%= @good_cache_rate ? "success" : "warning" %>">
@@ -68,7 +65,7 @@
68
65
  <% end %>
69
66
  </div>
70
67
  <div class="alert alert-<%= @invalid_indexes.empty? && @invalid_constraints.empty? ? "success" : "warning" %>">
71
- <% if @invalid_indexes.empty? && @invalid_constraints.empty? %>
68
+ <% if @invalid_indexes.empty? && @invalid_constraints.empty? %>
72
69
  No invalid indexes or constraints
73
70
  <% else %>
74
71
  <% if @invalid_indexes.any? %>
data/config/routes.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  PgHero::Engine.routes.draw do
2
- scope "(:database)", constraints: proc { |req| (PgHero.config["databases"].keys + [nil]).include?(req.params[:database]) } do
2
+ scope "(:database)", constraints: PgHero::DatabaseConstraint.new do
3
3
  get "space", to: "home#space"
4
4
  get "space/:relation", to: "home#relation_space", as: :relation_space
5
5
  get "index_bloat", to: "home#index_bloat"
@@ -98,7 +98,7 @@ module PgHero
98
98
  if config.key?("filter_data")
99
99
  config["filter_data"]
100
100
  elsif PgHero.config.key?("filter_data")
101
- PgHero.config.key?("filter_data")
101
+ PgHero.config["filter_data"]
102
102
  else
103
103
  PgHero.filter_data
104
104
  end
data/lib/pghero/engine.rb CHANGED
@@ -14,4 +14,11 @@ module PgHero
14
14
  PgHero.time_zone = file_config["time_zone"] if file_config["time_zone"]
15
15
  end
16
16
  end
17
+
18
+ class DatabaseConstraint
19
+ def matches?(request)
20
+ database = request.params[:database]
21
+ database.nil? || PgHero.config["databases"].key?(database)
22
+ end
23
+ end
17
24
  end
@@ -66,8 +66,20 @@ module PgHero
66
66
  if filter_data
67
67
  query_columns.each do |column|
68
68
  result.each do |row|
69
+ value = row[column]
70
+ if value.nil? || value == "<insufficient privilege>"
71
+ next
72
+ end
73
+
74
+ if value.start_with?("autovacuum:")
75
+ prefix = "autovacuum:"
76
+ value = value.delete_prefix("autovacuum:")
77
+ else
78
+ prefix = ""
79
+ end
80
+
69
81
  begin
70
- row[column] = PgQuery.normalize(row[column]) unless row[column].nil?
82
+ row[column] = prefix + PgQuery.normalize(value)
71
83
  rescue PgQuery::ParseError
72
84
  # try replacing "interval $1" with "$1::interval"
73
85
  # see https://github.com/lfittl/pg_query/issues/169 for more info
@@ -75,7 +87,7 @@ module PgHero
75
87
  # we could skip normalization
76
88
  # but this has a very small chance of data leakage
77
89
  begin
78
- row[column] = PgQuery.normalize(row[column].gsub(/\binterval\s+(\$\d+)\b/i, "\\1::interval"))
90
+ row[column] = prefix + PgQuery.normalize(value.gsub(/\binterval\s+(\$\d+)\b/i, "\\1::interval"))
79
91
  rescue PgQuery::ParseError
80
92
  row[column] = "<unable to filter data>"
81
93
  end
@@ -154,7 +154,7 @@ module PgHero
154
154
  ORDER BY
155
155
  1, 2
156
156
  SQL
157
- ).map { |v| v[:columns] = v[:columns].sub(") WHERE (", " WHERE ").split(", ").map { |c| unquote(c) }; v }
157
+ ).select { |v| v[:definition] }.map { |v| v[:columns] = v[:columns].sub(") WHERE (", " WHERE ").split(", ").map { |c| unquote(c) }; v }
158
158
 
159
159
  # determine if any invalid indexes being created
160
160
  # hacky, but works for simple cases
@@ -322,7 +322,7 @@ module PgHero
322
322
  wastedbytes DESC,
323
323
  index_name
324
324
  SQL
325
- select_all(sql, {min_size: min_size.to_i})
325
+ select_all(sql, {min_size: min_size.to_i}).select { |v| v[:definition] }
326
326
  end
327
327
 
328
328
  protected
@@ -2,29 +2,51 @@ module PgHero
2
2
  module Methods
3
3
  module Kill
4
4
  # note: this method is not affected by disable_kill option
5
- def kill(pid)
6
- select_one("SELECT pg_terminate_backend(:pid)", {pid: pid.to_i})
5
+ def kill(pid, key: nil)
6
+ kill_backends(pid: pid, key: key, backend_type: ["client backend", "autovacuum worker"]).any? { |v| v[:pg_terminate_backend] }
7
7
  end
8
8
 
9
9
  # note: this method is not affected by disable_kill option
10
10
  def kill_long_running_queries(min_duration: nil)
11
- running_queries(min_duration: min_duration || long_running_query_sec).each { |query| kill(query[:pid]) }
11
+ kill_backends(min_duration: min_duration || long_running_query_sec)
12
12
  true
13
13
  end
14
14
 
15
15
  # note: this method is not affected by disable_kill option
16
16
  def kill_all
17
- select_all <<~SQL
17
+ kill_backends
18
+ true
19
+ end
20
+
21
+ private
22
+
23
+ def kill_backends(pid: nil, key: nil, min_duration: nil, backend_type: ["client backend"])
24
+ select_expr = "pg_terminate_backend(pid)"
25
+ # key is used to reduce chance of killing different query
26
+ # check *after* row matches other conditions
27
+ if key
28
+ select_expr = "CASE WHEN EXTRACT(MICROSECONDS FROM COALESCE(query_start, xact_start)) % 1000000 = :key THEN #{select_expr} ELSE NULL END AS pg_terminate_backend"
29
+ end
30
+ query = <<~SQL
18
31
  SELECT
19
- pg_terminate_backend(pid)
32
+ #{select_expr}
20
33
  FROM
21
34
  pg_stat_activity
22
35
  WHERE
23
36
  pid <> pg_backend_pid()
24
37
  AND query <> '<insufficient privilege>'
25
38
  AND datname = current_database()
39
+ AND backend_type IN (:backend_type)
40
+ #{"AND pid = :pid" if pid}
41
+ #{"AND state <> 'idle' AND NOW() - COALESCE(query_start, xact_start) > interval :min_duration" if min_duration}
26
42
  SQL
27
- true
43
+
44
+ binds = {}
45
+ binds[:backend_type] = Array(backend_type)
46
+ binds[:pid] = pid.to_i if pid
47
+ binds[:key] = key.to_i if key
48
+ binds[:min_duration] = "#{min_duration.to_i} seconds" if min_duration
49
+ select_all(query, binds)
28
50
  end
29
51
  end
30
52
  end
@@ -5,6 +5,7 @@ module PgHero
5
5
  query = <<~SQL
6
6
  SELECT
7
7
  pid,
8
+ leader_pid,
8
9
  state,
9
10
  application_name AS source,
10
11
  age(NOW(), COALESCE(query_start, xact_start)) AS duration,
@@ -20,6 +21,7 @@ module PgHero
20
21
  state <> 'idle'
21
22
  AND pid <> pg_backend_pid()
22
23
  AND datname = current_database()
24
+ AND backend_type IN ('client backend', 'parallel worker', 'autovacuum worker')
23
25
  #{"AND NOW() - COALESCE(query_start, xact_start) > interval :min_duration" if min_duration}
24
26
  #{"AND query <> '<insufficient privilege>'" if !all}
25
27
  ORDER BY
@@ -28,7 +30,14 @@ module PgHero
28
30
 
29
31
  binds = {}
30
32
  binds[:min_duration] = "#{min_duration.to_i} seconds" if min_duration
31
- select_all(query, binds, query_columns: [:query])
33
+ queries = select_all(query, binds, query_columns: [:query])
34
+ groups = queries.group_by { |v| v[:leader_pid] || v[:pid] }
35
+ groups.map do |pid, queries|
36
+ leader = queries.find { |v| v[:leader_pid].nil? } || queries.first
37
+ leader.delete(:leader_pid)
38
+ leader[:worker_pids] = queries.filter_map { |v| v[:pid] if v.key?(:leader_pid) }
39
+ leader
40
+ end
32
41
  end
33
42
 
34
43
  def long_running_queries
@@ -67,10 +76,10 @@ module PgHero
67
76
  NOT blockedl.granted
68
77
  AND blockeda.query <> '<insufficient privilege>'
69
78
  AND blockeda.datname = current_database()
79
+ AND blockeda.backend_type IN ('client backend', 'parallel worker', 'autovacuum worker')
70
80
  ORDER BY
71
81
  blocked_duration DESC
72
82
  SQL
73
-
74
83
  select_all(query, query_columns: [:blocked_query, :current_or_recent_query_in_blocking_process])
75
84
  end
76
85
  end
@@ -93,7 +93,7 @@ module PgHero
93
93
  def relation_space_stats(relation, schema: "public")
94
94
  if space_stats_enabled?
95
95
  relation_sizes ||= self.relation_sizes
96
- sizes = relation_sizes.map { |r| [[r[:schema], r[:relation]], r[:size_bytes]] }.to_h
96
+ sizes = relation_sizes.to_h { |r| [[r[:schema], r[:relation]], r[:size_bytes]] }
97
97
  start_at = 30.days.ago
98
98
 
99
99
  sql = <<~SQL
@@ -198,6 +198,7 @@ module PgHero
198
198
  def best_index_structure(statement)
199
199
  return {error: "Empty statement"} if statement.to_s.empty?
200
200
  return {error: "Too large"} if statement.to_s.length > 10000
201
+ return {error: "Insufficient privilege"} if statement == "<insufficient privilege>"
201
202
 
202
203
  begin
203
204
  tree = PgQuery.parse(statement).tree
@@ -1,3 +1,3 @@
1
1
  module PgHero
2
- VERSION = "4.0.1"
2
+ VERSION = "4.1.0"
3
3
  end
data/lib/pghero.rb CHANGED
@@ -83,7 +83,7 @@ module PgHero
83
83
 
84
84
  # private
85
85
  def explain_mode
86
- @config["explain"]
86
+ config["explain"]
87
87
  end
88
88
 
89
89
  def visualize_url
@@ -163,7 +163,7 @@ module PgHero
163
163
  # return if another process initialized while we were waiting
164
164
  return @databases if defined?(@databases)
165
165
 
166
- @databases = config["databases"].map { |id, c| [id.to_sym, Database.new(id, c)] }.to_h
166
+ @databases = config["databases"].to_h { |id, c| [id.to_sym, Database.new(id, c)] }
167
167
  end
168
168
  end
169
169
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pghero
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.1
4
+ version: 4.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kane
@@ -121,7 +121,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
121
121
  - !ruby/object:Gem::Version
122
122
  version: '0'
123
123
  requirements: []
124
- rubygems_version: 4.0.16
124
+ rubygems_version: 4.0.20
125
125
  specification_version: 4
126
126
  summary: A performance dashboard for Postgres
127
127
  test_files: []