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 +4 -4
- data/CHANGELOG.md +8 -0
- data/app/controllers/pg_hero/home_controller.rb +9 -4
- data/app/views/pg_hero/home/_live_queries_table.html.erb +4 -4
- data/app/views/pg_hero/home/_queries_table.html.erb +2 -2
- data/app/views/pg_hero/home/index.html.erb +1 -4
- data/config/routes.rb +1 -1
- data/lib/pghero/database.rb +1 -1
- data/lib/pghero/engine.rb +7 -0
- data/lib/pghero/methods/basic.rb +14 -2
- data/lib/pghero/methods/indexes.rb +2 -2
- data/lib/pghero/methods/kill.rb +28 -6
- data/lib/pghero/methods/queries.rb +11 -2
- data/lib/pghero/methods/space.rb +1 -1
- data/lib/pghero/methods/suggested_indexes.rb +1 -0
- data/lib/pghero/version.rb +1 -1
- data/lib/pghero.rb +2 -2
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3acbc4629f0e8c8b468669dbcbeeb01ef5e8b3827f891772412a19d92d1c3b76
|
|
4
|
+
data.tar.gz: d9f2f710437de7b76e0d6757be81c36ca68f2469ee9c44ec751972a55d00e20f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
@
|
|
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]).
|
|
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
|
-
<%
|
|
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
|
·
|
|
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">
|
|
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:
|
|
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"
|
data/lib/pghero/database.rb
CHANGED
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
|
data/lib/pghero/methods/basic.rb
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
data/lib/pghero/methods/kill.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/pghero/methods/space.rb
CHANGED
|
@@ -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.
|
|
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
|
data/lib/pghero/version.rb
CHANGED
data/lib/pghero.rb
CHANGED
|
@@ -83,7 +83,7 @@ module PgHero
|
|
|
83
83
|
|
|
84
84
|
# private
|
|
85
85
|
def explain_mode
|
|
86
|
-
|
|
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"].
|
|
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
|
|
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.
|
|
124
|
+
rubygems_version: 4.0.20
|
|
125
125
|
specification_version: 4
|
|
126
126
|
summary: A performance dashboard for Postgres
|
|
127
127
|
test_files: []
|