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
@@ -2,26 +2,70 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  export default class extends Controller {
4
4
  static values = { interval: { type: Number, default: 60000 } }
5
+ static targets = ["status"]
5
6
 
6
7
  connect() {
8
+ this.visibilityChanged = this.visibilityChanged.bind(this)
9
+ document.addEventListener("visibilitychange", this.visibilityChanged)
7
10
  this.startPolling()
8
11
  }
9
12
 
10
13
  disconnect() {
14
+ document.removeEventListener("visibilitychange", this.visibilityChanged)
11
15
  this.stopPolling()
12
16
  }
13
17
 
14
18
  startPolling() {
15
- this.timer = setInterval(() => {
16
- this.element.querySelectorAll("turbo-frame[src]").forEach(frame => {
17
- frame.reload()
18
- })
19
- }, this.intervalValue)
19
+ this.stopPolling()
20
+ if (document.hidden) return
21
+
22
+ this.timer = setInterval(() => this.refresh(), this.intervalValue)
20
23
  }
21
24
 
22
25
  stopPolling() {
23
26
  if (this.timer) {
24
27
  clearInterval(this.timer)
28
+ this.timer = null
25
29
  }
26
30
  }
31
+
32
+ refresh() {
33
+ if (document.hidden) return
34
+
35
+ let refreshing = false
36
+
37
+ this.element.querySelectorAll("turbo-frame").forEach(frame => {
38
+ if (frame.hasAttribute("busy")) return
39
+
40
+ if (frame.src) {
41
+ frame.reload()
42
+ refreshing = true
43
+ } else if (frame.dataset.pollingRefreshUrl) {
44
+ frame.src = frame.dataset.pollingRefreshUrl
45
+ refreshing = true
46
+ }
47
+ })
48
+
49
+ if (refreshing) this.updateStatus("Refreshing…")
50
+ }
51
+
52
+ refreshed() {
53
+ const time = new Intl.DateTimeFormat([], { timeStyle: "medium" }).format(new Date())
54
+ this.updateStatus(`Updated ${time}`)
55
+ }
56
+
57
+ visibilityChanged() {
58
+ if (document.hidden) {
59
+ this.stopPolling()
60
+ } else {
61
+ this.refresh()
62
+ this.startPolling()
63
+ }
64
+ }
65
+
66
+ updateStatus(message) {
67
+ this.statusTargets.forEach(status => {
68
+ status.textContent = message
69
+ })
70
+ }
27
71
  }
@@ -12,7 +12,7 @@
12
12
  <%= csrf_meta_tags %>
13
13
  </head>
14
14
  <body class="bg-gray-100 min-h-screen">
15
- <nav class="bg-white border-b border-gray-200 fixed top-0 left-0 right-0 z-50 shadow-sm">
15
+ <nav class="bg-white border-b border-gray-200 fixed top-0 left-0 right-0 z-50 shadow-xs">
16
16
  <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
17
17
  <div class="flex items-center h-14">
18
18
  <%= link_to "PgBouncerHero", root_path, class: "text-lg font-bold text-gray-900 hover:text-gray-700" %>
@@ -1,4 +1,4 @@
1
- <div class="bg-white rounded-lg border border-gray-200 shadow-sm p-4">
1
+ <div class="bg-white rounded-lg border border-gray-200 shadow-xs p-4">
2
2
  <h4 class="text-base font-semibold text-gray-900 mb-3">Actions</h4>
3
3
  <div class="flex flex-wrap gap-2">
4
4
  <button class="px-3 py-1.5 text-sm font-medium rounded-md border border-red-300 text-red-700 hover:bg-red-50">Pause</button>
@@ -1,21 +1,22 @@
1
- <div class="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
2
- <div class="px-4 py-3 border-b border-gray-200">
3
- <h4 class="text-base font-semibold text-gray-900">Clients</h4>
1
+ <div class="bg-white rounded-lg border border-gray-200 shadow-xs" data-controller="data-table" data-data-table-default-hidden-value='["type","replication","local_addr","local_port","request_time","wait_us","close_needed","ptr","link","remote_pid","tls","prepared_statements"]'>
2
+ <div class="px-4 py-3 border-b border-gray-200 flex items-start justify-between gap-3 flex-wrap">
3
+ <h4 class="text-base font-semibold text-gray-900 py-1.5">Clients</h4>
4
+ <%= render "shared/data_table_toolbar", title: "Clients", table_id: "clients", row_count: clients.to_a.size %>
4
5
  </div>
5
- <div class="overflow-x-auto">
6
+ <div class="overflow-x-auto rounded-b-lg">
6
7
  <table class="min-w-full divide-y divide-gray-200">
7
8
  <thead class="bg-gray-50">
