pgbouncerhero 3.1.0 → 3.2.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +39 -0
  4. data/app/assets/builds/pgbouncerhero/application.css +2 -2
  5. data/app/assets/stylesheets/pgbouncerhero/application.css +11 -1
  6. data/app/controllers/pg_bouncer_hero/database_controller.rb +75 -11
  7. data/app/helpers/pg_bouncer_hero/application_helper.rb +54 -0
  8. data/app/javascript/pgbouncerhero/controllers/data_table_controller.js +183 -0
  9. data/app/javascript/pgbouncerhero/controllers/fleet_controller.js +52 -0
  10. data/app/javascript/pgbouncerhero/controllers/polling_controller.js +49 -5
  11. data/app/views/layouts/pg_bouncer_hero/application.html.erb +1 -1
  12. data/app/views/pg_bouncer_hero/database/_actions.html.erb +1 -1
  13. data/app/views/pg_bouncer_hero/database/_clients.html.erb +9 -8
  14. data/app/views/pg_bouncer_hero/database/_conf.html.erb +10 -9
  15. data/app/views/pg_bouncer_hero/database/_databases.html.erb +30 -9
  16. data/app/views/pg_bouncer_hero/database/_menu.html.erb +13 -4
  17. data/app/views/pg_bouncer_hero/database/_pools.html.erb +10 -9
  18. data/app/views/pg_bouncer_hero/database/_servers.html.erb +31 -0
  19. data/app/views/pg_bouncer_hero/database/_state.html.erb +1 -1
  20. data/app/views/pg_bouncer_hero/database/_stats.html.erb +12 -11
  21. data/app/views/pg_bouncer_hero/database/_users.html.erb +31 -0
  22. data/app/views/pg_bouncer_hero/database/clients.html.erb +4 -2
  23. data/app/views/pg_bouncer_hero/database/conf.html.erb +4 -2
  24. data/app/views/pg_bouncer_hero/database/databases.html.erb +4 -2
  25. data/app/views/pg_bouncer_hero/database/pools.html.erb +4 -2
  26. data/app/views/pg_bouncer_hero/database/servers.html.erb +6 -0
  27. data/app/views/pg_bouncer_hero/database/state.html.erb +4 -2
  28. data/app/views/pg_bouncer_hero/database/stats.html.erb +4 -2
  29. data/app/views/pg_bouncer_hero/database/users.html.erb +6 -0
  30. data/app/views/pg_bouncer_hero/home/_card.html.erb +5 -2
  31. data/app/views/pg_bouncer_hero/home/_card_content.html.erb +48 -31
  32. data/app/views/pg_bouncer_hero/home/index.html.erb +28 -8
  33. data/app/views/shared/_data_table_toolbar.html.erb +13 -0
  34. data/config/routes.rb +6 -0
  35. data/lib/generators/pgbouncerhero/templates/config.yml +3 -1
  36. data/lib/pgbouncerhero/database.rb +4 -0
  37. data/lib/pgbouncerhero/methods/basics.rb +68 -8
  38. data/lib/pgbouncerhero/version.rb +1 -1
  39. data/lib/pgbouncerhero.rb +16 -0
  40. metadata +8 -1
@@ -1,18 +1,38 @@
1
- <div class="bg-white rounded-lg border border-gray-200 shadow-sm mb-6">
2
- <div class="px-4 py-3 border-b border-gray-200">
3
- <h2 class="text-lg font-semibold text-gray-900">Overview</h2>
1
+ <div data-controller="fleet polling" data-polling-interval-value="60000" data-fleet-total-value="<%= @groups.values.sum { |group| group.databases.size } %>" data-action="turbo:frame-load->fleet#refresh turbo:frame-load->polling#refreshed">
2
+ <div class="bg-white rounded-lg border border-gray-200 shadow-xs mb-6">
3
+ <div class="px-4 py-3 border-b border-gray-200 flex flex-wrap items-center justify-between gap-2">
4
+ <div>
5
+ <h2 class="text-lg font-semibold text-gray-900">Fleet health</h2>
6
+ <p class="text-sm text-gray-500" data-fleet-target="headline" aria-live="polite">Checking <%= pluralize(@groups.values.sum { |group| group.databases.size }, "instance") %>…</p>
7
+ </div>
8
+ <span class="text-xs text-gray-400" data-polling-target="status">Loading current status…</span>
9
+ </div>
10
+ <div class="grid grid-cols-1 sm:grid-cols-3 divide-y sm:divide-y-0 sm:divide-x divide-gray-200">
11
+ <div class="px-4 py-3">
12
+ <div class="text-xs font-medium uppercase tracking-wider text-gray-500">Offline</div>
13
+ <div class="text-xl font-semibold text-red-700" data-fleet-target="offline">—</div>
14
+ </div>
15
+ <div class="px-4 py-3">
16
+ <div class="text-xs font-medium uppercase tracking-wider text-gray-500">Waiting clients</div>
17
+ <div class="text-xl font-semibold text-yellow-800" data-fleet-target="waiting">—</div>
18
+ </div>
19
+ <div class="px-4 py-3">
20
+ <div class="text-xs font-medium uppercase tracking-wider text-gray-500">High utilization</div>
21
+ <div class="text-xl font-semibold text-amber-800" data-fleet-target="utilization">—</div>
22
+ </div>
23
+ </div>
4
24
  </div>
