pgbouncerhero 3.0.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +119 -4
- data/app/assets/builds/pgbouncerhero/application.css +2 -2
- data/app/assets/stylesheets/pgbouncerhero/application.css +11 -1
- data/app/controllers/pg_bouncer_hero/application_controller.rb +4 -3
- data/app/controllers/pg_bouncer_hero/database_controller.rb +93 -9
- data/app/helpers/pg_bouncer_hero/application_helper.rb +54 -0
- data/app/javascript/pgbouncerhero/controllers/data_table_controller.js +183 -0
- data/app/javascript/pgbouncerhero/controllers/fleet_controller.js +52 -0
- data/app/javascript/pgbouncerhero/controllers/polling_controller.js +49 -5
- data/app/views/layouts/pg_bouncer_hero/application.html.erb +1 -1
- data/app/views/pg_bouncer_hero/database/_actions.html.erb +1 -1
- data/app/views/pg_bouncer_hero/database/_clients.html.erb +9 -8
- data/app/views/pg_bouncer_hero/database/_conf.html.erb +10 -9
- data/app/views/pg_bouncer_hero/database/_databases.html.erb +30 -9
- data/app/views/pg_bouncer_hero/database/_menu.html.erb +19 -4
- data/app/views/pg_bouncer_hero/database/_pools.html.erb +10 -9
- data/app/views/pg_bouncer_hero/database/_servers.html.erb +31 -0
- data/app/views/pg_bouncer_hero/database/_state.html.erb +11 -0
- data/app/views/pg_bouncer_hero/database/_stats.html.erb +12 -11
- data/app/views/pg_bouncer_hero/database/_users.html.erb +31 -0
- data/app/views/pg_bouncer_hero/database/clients.html.erb +4 -2
- data/app/views/pg_bouncer_hero/database/conf.html.erb +4 -2
- data/app/views/pg_bouncer_hero/database/databases.html.erb +4 -2
- data/app/views/pg_bouncer_hero/database/pools.html.erb +4 -2
- data/app/views/pg_bouncer_hero/database/servers.html.erb +6 -0
- data/app/views/pg_bouncer_hero/database/state.html.erb +6 -0
- data/app/views/pg_bouncer_hero/database/stats.html.erb +4 -2
- data/app/views/pg_bouncer_hero/database/users.html.erb +6 -0
- data/app/views/pg_bouncer_hero/home/_card.html.erb +5 -2
- data/app/views/pg_bouncer_hero/home/_card_content.html.erb +48 -31
- data/app/views/pg_bouncer_hero/home/index.html.erb +28 -8
- data/app/views/shared/_data_table_toolbar.html.erb +13 -0
- data/config/routes.rb +16 -2
- data/lib/generators/pgbouncerhero/templates/config.yml +7 -0
- data/lib/pgbouncerhero/connection.rb +54 -13
- data/lib/pgbouncerhero/database.rb +73 -3
- data/lib/pgbouncerhero/methods/basics.rb +86 -10
- data/lib/pgbouncerhero/version.rb +1 -1
- data/lib/pgbouncerhero.rb +139 -38
- metadata +83 -3
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["columnMenu", "empty", "query", "row", "status"]
|
|
5
|
+
static values = { defaultHidden: Array }
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
this.columns = Array.from(this.element.querySelectorAll("thead th"))
|
|
9
|
+
this.columns.forEach(column => { column.dataset.columnLabel ||= column.textContent.trim() })
|
|
10
|
+
this.collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" })
|
|
11
|
+
this.columnPreferences = this.loadColumnPreferences()
|
|
12
|
+
this.buildColumnMenu()
|
|
13
|
+
this.buildSortControls()
|
|
14
|
+
this.filter()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
filter() {
|
|
18
|
+
const query = this.queryTarget.value.trim().toLowerCase()
|
|
19
|
+
let visible = 0
|
|
20
|
+
|
|
21
|
+
this.rowTargets.forEach(row => {
|
|
22
|
+
const matches = query.length === 0 || row.textContent.toLowerCase().includes(query)
|
|
23
|
+
row.hidden = !matches
|
|
24
|
+
if (matches) visible += 1
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
this.emptyTarget.hidden = visible !== 0
|
|
28
|
+
this.statusTarget.textContent = query.length === 0 ? `${visible} rows` : `${visible} of ${this.rowTargets.length} rows`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
toggleColumn(event) {
|
|
32
|
+
const index = Number(event.currentTarget.dataset.columnIndex)
|
|
33
|
+
const key = event.currentTarget.dataset.columnKey
|
|
34
|
+
const visible = event.currentTarget.checked
|
|
35
|
+
|
|
36
|
+
this.setColumnVisibility(index, visible)
|
|
37
|
+
this.columnPreferences[key] = visible
|
|
38
|
+
this.saveColumnPreferences()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
resetColumns() {
|
|
42
|
+
this.columnPreferences = {}
|
|
43
|
+
|
|
44
|
+
this.columnMenuTarget.querySelectorAll("input[type='checkbox']").forEach(checkbox => {
|
|
45
|
+
const index = Number(checkbox.dataset.columnIndex)
|
|
46
|
+
const visible = !this.defaultHiddenValue.includes(checkbox.dataset.columnKey)
|
|
47
|
+
|
|
48
|
+
checkbox.checked = visible
|
|
49
|
+
this.setColumnVisibility(index, visible)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
window.localStorage.removeItem(this.storageKey)
|
|
54
|
+
} catch {
|
|
55
|
+
// The table remains usable when storage is disabled.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
sort(event) {
|
|
60
|
+
const index = Number(event.currentTarget.dataset.columnIndex)
|
|
61
|
+
const direction = this.sortIndex === index && this.sortDirection === "ascending" ? "descending" : "ascending"
|
|
62
|
+
const rows = this.rowTargets.map((row, originalIndex) => ({ row, originalIndex }))
|
|
63
|
+
|
|
64
|
+
rows.sort((left, right) => {
|
|
65
|
+
const leftValue = left.row.children[index]?.textContent.trim() || ""
|
|
66
|
+
const rightValue = right.row.children[index]?.textContent.trim() || ""
|
|
67
|
+
const comparison = this.compareValues(leftValue, rightValue, direction)
|
|
68
|
+
|
|
69
|
+
return comparison || left.originalIndex - right.originalIndex
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const body = this.element.querySelector("tbody")
|
|
73
|
+
rows.forEach(({ row }) => body.append(row))
|
|
74
|
+
this.sortIndex = index
|
|
75
|
+
this.sortDirection = direction
|
|
76
|
+
this.updateSortControls(index, direction)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
buildColumnMenu() {
|
|
80
|
+
this.columnMenuTarget.replaceChildren()
|
|
81
|
+
|
|
82
|
+
this.columns.forEach((column, index) => {
|
|
83
|
+
if (column.dataset.columnLocked === "true") return
|
|
84
|
+
|
|
85
|
+
const key = column.dataset.columnKey
|
|
86
|
+
const savedVisibility = this.columnPreferences[key]
|
|
87
|
+
const checked = savedVisibility === undefined ? !this.defaultHiddenValue.includes(key) : savedVisibility
|
|
88
|
+
const label = document.createElement("label")
|
|
89
|
+
label.className = "flex items-center gap-2 whitespace-nowrap"
|
|
90
|
+
|
|
91
|
+
const checkbox = document.createElement("input")
|
|
92
|
+
checkbox.type = "checkbox"
|
|
93
|
+
checkbox.checked = checked
|
|
94
|
+
checkbox.dataset.columnIndex = index
|
|
95
|
+
checkbox.dataset.columnKey = key
|
|
96
|
+
checkbox.dataset.action = "data-table#toggleColumn"
|
|
97
|
+
|
|
98
|
+
const text = document.createElement("span")
|
|
99
|
+
text.textContent = column.dataset.columnLabel
|
|
100
|
+
|
|
101
|
+
label.append(checkbox, text)
|
|
102
|
+
this.columnMenuTarget.append(label)
|
|
103
|
+
|
|
104
|
+
this.setColumnVisibility(index, checked)
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
buildSortControls() {
|
|
109
|
+
this.sortIndicators = new Map()
|
|
110
|
+
|
|
111
|
+
this.columns.forEach((column, index) => {
|
|
112
|
+
if (column.dataset.columnLocked === "true") return
|
|
113
|
+
|
|
114
|
+
const label = column.dataset.columnLabel
|
|
115
|
+
const button = document.createElement("button")
|
|
116
|
+
button.type = "button"
|
|
117
|
+
button.className = "inline-flex items-center gap-1 text-left text-inherit hover:text-gray-900"
|
|
118
|
+
button.dataset.columnIndex = index
|
|
119
|
+
button.dataset.action = "data-table#sort"
|
|
120
|
+
button.setAttribute("aria-label", `Sort by ${label} ascending`)
|
|
121
|
+
|
|
122
|
+
const text = document.createElement("span")
|
|
123
|
+
text.textContent = label
|
|
124
|
+
|
|
125
|
+
const indicator = document.createElement("span")
|
|
126
|
+
indicator.className = "text-gray-400"
|
|
127
|
+
indicator.setAttribute("aria-hidden", "true")
|
|
128
|
+
indicator.textContent = "↕"
|
|
129
|
+
|
|
130
|
+
button.append(text, indicator)
|
|
131
|
+
column.replaceChildren(button)
|
|
132
|
+
column.setAttribute("aria-sort", "none")
|
|
133
|
+
this.sortIndicators.set(index, { button, column, indicator, label })
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
updateSortControls(activeIndex, direction) {
|
|
138
|
+
this.sortIndicators.forEach(({ button, column, indicator, label }, index) => {
|
|
139
|
+
const active = index === activeIndex
|
|
140
|
+
const nextDirection = active && direction === "ascending" ? "descending" : "ascending"
|
|
141
|
+
|
|
142
|
+
column.setAttribute("aria-sort", active ? direction : "none")
|
|
143
|
+
indicator.textContent = active ? (direction === "ascending" ? "↑" : "↓") : "↕"
|
|
144
|
+
button.setAttribute("aria-label", `Sort by ${label} ${nextDirection}`)
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
compareValues(left, right, direction) {
|
|
149
|
+
if (left.length === 0 && right.length > 0) return 1
|
|
150
|
+
if (right.length === 0 && left.length > 0) return -1
|
|
151
|
+
|
|
152
|
+
const comparison = this.collator.compare(left, right)
|
|
153
|
+
return direction === "ascending" ? comparison : -comparison
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
setColumnVisibility(index, visible) {
|
|
157
|
+
this.element.querySelectorAll("tr").forEach(row => {
|
|
158
|
+
const cell = row.children[index]
|
|
159
|
+
if (cell) cell.hidden = !visible
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
loadColumnPreferences() {
|
|
164
|
+
try {
|
|
165
|
+
const preferences = JSON.parse(window.localStorage.getItem(this.storageKey))
|
|
166
|
+
return preferences && typeof preferences === "object" && !Array.isArray(preferences) ? preferences : {}
|
|
167
|
+
} catch {
|
|
168
|
+
return {}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
saveColumnPreferences() {
|
|
173
|
+
try {
|
|
174
|
+
window.localStorage.setItem(this.storageKey, JSON.stringify(this.columnPreferences))
|
|
175
|
+
} catch {
|
|
176
|
+
// The table remains usable when storage is disabled or full.
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
get storageKey() {
|
|
181
|
+
return `pgbouncerhero:data-table:${this.queryTarget.id}`
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["headline", "offline", "waiting", "utilization", "group", "item"]
|
|
5
|
+
static values = { total: Number }
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
this.refresh()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
refresh() {
|
|
12
|
+
const healthChecks = this.itemTargets.map(item => item.querySelector("[data-fleet-health]")).filter(Boolean)
|
|
13
|
+
const pending = this.totalValue - healthChecks.length
|
|
14
|
+
const offline = healthChecks.filter(health => health.dataset.status === "offline").length
|
|
15
|
+
const waiting = healthChecks.reduce((total, health) => total + Number(health.dataset.waitingClients), 0)
|
|
16
|
+
const highUtilization = healthChecks.filter(health => health.dataset.highUtilization === "true").length
|
|
17
|
+
const affected = healthChecks.filter(health => health.dataset.status !== "healthy").length
|
|
18
|
+
|
|
19
|
+
this.offlineTarget.textContent = offline
|
|
20
|
+
this.waitingTarget.textContent = waiting
|
|
21
|
+
this.utilizationTarget.textContent = highUtilization
|
|
22
|
+
|
|
23
|
+
if (pending > 0) {
|
|
24
|
+
this.headlineTarget.textContent = `Checking ${healthChecks.length} of ${this.totalValue} ${this.instances()}…`
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
this.headlineTarget.textContent = affected === 0
|
|
29
|
+
? `All ${this.totalValue} ${this.instances()} healthy`
|
|
30
|
+
: `${affected} of ${this.totalValue} ${this.instances()} ${affected === 1 ? "needs" : "need"} attention`
|
|
31
|
+
this.sortGroups()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
instances() {
|
|
35
|
+
return this.totalValue === 1 ? "instance" : "instances"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
sortGroups() {
|
|
39
|
+
this.groupTargets.forEach(group => {
|
|
40
|
+
const items = Array.from(group.querySelectorAll(":scope > [data-fleet-target~='item']"))
|
|
41
|
+
items.sort((left, right) => {
|
|
42
|
+
const severityDifference = this.severity(right) - this.severity(left)
|
|
43
|
+
return severityDifference || Number(left.dataset.fleetOriginalIndex) - Number(right.dataset.fleetOriginalIndex)
|
|
44
|
+
})
|
|
45
|
+
items.forEach(item => group.appendChild(item))
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
severity(item) {
|
|
50
|
+
return Number(item.querySelector("[data-fleet-health]")?.dataset.severity || 0)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -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.
|
|
16
|
-
|
|
17
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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 |
|
|
18
|
-
<td class="px-4 py-2 text-sm text-gray-700 whitespace-nowrap"><%=
|
|
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-
|
|
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.
|
|
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.
|
|
18
|
-
<td class="px-4 py-2 text-sm text-gray-700 whitespace-nowrap"><%=
|
|
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-
|
|
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.
|
|
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.
|
|
18
|
-
<td class="px-4 py-2 text-sm text-gray-700 whitespace-nowrap"><%=
|
|
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
|
-
|
|
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,12 +9,24 @@
|
|
|
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'}" %>
|
|
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'}" %>
|
|
10
16
|
</div>
|
|
11
17
|
<div class="flex items-center gap-2">
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
<% if database.read_only? %>
|
|
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>
|
|
20
|
+
<% else %>
|
|
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?" } } %>
|
|
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 %>
|
|
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?" } } %>
|
|
29
|
+
<% end %>
|
|
15
30
|
</div>
|
|
16
31
|
</div>
|
|
17
32
|
</nav>
|
|
@@ -1,21 +1,22 @@
|
|
|
1
|
-
<div class="bg-white rounded-lg border border-gray-200 shadow-
|
|
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.
|
|
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.
|
|
18
|
-
<td class="px-4 py-2 text-sm text-gray-700 whitespace-nowrap"><%=
|
|
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 %>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<div class="bg-white rounded-lg border border-gray-200 shadow-xs p-4">
|
|
2
|
+
<h4 class="text-base font-semibold text-gray-900 mb-3">State</h4>
|
|
3
|
+
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
4
|
+
<% state.each do |row| %>
|
|
5
|
+
<div>
|
|
6
|
+
<dt class="text-xs font-medium text-gray-500 uppercase tracking-wider"><%= row["key"].titleize %></dt>
|
|
7
|
+
<dd class="mt-1 text-sm text-gray-900"><%= row["value"] %></dd>
|
|
8
|
+
</div>
|
|
9
|
+
<% end %>
|
|
10
|
+
</dl>
|
|
11
|
+
</div>
|
|
@@ -1,26 +1,27 @@
|
|
|
1
|
-
<div class="bg-white rounded-lg border border-gray-200 shadow-
|
|
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.
|
|
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.
|
|
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 %>
|