8
9
  <tr>
9
10
  <% clients.first.keys.each do |key| %>
10
- <th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"><%= key.titleize %></th>
11
+ <th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider <%= "sticky left-0 z-10 bg-gray-50" if key == "database" %>" data-column-key="<%= key %>"><%= key.titleize %></th>
11
12
  <% end %>
12
13
  </tr>
13
14
  </thead>
14
15
  <tbody class="bg-white divide-y divide-gray-200">
15
16
  <% clients.each do |row| %>
16
- <tr class="hover:bg-gray-50">
17
- <% row.each do |_k, v| %>
18
- <td class="px-4 py-2 text-sm text-gray-700 whitespace-nowrap"><%= v %></td>
17
+ <tr class="group hover:bg-gray-50" data-data-table-target="row">
18
+ <% row.each do |key, value| %>
19
+ <td class="px-4 py-2 text-sm text-gray-700 whitespace-nowrap <%= "sticky left-0 bg-white group-hover:bg-gray-50" if key == "database" %>"><%= value %></td>
19
20
  <% end %>
20
21
  </tr>
21
22
  <% end %>
@@ -1,21 +1,22 @@
1
- <div class="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
2
- <div class="px-4 py-3 border-b border-gray-200">
3
- <h4 class="text-base font-semibold text-gray-900">Configuration</h4>
1
+ <div class="bg-white rounded-lg border border-gray-200 shadow-xs" data-controller="data-table" data-data-table-default-hidden-value='["default","changeable"]'>
2
+ <div class="px-4 py-3 border-b border-gray-200 flex items-start justify-between gap-3 flex-wrap">
3
+ <h4 class="text-base font-semibold text-gray-900 py-1.5">Configuration</h4>
4
+ <%= render "shared/data_table_toolbar", title: "Configuration", table_id: "configuration", row_count: conf.to_a.size %>
4
5
  </div>
5
- <div class="overflow-x-auto">
6
+ <div class="overflow-x-auto rounded-b-lg">
6
7
  <table class="min-w-full divide-y divide-gray-200">
7
8
  <thead class="bg-gray-50">
8
9
  <tr>
9
- <% conf.first.keys.each do |key| %>
10
- <th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"><%= key.titleize %></th>
10
+ <% conf.first.keys.each_with_index do |key, index| %>
11
+ <th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider <%= "sticky left-0 z-10 bg-gray-50" if index.zero? %>" data-column-key="<%= key %>"><%= key.titleize %></th>
11
12
  <% end %>
12
13
  </tr>
13
14
  </thead>
14
15
  <tbody class="bg-white divide-y divide-gray-200">
15
16
  <% conf.each do |row| %>
16
- <tr class="hover:bg-gray-50">
17
- <% row.each do |_k, v| %>
18
- <td class="px-4 py-2 text-sm text-gray-700 whitespace-nowrap"><%= v %></td>
17
+ <tr class="group hover:bg-gray-50" data-data-table-target="row">
18
+ <% row.each_value.with_index do |value, index| %>
19
+ <td class="px-4 py-2 text-sm text-gray-700 whitespace-nowrap <%= "sticky left-0 bg-white group-hover:bg-gray-50" if index.zero? %>"><%= value %></td>
19
20
  <% end %>
20
21
  </tr>
21
22
  <% end %>
@@ -1,21 +1,42 @@
1
- <div class="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
2
- <div class="px-4 py-3 border-b border-gray-200">
3
- <h4 class="text-base font-semibold text-gray-900">Databases</h4>
1
+ <div class="bg-white rounded-lg border border-gray-200 shadow-xs" data-controller="data-table" data-data-table-default-hidden-value='["force_user","min_pool_size","reserve_pool","server_lifetime","pool_mode","max_connections","paused","disabled"]'>
2
+ <div class="px-4 py-3 border-b border-gray-200 flex items-start justify-between gap-3 flex-wrap">
3
+ <h4 class="text-base font-semibold text-gray-900 py-1.5">Databases</h4>
4
+ <%= render "shared/data_table_toolbar", title: "Databases", table_id: "databases", row_count: databases.to_a.size %>
4
5
  </div>
5
- <div class="overflow-x-auto">
6
+ <div class="overflow-x-auto rounded-b-lg">
6
7
  <table class="min-w-full divide-y divide-gray-200">
7
8
  <thead class="bg-gray-50">
8
9
  <tr>
9
- <% databases.first.keys.each do |key| %>
10
- <th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"><%= key.titleize %></th>
10
+ <% databases.first.keys.each_with_index do |key, index| %>
11
+ <th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider <%= "sticky left-0 z-10 bg-gray-50" if index.zero? %>" data-column-key="<%= key %>"><%= key.titleize %></th>
12
+ <% end %>
13
+ <% unless database.read_only? %>
14
+ <th class="sticky right-0 z-10 min-w-52 bg-gray-50 px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-column-key="maintenance" data-column-locked="true">Maintenance</th>
11
15
  <% end %>