5
- </div>
6
25
 
7
- <div data-controller="polling" data-polling-interval-value="60000">
8
26
  <% @groups.each do |_, group| %>
9
27
  <div class="mb-8">
10
28
  <h3 class="text-xl font-semibold text-gray-900 mb-4 pb-2 border-b border-gray-200">
11
29
  <%= group.name %>
12
30
  </h3>
13
- <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
14
- <% group.databases.each do |database| %>
15
- <%= render partial: "card", locals: { database: database } %>
31
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4" data-fleet-target="group">
32
+ <% group.databases.each_with_index do |database, index| %>
33
+ <div data-fleet-target="item" data-fleet-original-index="<%= index %>">
34
+ <%= render partial: "card", locals: { database: database } %>
35
+ </div>
16
36
  <% end %>
17
37
  </div>
18
38
  </div>
@@ -0,0 +1,13 @@
1
+ <div class="flex items-start gap-3 flex-wrap">
2
+ <label class="sr-only" for="<%= table_id %>_search">Search <%= title.downcase %></label>
3
+ <input id="<%= table_id %>_search" type="search" placeholder="Search <%= title.downcase %>…" class="w-56 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700" data-data-table-target="query" data-action="input->data-table#filter">
4
+ <details class="relative text-sm text-gray-700">
5
+ <summary class="cursor-pointer rounded-md border border-gray-300 bg-white px-3 py-1.5 font-medium">Columns</summary>
6
+ <div class="absolute right-0 z-20 mt-2 w-max min-w-64 rounded-md border border-gray-200 bg-white p-3 text-xs shadow-lg">
7
+ <div class="grid grid-cols-2 gap-x-4 gap-y-2" data-data-table-target="columnMenu"></div>
8
+ <button type="button" class="mt-3 w-full border-t border-gray-200 pt-2 text-left font-medium text-gray-600 hover:text-gray-900" data-action="data-table#resetColumns">Reset columns</button>
9
+ </div>
10
+ </details>
11
+ <span class="py-1.5 text-xs text-gray-500" data-data-table-target="status"><%= row_count %> rows</span>
12
+ </div>
13
+ <p class="mt-3 text-sm text-gray-500" data-data-table-target="empty" hidden>No rows match this search.</p>
data/config/routes.rb CHANGED
@@ -13,11 +13,17 @@ PgBouncerHero::Engine.routes.draw do
13
13
  get :stats, controller: :database
14
14
  get :pools, controller: :database
15
15
  get :clients, controller: :database
16
+ get :servers, controller: :database
17
+ get :users, controller: :database
16
18
  get :conf, controller: :database
17
19
  get :state, controller: :database
18
20
  post :reload, controller: :database
19
21
  post :suspend, controller: :database
20
22
  post :resume, controller: :database
23
+ post :pause_database, controller: :database
24
+ post :reconnect_database, controller: :database
25
+ post :wait_close_database, controller: :database
26
+ post :resume_database, controller: :database
21
27
  post :shutdown, controller: :database
22
28
  end
23
29
  end
@@ -5,6 +5,7 @@ pgbouncers:
5
5
  # url: <%%= ENV["PGBOUNCER_PRODUCTION_PRIMARY_DATABASE_URL"] %>
6
6
  # pool_size: 5
7
7
  # pool_timeout: 5
8
+ # read_only: true
8
9
  # Add more databases
9
10
  # replica:
10
11
  # url: <%%= ENV["PGBOUNCER_PRODUCTION_REPLICA_DATABASE_URL"] %>
@@ -17,5 +18,6 @@ pgbouncers:
17
18
  # Time zone (defaults to app time zone)