12
16
  </tr>
13
17
  </thead>
14
18
  <tbody class="bg-white divide-y divide-gray-200">
15
19
  <% databases.each do |row| %>
16
- <tr class="hover:bg-gray-50">
17
- <% row.each do |_k, v| %>
18
- <td class="px-4 py-2 text-sm text-gray-700 whitespace-nowrap"><%= v %></td>
20
+ <tr class="group hover:bg-gray-50" data-data-table-target="row">
21
+ <% row.each_value.with_index do |value, index| %>
22
+ <td class="px-4 py-2 text-sm text-gray-700 whitespace-nowrap <%= "sticky left-0 bg-white group-hover:bg-gray-50" if index.zero? %>"><%= value %></td>
23
+ <% end %>
24
+ <% unless database.read_only? %>
25
+ <td class="sticky right-0 min-w-52 bg-white px-4 py-2 whitespace-nowrap group-hover:bg-gray-50">
26
+ <% target_database = row["name"].to_s %>
27
+ <% unless target_database == "pgbouncer" %>
28
+ <div class="grid grid-cols-2 gap-2">
29
+ <% if row["paused"].to_i.zero? %>
30
+ <%= button_to "Pause", pause_database_path(database: database.name.parameterize), method: :post, params: { target_database: target_database }, class: "w-full px-2 py-1 text-xs font-medium rounded-md border border-amber-300 text-amber-800 hover:bg-amber-50", form: { data: { turbo_confirm: "Pause #{target_database}? New client queries will wait until this database is resumed." } } %>
31
+ <% end %>
32
+ <%= button_to "Reconnect", reconnect_database_path(database: database.name.parameterize), method: :post, params: { target_database: target_database }, class: "w-full px-2 py-1 text-xs font-medium rounded-md border border-blue-300 text-blue-700 hover:bg-blue-50", form: { data: { turbo_confirm: "Reconnect released server connections for #{target_database}?" } } %>
33
+ <%= button_to "Wait close", wait_close_database_path(database: database.name.parameterize), method: :post, params: { target_database: target_database }, class: "w-full px-2 py-1 text-xs font-medium rounded-md border border-gray-300 text-gray-700 hover:bg-gray-50", form: { data: { turbo_confirm: "Wait until all marked server connections for #{target_database} have closed?" } } %>
34
+ <% if row["paused"].to_i.positive? %>
35
+ <%= button_to "Resume", resume_database_path(database: database.name.parameterize), method: :post, params: { target_database: target_database }, class: "w-full px-2 py-1 text-xs font-medium rounded-md border border-green-300 text-green-700 hover:bg-green-50", form: { data: { turbo_confirm: "Resume client processing for #{target_database}?" } } %>
36
+ <% end %>
37
+ </div>
38
+ <% end %>
39
+ </td>
19
40
  <% end %>
20
41
  </tr>
21
42
  <% end %>
@@ -1,4 +1,7 @@
1
- <nav class="bg-white rounded-lg border border-gray-200 shadow-sm mb-6">
1
+ <% state_rows = @admin_state&.to_a || [] %>
2
+ <% state_known = state_rows.any? %>
3
+ <% resumable = state_rows.any? { |row| %w[paused suspended].include?(row["key"]) && row["value"] == "yes" } %>
4
+ <nav class="bg-white rounded-lg border border-gray-200 shadow-xs mb-6">
2
5
  <div class="px-4 py-3 border-b border-gray-200 flex items-center justify-between flex-wrap gap-2">
3
6
  <div class="flex items-center gap-4 flex-wrap">
4
7
  <span class="font-semibold text-gray-900"><%= "#{database.group.name} > #{database.name}" %></span>
@@ -6,16 +9,22 @@
6
9
  <%= link_to "Stats", stats_path(database: database.name.parameterize), class: "px-3 py-1.5 text-sm rounded-md #{is_active('stats') ? 'bg-gray-900 text-white' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'}" %>
7
10
  <%= link_to "Pools", pools_path(database: database.name.parameterize), class: "px-3 py-1.5 text-sm rounded-md #{is_active('pools') ? 'bg-gray-900 text-white' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'}" %>
8
11
  <%= link_to "Clients", clients_path(database: database.name.parameterize), class: "px-3 py-1.5 text-sm rounded-md #{is_active('clients') ? 'bg-gray-900 text-white' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'}" %>
12
+ <%= link_to "Servers", servers_path(database: database.name.parameterize), class: "px-3 py-1.5 text-sm rounded-md #{is_active('servers') ? 'bg-gray-900 text-white' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'}" %>
13
+ <%= link_to "Users", users_path(database: database.name.parameterize), class: "px-3 py-1.5 text-sm rounded-md #{is_active('users') ? 'bg-gray-900 text-white' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'}" %>
9
14
  <%= link_to "Configuration", conf_path(database: database.name.parameterize), class: "px-3 py-1.5 text-sm rounded-md #{is_active('conf') ? 'bg-gray-900 text-white' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'}" %>
10
15
  <%= link_to "State", state_path(database: database.name.parameterize), class: "px-3 py-1.5 text-sm rounded-md #{is_active('state') ? 'bg-gray-900 text-white' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'}" %>
11
16
  </div>
12
17
  <div class="flex items-center gap-2">
13
- <% if PgBouncerHero.read_only? %>
18
+ <% if database.read_only? %>
14
19
  <span class="inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 ring-1 ring-gray-300 ring-inset">Read-only</span>
15
20
  <% else %>
16
21
  <%= button_to "Reload", reload_path(database: database.name.parameterize), method: :post, class: "px-3 py-1.5 text-sm font-medium rounded-md border border-green-300 text-green-700 hover:bg-green-50", form: { data: { turbo_confirm: "Reload PgBouncer configuration?" } } %>
17
- <%= button_to "Suspend", suspend_path(database: database.name.parameterize), method: :post, class: "px-3 py-1.5 text-sm font-medium rounded-md border border-blue-300 text-blue-700 hover:bg-blue-50", form: { data: { turbo_confirm: "Suspend all PgBouncer socket processing until Resume is selected?" } } %>
18
- <%= button_to "Resume", resume_path(database: database.name.parameterize), method: :post, class: "px-3 py-1.5 text-sm font-medium rounded-md border border-green-300 text-green-700 hover:bg-green-50", form: { data: { turbo_confirm: "Resume PgBouncer socket processing?" } } %>
22
+ <% if !state_known || !resumable %>
23
+ <%= button_to "Suspend", suspend_path(database: database.name.parameterize), method: :post, class: "px-3 py-1.5 text-sm font-medium rounded-md border border-blue-300 text-blue-700 hover:bg-blue-50", form: { data: { turbo_confirm: "Suspend all PgBouncer socket processing until Resume is selected?" } } %>
24
+ <% end %>
25
+ <% if !state_known || resumable %>
26
+ <%= button_to "Resume all", resume_path(database: database.name.parameterize), method: :post, class: "px-3 py-1.5 text-sm font-medium rounded-md border border-green-300 text-green-700 hover:bg-green-50", form: { data: { turbo_confirm: "Resume all paused or suspended PgBouncer processing?" } } %>
27
+ <% end %>
19
28
  <%= button_to "Graceful shutdown", shutdown_path(database: database.name.parameterize), method: :post, class: "px-3 py-1.5 text-sm font-medium rounded-md border border-red-300 text-red-700 hover:bg-red-50", form: { data: { turbo_confirm: "Stop accepting new clients and shut down after existing clients disconnect?" } } %>
20
29
  <% end %>
21
30
  </div>
@@ -1,21 +1,22 @@
1
- <div class="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
2
- <div class="px-4 py-3 border-b border-gray-200">
3
- <h4 class="text-base font-semibold text-gray-900">Pools</h4>
1
+ <div class="bg-white rounded-lg border border-gray-200 shadow-xs" data-controller="data-table" data-data-table-default-hidden-value='["cl_active_cancel_req","cl_waiting_cancel_req","sv_active_cancel","sv_being_canceled","sv_used","sv_tested","sv_login","maxwait_us"]'>
2
+ <div class="px-4 py-3 border-b border-gray-200 flex items-start justify-between gap-3 flex-wrap">
3
+ <h4 class="text-base font-semibold text-gray-900 py-1.5">Pools</h4>
4
+ <%= render "shared/data_table_toolbar", title: "Pools", table_id: "pools", row_count: pools.to_a.size %>
4
5
  </div>
5
- <div class="overflow-x-auto">
6
+ <div class="overflow-x-auto rounded-b-lg">
6
7
  <table class="min-w-full divide-y divide-gray-200">
7
8
  <thead class="bg-gray-50">
8
9
  <tr>
9
- <% pools.first.keys.each do |key| %>
10
- <th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"><%= key.titleize %></th>
10
+ <% pools.first.keys.each_with_index do |key, index| %>
11
+ <th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider <%= "sticky left-0 z-10 bg-gray-50" if index.zero? %>" data-column-key="<%= key %>"><%= key.titleize %></th>
11
12
  <% end %>
12
13
  </tr>
13
14
  </thead>