18
19
  # time_zone: "Pacific Time (US & Canada)"
19
20
 
20
- # Hide and reject Reload, Suspend, Resume, and Shutdown commands
21
+ # Hide and reject administrative commands for every PgBouncer unless an
22
+ # individual PgBouncer sets read_only explicitly
21
23
  # read_only: true
@@ -65,6 +65,10 @@ module PgBouncerHero
65
65
  @url.password if @url
66
66
  end
67
67
 
68
+ def read_only?
69
+ PgBouncerHero.read_only_for?(self)
70
+ end
71
+
68
72
  def dbname
69
73
  @url.path[1..-1] if @url
70
74
  end
@@ -12,9 +12,12 @@ module PgBouncerHero
12
12
  if connection
13
13
  l = lists
14
14
  d = databases
15
+ p = pools
15
16
  l = l.as_json
16
17
  d = d.as_json.reject { |a| a["name"] == "pgbouncer" }
18
+ p = p.as_json
17
19
  l.push({ databases_details: d })
20
+ l.push({ pools_details: p })
18
21
  l
19
22
  end
20
23
  end
@@ -33,6 +36,12 @@ module PgBouncerHero
33
36
  def clients
34
37
  execute("SHOW clients")
35
38
  end
39
+ def servers
40
+ execute("SHOW servers")
41
+ end
42
+ def users
43
+ execute("SHOW users")
44
+ end
36
45
  def conf
37
46
  execute("SHOW config")
38
47
  end
@@ -40,19 +49,70 @@ module PgBouncerHero
40
49
  execute("SHOW state")
41
50
  end
42
51
  def reload
43
- execute("RELOAD")
52
+ instrument_admin_command(:reload) { execute("RELOAD") }
44
53
  end
45
54
  def suspend
46
- execute("SUSPEND")
55
+ instrument_admin_command(:suspend) { execute("SUSPEND") }
56
+ end
57
+ def pause(database_name)
58
+ execute_database_command(:pause, "PAUSE", database_name)
47
59
  end
48
- def resume
49
- execute("RESUME")
60
+ def reconnect(database_name)
61
+ execute_database_command(:reconnect, "RECONNECT", database_name)
62
+ end
63
+ def wait_close(database_name)
64
+ execute_database_command(:wait_close, "WAIT_CLOSE", database_name)
65
+ end
66
+ def resume(database_name = nil)
67
+ return instrument_admin_command(:resume) { execute("RESUME") } if database_name.nil?
68
+
69
+ execute_database_command(:resume, "RESUME", database_name)
50
70
  end
51
71
  def shutdown(mode = nil)
52
- suffix = SHUTDOWN_MODES.fetch(mode)
53
- execute("SHUTDOWN#{suffix}")
54
- rescue KeyError
55
- raise ArgumentError, "unsupported shutdown mode: #{mode.inspect}"
72
+ instrument_admin_command(:shutdown, mode: mode || :immediate) do
73
+ suffix = SHUTDOWN_MODES.fetch(mode)
74
+ execute("SHUTDOWN#{suffix}")
75
+ rescue KeyError
76
+ raise ArgumentError, "unsupported shutdown mode: #{mode.inspect}"
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def execute_database_command(action, command, database_name)
83
+ name = database_name.to_s
84
+ instrument_admin_command(action, target_database: name) do
85
+ raise ArgumentError, "database name must not be empty" if name.empty?
86
+
87
+ execute("#{command} #{PG::Connection.quote_ident(name)}")
88
+ end
89
+ end
90
+
91
+ def instrument_admin_command(action, target_database: nil, **attributes)
92
+ payload = {
93
+ group: group.name,
94
+ database: name,
95
+ action: action,
96
+ target_database: target_database,
97
+ **attributes
98
+ }
99
+
100
+ error = nil
101
+ result = ActiveSupport::Notifications.instrument(PgBouncerHero::ADMIN_COMMAND_EVENT, payload) do
102
+ begin
103
+ command_result = yield
104
+ payload[:outcome] = command_result.nil? ? :unavailable : :success
105
+ command_result
106
+ rescue StandardError => command_error
107
+ error = command_error
108
+ payload[:outcome] = :error
109
+ payload[:error_class] = command_error.class.name
110
+ nil
111
+ end
112
+ end
113
+ raise error if error
114
+
115
+ result
56
116
  end
57
117
  end
58
118
  end
@@ -1,3 +1,3 @@
1
1
  module PgBouncerHero