14
15
  <tbody class="bg-white divide-y divide-gray-200">
15
16
  <% pools.each do |row| %>
16
- <tr class="hover:bg-gray-50">
17
- <% row.each do |_k, v| %>
18
- <td class="px-4 py-2 text-sm text-gray-700 whitespace-nowrap"><%= v %></td>
17
+ <tr class="group hover:bg-gray-50" data-data-table-target="row">
18
+ <% row.each_value.with_index do |value, index| %>
19
+ <td class="px-4 py-2 text-sm text-gray-700 whitespace-nowrap <%= "sticky left-0 bg-white group-hover:bg-gray-50" if index.zero? %>"><%= value %></td>
19
20
  <% end %>
20
21
  </tr>
21
22
  <% end %>
@@ -0,0 +1,31 @@
1
+ <% rows = servers.to_a %>
2
+ <%= tag.div class: "bg-white rounded-lg border border-gray-200 shadow-xs", data: rows.empty? ? {} : { controller: "data-table", data_table_default_hidden_value: %w[type replication local_addr local_port request_time wait_us close_needed ptr link remote_pid tls prepared_statements] } do %>
3
+ <div class="px-4 py-3 border-b border-gray-200 flex items-start justify-between gap-3 flex-wrap">
4
+ <h4 class="text-base font-semibold text-gray-900 py-1.5">Servers</h4>
5
+ <%= render "shared/data_table_toolbar", title: "Servers", table_id: "servers", row_count: rows.size unless rows.empty? %>
6
+ </div>
7
+ <% if rows.empty? %>
8
+ <p class="px-4 py-6 text-sm text-gray-500">No server connections.</p>
9
+ <% else %>
10
+ <div class="overflow-x-auto rounded-b-lg">
11
+ <table class="min-w-full divide-y divide-gray-200">
12
+ <thead class="bg-gray-50">
13
+ <tr>
14
+ <% rows.first.keys.each do |key| %>
15
+ <th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider <%= "sticky left-0 z-10 bg-gray-50" if key == "database" %>" data-column-key="<%= key %>"><%= key.titleize %></th>
16
+ <% end %>
17
+ </tr>
18
+ </thead>
19
+ <tbody class="bg-white divide-y divide-gray-200">
20
+ <% rows.each do |row| %>
21
+ <tr class="group hover:bg-gray-50" data-data-table-target="row">
22
+ <% row.each do |key, value| %>
23
+ <td class="px-4 py-2 text-sm text-gray-700 whitespace-nowrap <%= "sticky left-0 bg-white group-hover:bg-gray-50" if key == "database" %>"><%= value %></td>
24
+ <% end %>
25
+ </tr>
26
+ <% end %>
27
+ </tbody>
28
+ </table>
29
+ </div>
30
+ <% end %>
31
+ <% end %>
@@ -1,4 +1,4 @@
1
- <div class="bg-white rounded-lg border border-gray-200 shadow-sm p-4">
1
+ <div class="bg-white rounded-lg border border-gray-200 shadow-xs p-4">
2
2
  <h4 class="text-base font-semibold text-gray-900 mb-3">State</h4>
3
3
  <dl class="grid grid-cols-1 sm:grid-cols-2 gap-4">
4
4
  <% state.each do |row| %>
@@ -1,26 +1,27 @@
1
- <div class="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
2
- <div class="px-4 py-3 border-b border-gray-200">
3
- <h4 class="text-base font-semibold text-gray-900">Stats</h4>
1
+ <div class="bg-white rounded-lg border border-gray-200 shadow-xs" data-controller="data-table" data-data-table-default-hidden-value='["total_server_assignment_count","total_xact_time","total_query_time","total_wait_time","avg_server_assignment_count","avg_xact_count","avg_query_count","avg_recv","avg_sent","avg_xact_time"]'>
2
+ <div class="px-4 py-3 border-b border-gray-200 flex items-start justify-between gap-3 flex-wrap">
3
+ <h4 class="text-base font-semibold text-gray-900 py-1.5">Stats</h4>
4
+ <%= render "shared/data_table_toolbar", title: "Stats", table_id: "stats", row_count: stats.to_a.size %>
4
5
  </div>
5
- <div class="overflow-x-auto">
6
+ <div class="overflow-x-auto rounded-b-lg">
6
7
  <table class="min-w-full divide-y divide-gray-200">
7
8
  <thead class="bg-gray-50">
8
9
  <tr>
9
- <% stats.first.keys.each do |key| %>
10
- <th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"><%= key.titleize %></th>
10
+ <% stats.first.keys.each_with_index do |key, index| %>
11
+ <th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider <%= "sticky left-0 z-10 bg-gray-50" if index.zero? %>" data-column-key="<%= key %>"><%= key.titleize %></th>
11
12
  <% end %>
12
13
  </tr>
13
14
  </thead>
14
15
  <tbody class="bg-white divide-y divide-gray-200">
15
16
  <% stats.each do |row| %>
16
- <tr class="hover:bg-gray-50">
17
- <% row.each do |k, v| %>
17
+ <tr class="group hover:bg-gray-50" data-data-table-target="row">
18
+ <% row.each_with_index do |(k, v), index| %>
18
19
  <% if %w[total_received total_sent avg_recv avg_sent].include?(k) %>
19
- <td class="px-4 py-2 text-sm text-gray-700 whitespace-nowrap"><%= number_to_human_size(v) %></td>
20
+ <td class="px-4 py-2 text-sm text-gray-700 whitespace-nowrap <%= "sticky left-0 bg-white group-hover:bg-gray-50" if index.zero? %>"><%= number_to_human_size(v) %></td>
20
21
  <% elsif %w[total_query_time avg_query].include?(k) %>
21
- <td class="px-4 py-2 text-sm text-gray-700 whitespace-nowrap"><%= humanize_ms(v.to_i) %></td>
22
+ <td class="px-4 py-2 text-sm text-gray-700 whitespace-nowrap <%= "sticky left-0 bg-white group-hover:bg-gray-50" if index.zero? %>"><%= humanize_ms(v.to_i) %></td>
22
23
  <% else %>
23
- <td class="px-4 py-2 text-sm text-gray-700 whitespace-nowrap"><%= v %></td>
24
+ <td class="px-4 py-2 text-sm text-gray-700 whitespace-nowrap <%= "sticky left-0 bg-white group-hover:bg-gray-50" if index.zero? %>"><%= v %></td>
24
25
  <% end %>
25
26
  <% end %>
26
27
  </tr>
@@ -0,0 +1,31 @@
1
+ <% rows = users.to_a %>
2
+ <%= tag.div class: "bg-white rounded-lg border border-gray-200 shadow-xs", data: rows.empty? ? {} : { controller: "data-table" } do %>
3
+ <div class="px-4 py-3 border-b border-gray-200 flex items-start justify-between gap-3 flex-wrap">
4
+ <h4 class="text-base font-semibold text-gray-900 py-1.5">Users</h4>
5
+ <%= render "shared/data_table_toolbar", title: "Users", table_id: "users", row_count: rows.size unless rows.empty? %>
6
+ </div>
7
+ <% if rows.empty? %>
8
+ <p class="px-4 py-6 text-sm text-gray-500">No configured users.</p>
9
+ <% else %>
10
+ <div class="overflow-x-auto rounded-b-lg">
11
+ <table class="min-w-full divide-y divide-gray-200">
12
+ <thead class="bg-gray-50">
13
+ <tr>
14
+ <% rows.first.keys.each_with_index do |key, index| %>
15
+ <th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider <%= "sticky left-0 z-10 bg-gray-50" if index.zero? %>" data-column-key="<%= key %>"><%= key.titleize %></th>
16
+ <% end %>
17
+ </tr>
18
+ </thead>
19
+ <tbody class="bg-white divide-y divide-gray-200">
20
+ <% rows.each do |row| %>
21
+ <tr class="group hover:bg-gray-50" data-data-table-target="row">
22
+ <% row.each_value.with_index do |value, index| %>
23
+ <td class="px-4 py-2 text-sm text-gray-700 whitespace-nowrap <%= "sticky left-0 bg-white group-hover:bg-gray-50" if index.zero? %>"><%= value %></td>
24
+ <% end %>
25
+ </tr>
26
+ <% end %>
27
+ </tbody>
28
+ </table>
29
+ </div>
30
+ <% end %>
31
+ <% end %>
@@ -1,4 +1,6 @@
1
1
  <%= render partial: "menu", locals: {database: @database} %>
2
- <% if @clients %>
3
- <%= render partial: "clients", locals: {database: @database, clients: @clients} %>
2
+ <%= monitoring_panel do %>
3
+ <% if @clients %>
4
+ <%= render partial: "clients", locals: {database: @database, clients: @clients} %>
5
+ <% end %>
4
6
  <% end %>
@@ -1,4 +1,6 @@
1
1
  <%= render partial: "menu", locals: {database: @database} %>
2
- <% if @conf %>
3
- <%= render partial: "conf", locals: {database: @database, conf: @conf} %>
2
+ <%= monitoring_panel do %>
3
+ <% if @conf %>
4
+ <%= render partial: "conf", locals: {database: @database, conf: @conf} %>
5
+ <% end %>
4
6
  <% end %>
@@ -1,4 +1,6 @@
1
1
  <%= render partial: "menu", locals: {database: @database} %>