2
- VERSION = "3.1.0"
2
+ VERSION = "3.2.0"
3
3
  end
data/lib/pgbouncerhero.rb CHANGED
@@ -3,6 +3,7 @@ require "monitor"
3
3
  require "pathname"
4
4
  require "yaml"
5
5
  require "rails"
6
+ require "active_support/notifications"
6
7
  require "pg"
7
8
  require "importmap-rails"
8
9
  require "turbo-rails"
@@ -15,6 +16,8 @@ require "pgbouncerhero/group"
15
16
  require "pgbouncerhero/engine"
16
17
 
17
18
  module PgBouncerHero
19
+ ADMIN_COMMAND_EVENT = "admin_command.pgbouncerhero"
20
+
18
21
  class ConfigurationError < StandardError; end
19
22
 
20
23
  BOOLEAN_SETTINGS = {
@@ -79,6 +82,17 @@ module PgBouncerHero
79
82
 
80
83
  def read_only?
81
84
  value = ENV.fetch("PGBOUNCERHERO_READ_ONLY") { config.fetch("read_only", false) }
85
+ parse_read_only(value)
86
+ end
87
+
88
+ def read_only_for?(database)
89
+ value = ENV.fetch("PGBOUNCERHERO_READ_ONLY") do
90
+ database.config.fetch("read_only") { config.fetch("read_only", false) }
91
+ end
92
+ parse_read_only(value)
93
+ end
94
+
95
+ def parse_read_only(value)
82
96
  BOOLEAN_SETTINGS.fetch(value.is_a?(String) ? value.downcase : value)
83
97
  rescue KeyError
84
98
  raise ConfigurationError, "read_only must be a boolean"
@@ -100,6 +114,8 @@ module PgBouncerHero
100
114
  end
101
115
  end
102
116
 
117
+ private :parse_read_only
118
+
103
119
  private
104
120
 
105
121
  def default_config_path
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgbouncerhero
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.0
4
+ version: 3.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Quentin Rousseau
@@ -265,6 +265,8 @@ files:
265
265
  - app/controllers/pg_bouncer_hero/home_controller.rb
266
266
  - app/helpers/pg_bouncer_hero/application_helper.rb
267
267
  - app/javascript/pgbouncerhero/application.js
268
+ - app/javascript/pgbouncerhero/controllers/data_table_controller.js
269
+ - app/javascript/pgbouncerhero/controllers/fleet_controller.js
268
270
  - app/javascript/pgbouncerhero/controllers/polling_controller.js
269
271
  - app/views/layouts/pg_bouncer_hero/application.html.erb
270
272
  - app/views/pg_bouncer_hero/database/_actions.html.erb
@@ -273,20 +275,25 @@ files:
273
275
  - app/views/pg_bouncer_hero/database/_databases.html.erb
274
276
  - app/views/pg_bouncer_hero/database/_menu.html.erb
275
277
  - app/views/pg_bouncer_hero/database/_pools.html.erb
278
+ - app/views/pg_bouncer_hero/database/_servers.html.erb
276
279
  - app/views/pg_bouncer_hero/database/_state.html.erb
277
280
  - app/views/pg_bouncer_hero/database/_stats.html.erb
281
+ - app/views/pg_bouncer_hero/database/_users.html.erb
278
282
  - app/views/pg_bouncer_hero/database/clients.html.erb
279
283
  - app/views/pg_bouncer_hero/database/conf.html.erb
280
284
  - app/views/pg_bouncer_hero/database/databases.html.erb
281
285
  - app/views/pg_bouncer_hero/database/pools.html.erb
286
+ - app/views/pg_bouncer_hero/database/servers.html.erb
282
287
  - app/views/pg_bouncer_hero/database/state.html.erb
283
288
  - app/views/pg_bouncer_hero/database/stats.html.erb
284
289
  - app/views/pg_bouncer_hero/database/summary.html.erb
290
+ - app/views/pg_bouncer_hero/database/users.html.erb
285
291
  - app/views/pg_bouncer_hero/home/_card.html.erb
286
292
  - app/views/pg_bouncer_hero/home/_card_content.html.erb
287
293
  - app/views/pg_bouncer_hero/home/_card_loading_content.html.erb
288
294
  - app/views/pg_bouncer_hero/home/index.html.erb
289
295
  - app/views/shared/_alert.html.erb
296
+ - app/views/shared/_data_table_toolbar.html.erb
290
297
  - app/views/shared/_flash_messages.html.erb
291
298
  - config/importmap.rb
292
299
  - config/routes.rb