2
- <% if @dbs %>
3
- <%= render partial: "databases", locals: {database: @database, databases: @dbs} %>
2
+ <%= monitoring_panel do %>
3
+ <% if @dbs %>
4
+ <%= render partial: "databases", locals: {database: @database, databases: @dbs} %>
5
+ <% end %>
4
6
  <% end %>
@@ -1,4 +1,6 @@
1
1
  <%= render partial: "menu", locals: {database: @database} %>
2
- <% if @pools %>
3
- <%= render partial: "pools", locals: {database: @database, pools: @pools} %>
2
+ <%= monitoring_panel do %>
3
+ <% if @pools %>
4
+ <%= render partial: "pools", locals: {database: @database, pools: @pools} %>
5
+ <% end %>
4
6
  <% end %>
@@ -0,0 +1,6 @@
1
+ <%= render partial: "menu", locals: {database: @database} %>
2
+ <%= monitoring_panel do %>
3
+ <% if @servers %>
4
+ <%= render partial: "servers", locals: {database: @database, servers: @servers} %>
5
+ <% end %>
6
+ <% end %>
@@ -1,4 +1,6 @@
1
1
  <%= render partial: "menu", locals: {database: @database} %>
2
- <% if @state %>
3
- <%= render partial: "state", locals: {state: @state} %>
2
+ <%= monitoring_panel do %>
3
+ <% if @state %>
4
+ <%= render partial: "state", locals: {state: @state} %>
5
+ <% end %>
4
6
  <% end %>
@@ -1,4 +1,6 @@
1
1
  <%= render partial: "menu", locals: {database: @database} %>
2
- <% if @stats %>
3
- <%= render partial: "stats", locals: {database: @database, stats: @stats} %>
2
+ <%= monitoring_panel do %>
3
+ <% if @stats %>
4
+ <%= render partial: "stats", locals: {database: @database, stats: @stats} %>
5
+ <% end %>
4
6
  <% end %>
@@ -0,0 +1,6 @@
1
+ <%= render partial: "menu", locals: {database: @database} %>
2
+ <%= monitoring_panel do %>
3
+ <% if @users %>
4
+ <%= render partial: "users", locals: {database: @database, users: @users} %>
5
+ <% end %>
6
+ <% end %>
@@ -1,13 +1,16 @@
1
- <div class="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
1
+ <div class="bg-white rounded-lg border border-gray-200 shadow-xs overflow-hidden">
2
2
  <%= link_to databases_path(group: database.group.name.parameterize, database: database.name.parameterize), class: "block no-underline text-inherit" do %>
3
3
  <div class="px-4 py-3 flex items-center justify-between border-b border-gray-100">
4
4
  <div>
5
5
  <div class="text-base font-bold text-gray-900"><%= database.name %></div>
6
6
  <div class="text-xs text-gray-500"><%= database.host %></div>
7
7
  </div>
8
+ <% if database.read_only? %>
9
+ <span class="inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 ring-1 ring-gray-300 ring-inset">Read-only</span>
10
+ <% end %>
8
11
  </div>
9
12
  <% end %>
10
- <turbo-frame id="card_<%= database.group.name.parameterize %>_<%= database.name.parameterize %>" src="<%= summary_path(group: database.group.name.parameterize, database: database.name.parameterize) %>" loading="lazy">
13
+ <turbo-frame id="card_<%= database.group.name.parameterize %>_<%= database.name.parameterize %>" src="<%= summary_path(group: database.group.name.parameterize, database: database.name.parameterize) %>">
11
14
  <%= render partial: "card_loading_content" %>
12
15
  </turbo-frame>
13
16
  </div>
@@ -1,43 +1,60 @@
1
1
  <% if database.connection %>
2
2
  <% summary = database.summary %>
3
- <div class="px-4 py-3 flex items-start gap-6">
4
- <div class="space-y-1 text-sm text-gray-700">
5
- <div>
6
- <% number = summary.select { |row| row["list"] == "users" }.first["items"] %>
7
- <%= pluralize(number, "user") %>
8
- </div>
9
- <div>
10
- <% number = summary.select { |row| row["list"] == "databases" }.first["items"].to_i - 1 %>
11
- <%= pluralize(number, "database") %>
12
- </div>
13
- <div>
14
- <% number = summary.select { |row| row["list"] == "pools" }.first["items"].to_i - 1 %>
15
- <%= pluralize(number, "pool") %>
3
+ <% health = fleet_health(summary) %>
4
+ <div data-fleet-health data-status="<%= health[:status] %>" data-severity="<%= health[:severity] %>" data-waiting-clients="<%= health[:waiting_clients] %>" data-high-utilization="<%= health[:max_utilization] >= 80 %>">
5
+ <div class="px-4 py-3 flex items-start gap-6">
6
+ <div class="space-y-1 text-sm text-gray-700">
7
+ <div>
8
+ <% number = summary.select { |row| row["list"] == "users" }.first["items"] %>
9
+ <%= pluralize(number, "user") %>
10
+ </div>
11
+ <div>
12
+ <% number = summary.select { |row| row["list"] == "databases" }.first["items"].to_i - 1 %>
13
+ <%= pluralize(number, "database") %>
14
+ </div>
15
+ <div>
16
+ <% number = summary.select { |row| row["list"] == "pools" }.first["items"].to_i - 1 %>
17
+ <%= pluralize(number, "pool") %>
18
+ </div>
19
+ <div>
20
+ <% if health[:waiting_clients].positive? %>
21
+ <span class="inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-800 ring-1 ring-yellow-200 ring-inset"><%= pluralize(health[:waiting_clients], "waiting client") %></span>
22
+ <% else %>
23
+ <span class="text-xs text-gray-400">No clients waiting</span>
24
+ <% end %>
25
+ </div>
16
26
  </div>
17
- </div>
18
- <div class="flex-1 space-y-2">
19
- <% summary.select { |row| row.key?(:databases_details) }.first[:databases_details].each do |db| %>
20
- <div class="flex items-center gap-2 text-sm">
21
- <span class="font-medium text-gray-900 w-24 truncate"><%= db["name"] %></span>
22
- <div class="flex-1">
23
- <% current = db["current_connections"].to_i %>
24
- <% max = db["max_connections"].to_i %>
25
- <% pct = max > 0 ? (current.to_f / max * 100).round : 0 %>
26
- <div class="w-full bg-gray-200 rounded-full h-2.5">
27
- <div class="bg-green-500 h-2.5 rounded-full" style="width: <%= pct %>%"></div>
27
+ <div class="flex-1 space-y-2">
28
+ <% summary.select { |row| row.key?(:databases_details) }.first[:databases_details].each do |db| %>
29
+ <div class="flex items-center gap-2 text-sm">
30
+ <span class="font-medium text-gray-900 w-24 truncate"><%= db["name"] %></span>
31
+ <div class="flex-1">
32
+ <% current = db["current_connections"].to_i %>
33
+ <% max = db["max_connections"].to_i %>
34
+ <% pct = max > 0 ? (current.to_f / max * 100).round : 0 %>
35
+ <div class="w-full bg-gray-200 rounded-full h-2.5">
36
+ <div class="<%= pct >= 80 ? "bg-amber-500" : "bg-green-500" %> h-2.5 rounded-full" style="width: <%= [pct, 100].min %>%"></div>
37
+ </div>
38
+ <div class="text-xs text-gray-500 mt-0.5"><%= current %> / <%= max %> connections</div>
28
39
  </div>
29
- <div class="text-xs text-gray-500 mt-0.5"><%= current %> / <%= max %> connections</div>
30
40
  </div>
31
- </div>
41
+ <% end %>
42
+ </div>
43
+ </div>
44
+ <div class="px-4 py-2 border-t border-gray-100 flex items-center justify-between">
45
+ <% if health[:waiting_clients].positive? %>
46
+ <span class="inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-800 ring-1 ring-yellow-200 ring-inset"><%= pluralize(health[:waiting_clients], "client") %> waiting</span>
47
+ <% elsif health[:max_utilization] >= 80 %>
48
+ <span class="inline-flex items-center rounded-md bg-amber-50 px-2 py-1 text-xs font-medium text-amber-800 ring-1 ring-amber-300 ring-inset"><%= health[:max_utilization] %>% utilized</span>
49
+ <% else %>
50
+ <span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-green-600/20 ring-inset">Healthy</span>
32
51
  <% end %>
52
+ <span class="text-xs text-gray-400">Last refresh: <%= Time.now.strftime("%H:%M:%S %Z") %></span>
33
53
  </div>
34
54
  </div>
35
- <div class="px-4 py-2 border-t border-gray-100 flex items-center justify-between">
36
- <span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-green-600/20 ring-inset">Online</span>
37
- <span class="text-xs text-gray-400">Last refresh: <%= Time.now.strftime("%H:%M:%S %Z") %></span>
38
- </div>
39
55
  <% else %>
40
- <div class="px-4 py-6 text-center">
56
+ <% health = fleet_health(nil) %>
57
+ <div class="px-4 py-6 text-center" data-fleet-health data-status="<%= health[:status] %>" data-severity="<%= health[:severity] %>" data-waiting-clients="0" data-high-utilization="false">
41
58
  <span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-red-600/20 ring-inset">Offline</span>
42
59
  </div>
43
60
  <% end %>