pgbus 0.2.9 → 0.3.1
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/app/controllers/pgbus/api/insights_controller.rb +6 -1
- data/app/controllers/pgbus/frontends_controller.rb +68 -0
- data/app/controllers/pgbus/insights_controller.rb +2 -0
- data/app/controllers/pgbus/jobs_controller.rb +5 -0
- data/app/frontend/pgbus/application.js +90 -0
- data/app/frontend/pgbus/modules/charts.js +106 -0
- data/app/frontend/pgbus/style.css +2 -0
- data/app/frontend/pgbus/tailwind.css +64 -0
- data/app/frontend/pgbus/vendor/apexcharts.js +38 -0
- data/app/frontend/pgbus/vendor/turbo.js +6696 -0
- data/app/models/pgbus/job_stat.rb +85 -13
- data/app/views/layouts/pgbus/application.html.erb +20 -141
- data/app/views/pgbus/insights/show.html.erb +86 -80
- data/app/views/pgbus/jobs/_enqueued_table.html.erb +8 -1
- data/config/locales/da.yml +3 -0
- data/config/locales/de.yml +3 -0
- data/config/locales/en.yml +19 -0
- data/config/locales/es.yml +3 -0
- data/config/locales/fi.yml +3 -0
- data/config/locales/fr.yml +3 -0
- data/config/locales/it.yml +3 -0
- data/config/locales/ja.yml +3 -0
- data/config/locales/nb.yml +3 -0
- data/config/locales/nl.yml +3 -0
- data/config/locales/pt.yml +3 -0
- data/config/locales/sv.yml +3 -0
- data/config/routes.rb +6 -0
- data/lib/generators/pgbus/add_job_stats_latency_generator.rb +52 -0
- data/lib/generators/pgbus/templates/add_job_stats_latency.rb.erb +9 -0
- data/lib/pgbus/active_job/executor.rb +24 -5
- data/lib/pgbus/recurring/schedule.rb +86 -0
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +107 -0
- metadata +10 -1
|
@@ -10,15 +10,19 @@ module Pgbus
|
|
|
10
10
|
scope :dead_lettered, -> { where(status: "dead_lettered") }
|
|
11
11
|
|
|
12
12
|
# Record a job execution stat. Called by the executor after each job.
|
|
13
|
-
def self.record!(job_class:, queue_name:, status:, duration_ms:)
|
|
13
|
+
def self.record!(job_class:, queue_name:, status:, duration_ms:, enqueue_latency_ms: nil, retry_count: 0)
|
|
14
14
|
return unless table_exists?
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
attrs = {
|
|
17
17
|
job_class: job_class,
|
|
18
18
|
queue_name: queue_name,
|
|
19
19
|
status: status,
|
|
20
20
|
duration_ms: duration_ms
|
|
21
|
-
|
|
21
|
+
}
|
|
22
|
+
attrs[:enqueue_latency_ms] = enqueue_latency_ms if latency_columns?
|
|
23
|
+
attrs[:retry_count] = retry_count if latency_columns?
|
|
24
|
+
|
|
25
|
+
create!(attrs)
|
|
22
26
|
rescue StandardError => e
|
|
23
27
|
Pgbus.logger.debug { "[Pgbus] Failed to record job stat: #{e.message}" }
|
|
24
28
|
end
|
|
@@ -34,6 +38,15 @@ module Pgbus
|
|
|
34
38
|
@table_exists = false
|
|
35
39
|
end
|
|
36
40
|
|
|
41
|
+
# Memoized — checks if the latency migration has been applied.
|
|
42
|
+
def self.latency_columns?
|
|
43
|
+
return @latency_columns if defined?(@latency_columns)
|
|
44
|
+
|
|
45
|
+
@latency_columns = table_exists? && column_names.include?("enqueue_latency_ms")
|
|
46
|
+
rescue StandardError
|
|
47
|
+
@latency_columns = false
|
|
48
|
+
end
|
|
49
|
+
|
|
37
50
|
# Throughput: jobs per minute bucketed by minute for the last N minutes
|
|
38
51
|
def self.throughput(minutes: 60)
|
|
39
52
|
since(minutes.minutes.ago)
|
|
@@ -67,16 +80,30 @@ module Pgbus
|
|
|
67
80
|
|
|
68
81
|
# Single-query aggregate summary using conditional counts.
|
|
69
82
|
def self.summary(minutes: 60)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
83
|
+
cols = [
|
|
84
|
+
"COUNT(*)",
|
|
85
|
+
"COUNT(*) FILTER (WHERE status = 'success')",
|
|
86
|
+
"COUNT(*) FILTER (WHERE status = 'failed')",
|
|
87
|
+
"COUNT(*) FILTER (WHERE status = 'dead_lettered')",
|
|
88
|
+
"ROUND(AVG(duration_ms)::numeric, 1)",
|
|
89
|
+
"MAX(duration_ms)"
|
|
90
|
+
]
|
|
91
|
+
if latency_columns?
|
|
92
|
+
cols.push(
|
|
93
|
+
"ROUND(AVG(enqueue_latency_ms) FILTER (WHERE enqueue_latency_ms IS NOT NULL)::numeric, 1)",
|
|
94
|
+
"PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY enqueue_latency_ms) " \
|
|
95
|
+
"FILTER (WHERE enqueue_latency_ms IS NOT NULL)",
|
|
96
|
+
"PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY enqueue_latency_ms) " \
|
|
97
|
+
"FILTER (WHERE enqueue_latency_ms IS NOT NULL)",
|
|
98
|
+
"PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY enqueue_latency_ms) " \
|
|
99
|
+
"FILTER (WHERE enqueue_latency_ms IS NOT NULL)",
|
|
100
|
+
"ROUND(AVG(retry_count) FILTER (WHERE retry_count IS NOT NULL)::numeric, 2)"
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
row = since(minutes.minutes.ago).pick(*cols.map { |c| Arel.sql(c) })
|
|
105
|
+
|
|
106
|
+
result = {
|
|
80
107
|
total: row[0].to_i,
|
|
81
108
|
success: row[1].to_i,
|
|
82
109
|
failed: row[2].to_i,
|
|
@@ -84,6 +111,51 @@ module Pgbus
|
|
|
84
111
|
avg_duration_ms: row[4]&.to_f || 0,
|
|
85
112
|
max_duration_ms: row[5].to_i
|
|
86
113
|
}
|
|
114
|
+
|
|
115
|
+
if latency_columns?
|
|
116
|
+
result.merge!(
|
|
117
|
+
avg_latency_ms: row[6]&.to_f || 0,
|
|
118
|
+
p50_latency_ms: row[7]&.to_f || 0,
|
|
119
|
+
p95_latency_ms: row[8]&.to_f || 0,
|
|
120
|
+
p99_latency_ms: row[9]&.to_f || 0,
|
|
121
|
+
avg_retries: row[10]&.to_f || 0
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
result
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Latency trend: average enqueue latency per minute bucketed
|
|
129
|
+
def self.latency_trend(minutes: 60)
|
|
130
|
+
return [] unless latency_columns?
|
|
131
|
+
|
|
132
|
+
since(minutes.minutes.ago)
|
|
133
|
+
.where.not(enqueue_latency_ms: nil)
|
|
134
|
+
.group("date_trunc('minute', created_at)")
|
|
135
|
+
.order(Arel.sql("date_trunc('minute', created_at)"))
|
|
136
|
+
.pluck(
|
|
137
|
+
Arel.sql("date_trunc('minute', created_at)"),
|
|
138
|
+
Arel.sql("ROUND(AVG(enqueue_latency_ms))"),
|
|
139
|
+
Arel.sql("ROUND(PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY enqueue_latency_ms))")
|
|
140
|
+
)
|
|
141
|
+
.map { |time, avg, p95| { time: time, avg_ms: avg.to_i, p95_ms: p95.to_i } }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Average latency by queue
|
|
145
|
+
def self.avg_latency_by_queue(minutes: 60)
|
|
146
|
+
return [] unless latency_columns?
|
|
147
|
+
|
|
148
|
+
since(minutes.minutes.ago)
|
|
149
|
+
.where.not(enqueue_latency_ms: nil)
|
|
150
|
+
.group(:queue_name)
|
|
151
|
+
.order(Arel.sql("AVG(enqueue_latency_ms) DESC"))
|
|
152
|
+
.pluck(
|
|
153
|
+
:queue_name,
|
|
154
|
+
Arel.sql("ROUND(AVG(enqueue_latency_ms))"),
|
|
155
|
+
Arel.sql("ROUND(PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY enqueue_latency_ms))"),
|
|
156
|
+
Arel.sql("COUNT(*)")
|
|
157
|
+
)
|
|
158
|
+
.map { |q, avg, p95, count| { queue_name: q, avg_ms: avg.to_i, p95_ms: p95.to_i, count: count.to_i } }
|
|
87
159
|
end
|
|
88
160
|
|
|
89
161
|
# Cleanup old stats
|
|
@@ -4,63 +4,19 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title><%= t("pgbus.layout.title") %></title>
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
Applied before Tailwind CDN loads so the background is correct immediately. */
|
|
10
|
-
html.dark { background-color: #030712; } /* gray-950 */
|
|
11
|
-
html.dark body { background-color: #030712; }
|
|
7
|
+
<%= csrf_meta_tags %>
|
|
8
|
+
<%= csp_meta_tag %>
|
|
12
9
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
.pgbus-table thead { display: none; }
|
|
16
|
-
.pgbus-table tbody tr {
|
|
17
|
-
display: block;
|
|
18
|
-
margin-bottom: 0.75rem;
|
|
19
|
-
border-radius: 0.5rem;
|
|
20
|
-
padding: 0.75rem;
|
|
21
|
-
border: 1px solid #e5e7eb;
|
|
22
|
-
}
|
|
23
|
-
html.dark .pgbus-table tbody tr { border-color: #374151; }
|
|
24
|
-
.pgbus-table tbody td {
|
|
25
|
-
display: flex;
|
|
26
|
-
justify-content: space-between;
|
|
27
|
-
align-items: baseline;
|
|
28
|
-
padding: 0.25rem 0;
|
|
29
|
-
border: none;
|
|
30
|
-
text-align: right;
|
|
31
|
-
}
|
|
32
|
-
.pgbus-table tbody td::before {
|
|
33
|
-
content: attr(data-label);
|
|
34
|
-
font-weight: 600;
|
|
35
|
-
font-size: 0.75rem;
|
|
36
|
-
text-transform: uppercase;
|
|
37
|
-
color: #6b7280;
|
|
38
|
-
text-align: left;
|
|
39
|
-
margin-right: 1rem;
|
|
40
|
-
flex-shrink: 0;
|
|
41
|
-
}
|
|
42
|
-
html.dark .pgbus-table tbody td::before { color: #9ca3af; }
|
|
43
|
-
.pgbus-table tbody td[colspan] {
|
|
44
|
-
display: block;
|
|
45
|
-
text-align: center;
|
|
46
|
-
}
|
|
47
|
-
.pgbus-table tbody td[colspan]::before { display: none; }
|
|
48
|
-
}
|
|
49
|
-
</style>
|
|
50
|
-
<script src="https://cdn.tailwindcss.com"></script>
|
|
51
|
-
<script>
|
|
52
|
-
tailwind.config = { darkMode: 'class' };
|
|
53
|
-
// Restore dark mode preference
|
|
10
|
+
<%# Prevent white flash in dark mode — must run before stylesheet loads %>
|
|
11
|
+
<script nonce="<%= content_security_policy_nonce %>">
|
|
54
12
|
if (localStorage.getItem('pgbus-dark') === 'true' ||
|
|
55
13
|
(!localStorage.getItem('pgbus-dark') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
56
14
|
document.documentElement.classList.add('dark');
|
|
57
15
|
}
|
|
58
|
-
// Dark mode toggle — must be in non-module script for onclick access
|
|
59
16
|
function toggleDarkMode() {
|
|
60
17
|
var isDark = document.documentElement.classList.toggle('dark');
|
|
61
18
|
localStorage.setItem('pgbus-dark', isDark);
|
|
62
19
|
}
|
|
63
|
-
// Mobile menu toggle
|
|
64
20
|
function toggleMobileMenu() {
|
|
65
21
|
var menu = document.getElementById('pgbus-mobile-menu');
|
|
66
22
|
var openIcon = document.getElementById('pgbus-menu-open');
|
|
@@ -69,7 +25,6 @@
|
|
|
69
25
|
openIcon.classList.toggle('hidden');
|
|
70
26
|
closeIcon.classList.toggle('hidden');
|
|
71
27
|
}
|
|
72
|
-
// Close locale dropdown when clicking outside
|
|
73
28
|
document.addEventListener('click', function(e) {
|
|
74
29
|
var switcher = document.getElementById('pgbus-locale-switcher');
|
|
75
30
|
var menu = document.getElementById('pgbus-locale-menu');
|
|
@@ -78,103 +33,27 @@
|
|
|
78
33
|
}
|
|
79
34
|
});
|
|
80
35
|
</script>
|
|
81
|
-
<script type="module">
|
|
82
|
-
import * as Turbo from "https://esm.sh/@hotwired/turbo@8";
|
|
83
|
-
|
|
84
|
-
// -- Custom confirm dialog (replaces browser confirm) --
|
|
85
|
-
Turbo.config.forms.confirm = (message, element) => {
|
|
86
|
-
const dialog = document.getElementById("pgbus-confirm-dialog");
|
|
87
|
-
const messageEl = document.getElementById("pgbus-confirm-message");
|
|
88
|
-
const titleEl = document.getElementById("pgbus-confirm-title");
|
|
89
|
-
const confirmBtn = document.getElementById("pgbus-confirm-btn");
|
|
90
|
-
const iconEl = document.getElementById("pgbus-confirm-icon");
|
|
91
|
-
|
|
92
|
-
// Detect action type from the element
|
|
93
|
-
const turboMethod = element.getAttribute("data-turbo-method");
|
|
94
|
-
const isDelete = turboMethod === "delete";
|
|
95
|
-
|
|
96
|
-
// Set title based on action
|
|
97
|
-
titleEl.textContent = isDelete ? "<%= t("pgbus.dialogs.delete_title", default: "Delete") %>" : "<%= t("pgbus.dialogs.confirm_title", default: "Are you sure?") %>";
|
|
98
|
-
messageEl.textContent = message;
|
|
99
|
-
|
|
100
|
-
// Style confirm button based on action severity
|
|
101
|
-
confirmBtn.className = "rounded-md px-4 py-2 text-sm font-medium text-white focus:outline-none focus:ring-2";
|
|
102
|
-
if (isDelete) {
|
|
103
|
-
confirmBtn.classList.add("bg-red-600", "hover:bg-red-500", "focus:ring-red-500");
|
|
104
|
-
confirmBtn.textContent = "<%= t("pgbus.dialogs.delete", default: "Delete") %>";
|
|
105
|
-
iconEl.className = "flex-shrink-0 flex items-center justify-center h-10 w-10 rounded-full bg-red-100 dark:bg-red-900/30";
|
|
106
|
-
} else {
|
|
107
|
-
confirmBtn.classList.add("bg-yellow-500", "hover:bg-yellow-400", "focus:ring-yellow-500");
|
|
108
|
-
confirmBtn.textContent = "<%= t("pgbus.dialogs.confirm", default: "Confirm") %>";
|
|
109
|
-
iconEl.className = "flex-shrink-0 flex items-center justify-center h-10 w-10 rounded-full bg-yellow-100 dark:bg-yellow-900/30";
|
|
110
|
-
}
|
|
111
36
|
|
|
112
|
-
|
|
37
|
+
<%# Self-hosted assets — no external CDN dependencies %>
|
|
38
|
+
<%= tag.link rel: "stylesheet", href: frontend_static_path(:style, format: :css, locale: nil), nonce: content_security_policy_nonce %>
|
|
113
39
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
});
|
|
119
|
-
};
|
|
40
|
+
<%# Importmap for ES modules %>
|
|
41
|
+
<% importmaps = Pgbus::FrontendsController.js_modules.keys.index_with { |mod| frontend_module_path(mod, format: :js, locale: nil) } %>
|
|
42
|
+
<%= tag.script({ imports: importmaps }.to_json.html_safe, type: "importmap", nonce: content_security_policy_nonce) %>
|
|
43
|
+
<%= tag.script("", src: frontend_static_path(:apexcharts, format: :js, locale: nil), nonce: content_security_policy_nonce) %>
|
|
120
44
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const container = document.getElementById("pgbus-toast-container");
|
|
124
|
-
const toast = document.createElement("div");
|
|
125
|
-
|
|
126
|
-
const colors = {
|
|
127
|
-
success: "bg-green-50 dark:bg-green-900/30 text-green-800 dark:text-green-300 border-green-200 dark:border-green-800",
|
|
128
|
-
error: "bg-red-50 dark:bg-red-900/30 text-red-800 dark:text-red-300 border-red-200 dark:border-red-800",
|
|
129
|
-
info: "bg-blue-50 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 border-blue-200 dark:border-blue-800",
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
toast.className = `rounded-md border p-3 text-sm shadow-lg transition-all duration-300 ${colors[type] || colors.info}`;
|
|
133
|
-
toast.textContent = message;
|
|
134
|
-
container.appendChild(toast);
|
|
135
|
-
|
|
136
|
-
setTimeout(() => {
|
|
137
|
-
toast.style.opacity = "0";
|
|
138
|
-
toast.style.transform = "translateX(100%)";
|
|
139
|
-
setTimeout(() => toast.remove(), 300);
|
|
140
|
-
}, 5000);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Render flash toasts from <template> tags.
|
|
144
|
-
// Must run on turbo:load as well — module scripts only execute once,
|
|
145
|
-
// but Turbo Drive replaces the body on navigation.
|
|
146
|
-
function renderFlashToasts() {
|
|
147
|
-
document.querySelectorAll("template[data-pgbus-toast]").forEach(tpl => {
|
|
148
|
-
showToast(tpl.content.textContent.trim(), tpl.dataset.pgbusToast);
|
|
149
|
-
tpl.remove();
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
renderFlashToasts();
|
|
153
|
-
document.addEventListener("turbo:load", renderFlashToasts);
|
|
154
|
-
|
|
155
|
-
<% if Pgbus.configuration.web_live_updates %>
|
|
156
|
-
const interval = <%= Pgbus.configuration.web_refresh_interval %>;
|
|
157
|
-
if (interval > 0) {
|
|
158
|
-
let timer;
|
|
159
|
-
function refreshFrames() {
|
|
160
|
-
if (document.hidden) return;
|
|
161
|
-
document.querySelectorAll("turbo-frame[data-auto-refresh]")
|
|
162
|
-
.forEach(frame => {
|
|
163
|
-
try {
|
|
164
|
-
if (!frame.src && frame.dataset.src) frame.src = frame.dataset.src;
|
|
165
|
-
if (frame.src) frame.reload();
|
|
166
|
-
} catch (_) { /* Turbo may abort in-flight fetches during navigation */ }
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
function start() { timer = setInterval(refreshFrames, interval); }
|
|
170
|
-
function stop() { clearInterval(timer); }
|
|
171
|
-
document.addEventListener("visibilitychange", () => document.hidden ? stop() : start());
|
|
172
|
-
start();
|
|
173
|
-
}
|
|
174
|
-
<% end %>
|
|
45
|
+
<script type="module" nonce="<%= content_security_policy_nonce %>">
|
|
46
|
+
import "application";
|
|
175
47
|
</script>
|
|
176
48
|
</head>
|
|
177
|
-
<body class="h-full bg-gray-50 dark:bg-gray-950 transition-colors"
|
|
49
|
+
<body class="h-full bg-gray-50 dark:bg-gray-950 transition-colors"
|
|
50
|
+
<% if Pgbus.configuration.web_live_updates %>data-pgbus-refresh-interval="<%= Pgbus.configuration.web_refresh_interval %>"<% end %>>
|
|
51
|
+
<%# i18n data for JS modules %>
|
|
52
|
+
<div id="pgbus-i18n" class="hidden"
|
|
53
|
+
data-delete-title="<%= t("pgbus.dialogs.delete_title", default: "Delete") %>"
|
|
54
|
+
data-confirm-title="<%= t("pgbus.dialogs.confirm_title", default: "Are you sure?") %>"
|
|
55
|
+
data-delete-label="<%= t("pgbus.dialogs.delete", default: "Delete") %>"
|
|
56
|
+
data-confirm-label="<%= t("pgbus.dialogs.confirm", default: "Confirm") %>"></div>
|
|
178
57
|
<div class="min-h-full">
|
|
179
58
|
<!-- Top nav -->
|
|
180
59
|
<nav class="bg-gray-900 dark:bg-gray-950 border-b border-gray-800">
|
|
@@ -51,6 +51,32 @@
|
|
|
51
51
|
</div>
|
|
52
52
|
</div>
|
|
53
53
|
|
|
54
|
+
<% if @latency_available %>
|
|
55
|
+
<!-- Latency summary cards -->
|
|
56
|
+
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5 mb-8">
|
|
57
|
+
<div class="rounded-lg bg-white dark:bg-gray-800 p-4 shadow ring-1 ring-gray-200 dark:ring-gray-700">
|
|
58
|
+
<dt class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.insights.show.summary.avg_latency") %></dt>
|
|
59
|
+
<dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white"><%= pgbus_ms_duration(@summary[:avg_latency_ms]) %></dd>
|
|
60
|
+
</div>
|
|
61
|
+
<div class="rounded-lg bg-white dark:bg-gray-800 p-4 shadow ring-1 ring-gray-200 dark:ring-gray-700">
|
|
62
|
+
<dt class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.insights.show.summary.p50_latency") %></dt>
|
|
63
|
+
<dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white"><%= pgbus_ms_duration(@summary[:p50_latency_ms]) %></dd>
|
|
64
|
+
</div>
|
|
65
|
+
<div class="rounded-lg bg-white dark:bg-gray-800 p-4 shadow ring-1 ring-gray-200 dark:ring-gray-700">
|
|
66
|
+
<dt class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.insights.show.summary.p95_latency") %></dt>
|
|
67
|
+
<dd class="mt-1 text-2xl font-semibold text-yellow-600 dark:text-yellow-400"><%= pgbus_ms_duration(@summary[:p95_latency_ms]) %></dd>
|
|
68
|
+
</div>
|
|
69
|
+
<div class="rounded-lg bg-white dark:bg-gray-800 p-4 shadow ring-1 ring-gray-200 dark:ring-gray-700">
|
|
70
|
+
<dt class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.insights.show.summary.p99_latency") %></dt>
|
|
71
|
+
<dd class="mt-1 text-2xl font-semibold text-orange-600 dark:text-orange-400"><%= pgbus_ms_duration(@summary[:p99_latency_ms]) %></dd>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="rounded-lg bg-white dark:bg-gray-800 p-4 shadow ring-1 ring-gray-200 dark:ring-gray-700">
|
|
74
|
+
<dt class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.insights.show.summary.avg_retries") %></dt>
|
|
75
|
+
<dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white"><%= @summary[:avg_retries]&.round(2) || 0 %></dd>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
<% end %>
|
|
79
|
+
|
|
54
80
|
<!-- Charts -->
|
|
55
81
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
|
56
82
|
<div class="rounded-lg bg-white dark:bg-gray-800 p-5 shadow ring-1 ring-gray-200 dark:ring-gray-700">
|
|
@@ -63,6 +89,46 @@
|
|
|
63
89
|
</div>
|
|
64
90
|
</div>
|
|
65
91
|
|
|
92
|
+
<% if @latency_available %>
|
|
93
|
+
<!-- Latency chart -->
|
|
94
|
+
<div class="grid grid-cols-1 gap-6 mb-8">
|
|
95
|
+
<div class="rounded-lg bg-white dark:bg-gray-800 p-5 shadow ring-1 ring-gray-200 dark:ring-gray-700">
|
|
96
|
+
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4"><%= t("pgbus.insights.show.charts.latency") %></h3>
|
|
97
|
+
<div id="latency-chart" style="height: 280px;"></div>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<!-- Latency by queue -->
|
|
102
|
+
<div class="rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700 mb-8">
|
|
103
|
+
<div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
104
|
+
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300"><%= t("pgbus.insights.show.latency_by_queue.title") %></h3>
|
|
105
|
+
</div>
|
|
106
|
+
<table class="pgbus-table min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
107
|
+
<thead class="bg-gray-50 dark:bg-gray-900">
|
|
108
|
+
<tr>
|
|
109
|
+
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.insights.show.latency_by_queue.headers.queue") %></th>
|
|
110
|
+
<th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.insights.show.latency_by_queue.headers.count") %></th>
|
|
111
|
+
<th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.insights.show.latency_by_queue.headers.avg") %></th>
|
|
112
|
+
<th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.insights.show.latency_by_queue.headers.p95") %></th>
|
|
113
|
+
</tr>
|
|
114
|
+
</thead>
|
|
115
|
+
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
|
116
|
+
<% @latency_by_queue.each do |row| %>
|
|
117
|
+
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
|
118
|
+
<td data-label="Queue" class="px-4 py-3 text-sm font-medium text-gray-700 dark:text-gray-300"><%= row[:queue_name] %></td>
|
|
119
|
+
<td data-label="Count" class="px-4 py-3 text-sm text-right font-mono text-gray-700 dark:text-gray-300"><%= pgbus_number(row[:count]) %></td>
|
|
120
|
+
<td data-label="Avg" class="px-4 py-3 text-sm text-right font-mono text-gray-700 dark:text-gray-300"><%= pgbus_ms_duration(row[:avg_ms]) %></td>
|
|
121
|
+
<td data-label="P95" class="px-4 py-3 text-sm text-right font-mono text-gray-700 dark:text-gray-300"><%= pgbus_ms_duration(row[:p95_ms]) %></td>
|
|
122
|
+
</tr>
|
|
123
|
+
<% end %>
|
|
124
|
+
<% if @latency_by_queue.empty? %>
|
|
125
|
+
<tr><td colspan="4" class="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500"><%= t("pgbus.insights.show.latency_by_queue.empty") %></td></tr>
|
|
126
|
+
<% end %>
|
|
127
|
+
</tbody>
|
|
128
|
+
</table>
|
|
129
|
+
</div>
|
|
130
|
+
<% end %>
|
|
131
|
+
|
|
66
132
|
<!-- Slowest job classes -->
|
|
67
133
|
<div class="rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700">
|
|
68
134
|
<div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
@@ -93,91 +159,31 @@
|
|
|
93
159
|
</table>
|
|
94
160
|
</div>
|
|
95
161
|
|
|
96
|
-
<script
|
|
97
|
-
|
|
98
|
-
(function() {
|
|
99
|
-
// IIFE prevents "redeclaration of let" when Turbo re-executes on navigation
|
|
100
|
-
var throughputChart, statusChart;
|
|
101
|
-
|
|
102
|
-
function getThemeColors() {
|
|
103
|
-
var isDark = document.documentElement.classList.contains('dark');
|
|
104
|
-
return {
|
|
105
|
-
isDark: isDark,
|
|
106
|
-
text: isDark ? '#9ca3af' : '#6b7280',
|
|
107
|
-
grid: isDark ? '#374151' : '#e5e7eb',
|
|
108
|
-
tooltip: isDark ? 'dark' : 'light',
|
|
109
|
-
dataLabel: isDark ? '#fff' : '#000'
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function renderCharts(data) {
|
|
114
|
-
var t = getThemeColors();
|
|
162
|
+
<script type="module" nonce="<%= content_security_policy_nonce %>">
|
|
163
|
+
import { renderCharts, observeThemeChanges } from "charts";
|
|
115
164
|
|
|
116
|
-
|
|
117
|
-
|
|
165
|
+
const i18n = {
|
|
166
|
+
seriesName: "<%= j(t("pgbus.insights.show.charts.series_name")) %>",
|
|
167
|
+
noData: "<%= j(t("pgbus.insights.show.charts.no_data")) %>",
|
|
168
|
+
failedToLoad: "<%= j(t("pgbus.insights.show.charts.failed_to_load")) %>",
|
|
169
|
+
latencyAvg: "<%= j(t("pgbus.insights.show.charts.latency_avg")) %>",
|
|
170
|
+
latencyP95: "<%= j(t("pgbus.insights.show.charts.latency_p95")) %>",
|
|
171
|
+
};
|
|
118
172
|
|
|
119
|
-
|
|
120
|
-
return { x: new Date(p.time).getTime(), y: p.count };
|
|
121
|
-
});
|
|
173
|
+
let chartData = null;
|
|
122
174
|
|
|
123
|
-
|
|
124
|
-
series: [{ name: '<%= j(t("pgbus.insights.show.charts.series_name")) %>', data: throughputData }],
|
|
125
|
-
chart: { type: 'area', height: 280, toolbar: { show: false }, background: 'transparent', foreColor: t.text },
|
|
126
|
-
stroke: { curve: 'smooth', width: 2 },
|
|
127
|
-
fill: { type: 'gradient', gradient: { shadeIntensity: 1, opacityFrom: 0.4, opacityTo: 0.05, stops: [0, 100] } },
|
|
128
|
-
colors: ['#6366f1'],
|
|
129
|
-
xaxis: { type: 'datetime', labels: { style: { colors: t.text } } },
|
|
130
|
-
yaxis: { labels: { style: { colors: t.text } } },
|
|
131
|
-
grid: { borderColor: t.grid },
|
|
132
|
-
tooltip: { theme: t.tooltip },
|
|
133
|
-
dataLabels: { enabled: false }
|
|
134
|
-
});
|
|
135
|
-
throughputChart.render();
|
|
175
|
+
observeThemeChanges(() => chartData, i18n);
|
|
136
176
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (s === 'success') return '#10b981';
|
|
141
|
-
if (s === 'failed') return '#ef4444';
|
|
142
|
-
if (s === 'dead_lettered') return '#f97316';
|
|
143
|
-
return '#6b7280';
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
if (statusLabels.length > 0) {
|
|
147
|
-
statusChart = new ApexCharts(document.querySelector('#status-chart'), {
|
|
148
|
-
series: statusValues, labels: statusLabels,
|
|
149
|
-
chart: { type: 'donut', height: 280, background: 'transparent', foreColor: t.text },
|
|
150
|
-
colors: statusColors,
|
|
151
|
-
legend: { position: 'bottom', labels: { colors: t.text } },
|
|
152
|
-
plotOptions: { pie: { donut: { size: '60%' } } },
|
|
153
|
-
dataLabels: { style: { colors: [t.dataLabel] } },
|
|
154
|
-
tooltip: { theme: t.tooltip }
|
|
155
|
-
});
|
|
156
|
-
statusChart.render();
|
|
157
|
-
} else {
|
|
158
|
-
document.querySelector('#status-chart').innerHTML =
|
|
159
|
-
'<p class="text-center text-sm text-gray-400 dark:text-gray-500 pt-24"><%= j(t("pgbus.insights.show.charts.no_data")) %></p>';
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
var chartData = null;
|
|
164
|
-
fetch('<%= pgbus.api_insights_path(minutes: @minutes) %>')
|
|
165
|
-
.then(function(r) {
|
|
166
|
-
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
177
|
+
fetch("<%= pgbus.api_insights_path(minutes: @minutes) %>")
|
|
178
|
+
.then(r => {
|
|
179
|
+
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
167
180
|
return r.json();
|
|
168
181
|
})
|
|
169
|
-
.then(
|
|
170
|
-
.catch(
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (el1) el1.innerHTML = msg;
|
|
176
|
-
if (el2) el2.innerHTML = msg;
|
|
182
|
+
.then(data => { chartData = data; renderCharts(data, i18n); })
|
|
183
|
+
.catch(err => {
|
|
184
|
+
const msg = '<p class="text-center text-sm text-gray-400 dark:text-gray-500 pt-24">' + i18n.failedToLoad + "</p>";
|
|
185
|
+
document.querySelectorAll("#throughput-chart, #status-chart, #latency-chart").forEach(el => {
|
|
186
|
+
el.innerHTML = msg;
|
|
187
|
+
});
|
|
177
188
|
});
|
|
178
|
-
|
|
179
|
-
new MutationObserver(function() {
|
|
180
|
-
if (chartData) renderCharts(chartData);
|
|
181
|
-
}).observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
|
182
|
-
})();
|
|
183
189
|
</script>
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
<turbo-frame id="jobs-enqueued" data-auto-refresh data-src="<%= pgbus.jobs_path(request.query_parameters.merge(frame: 'enqueued')) %>">
|
|
2
2
|
<div>
|
|
3
|
-
<
|
|
3
|
+
<div class="flex items-center justify-between mb-3">
|
|
4
|
+
<h2 class="text-lg font-semibold text-gray-900 dark:text-white"><%= t("pgbus.jobs.enqueued_table.title") %></h2>
|
|
5
|
+
<% if @jobs.any? %>
|
|
6
|
+
<%= button_to t("pgbus.jobs.enqueued_table.discard_all"), pgbus.discard_all_enqueued_jobs_path, method: :post,
|
|
7
|
+
class: "rounded-md bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-500",
|
|
8
|
+
data: { turbo_confirm: t("pgbus.jobs.enqueued_table.discard_all_confirm"), turbo_frame: "_top" } %>
|
|
9
|
+
<% end %>
|
|
10
|
+
</div>
|
|
4
11
|
<div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700">
|
|
5
12
|
<table class="pgbus-table min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
6
13
|
<thead class="bg-gray-50 dark:bg-gray-900">
|
data/config/locales/da.yml
CHANGED
|
@@ -179,6 +179,8 @@ da:
|
|
|
179
179
|
title: Job i kø
|
|
180
180
|
discard: Kassér
|
|
181
181
|
discard_confirm: Kassér denne besked?
|
|
182
|
+
discard_all: Kassér alle
|
|
183
|
+
discard_all_confirm: Kassér alle ventende jobs og frigiv deres låse? Dette kan ikke fortrydes.
|
|
182
184
|
retry: Prøv igen
|
|
183
185
|
retry_confirm: Nulstil synlighedstimeout og prøv igen?
|
|
184
186
|
failed_table:
|
|
@@ -197,6 +199,7 @@ da:
|
|
|
197
199
|
index:
|
|
198
200
|
discard_all: Kassér alle
|
|
199
201
|
discard_all_confirm: Kassér alle mislykkede job?
|
|
202
|
+
discard_all_enqueued_notice: Kasserede %{count} ventende jobs og frigav deres låse.
|
|
200
203
|
retry_all: Forsøg alle igen
|
|
201
204
|
retry_all_confirm: Forsøg alle mislykkede job igen?
|
|
202
205
|
title: Job
|
data/config/locales/de.yml
CHANGED
|
@@ -179,6 +179,8 @@ de:
|
|
|
179
179
|
title: Eingereihte Jobs
|
|
180
180
|
discard: Verwerfen
|
|
181
181
|
discard_confirm: Diese Nachricht verwerfen?
|
|
182
|
+
discard_all: Alle verwerfen
|
|
183
|
+
discard_all_confirm: Alle eingereihten Jobs verwerfen und ihre Sperren freigeben? Dies kann nicht rückgängig gemacht werden.
|
|
182
184
|
retry: Wiederholen
|
|
183
185
|
retry_confirm: Sichtbarkeits-Timeout zurücksetzen und erneut versuchen?
|
|
184
186
|
failed_table:
|
|
@@ -197,6 +199,7 @@ de:
|
|
|
197
199
|
index:
|
|
198
200
|
discard_all: Alle verwerfen
|
|
199
201
|
discard_all_confirm: Alle fehlgeschlagenen Jobs verwerfen?
|
|
202
|
+
discard_all_enqueued_notice: "%{count} eingereihte Jobs verworfen und Sperren freigegeben."
|
|
200
203
|
retry_all: Alle wiederholen
|
|
201
204
|
retry_all_confirm: Alle fehlgeschlagenen Jobs wiederholen?
|
|
202
205
|
title: Jobs
|
data/config/locales/en.yml
CHANGED
|
@@ -128,11 +128,22 @@ en:
|
|
|
128
128
|
show:
|
|
129
129
|
charts:
|
|
130
130
|
failed_to_load: Failed to load chart data
|
|
131
|
+
latency: Queue Latency (ms)
|
|
132
|
+
latency_avg: Avg
|
|
133
|
+
latency_p95: P95
|
|
131
134
|
no_data: No data yet
|
|
132
135
|
series_name: Jobs/min
|
|
133
136
|
status_distribution: Status Distribution
|
|
134
137
|
throughput: Throughput (jobs/min)
|
|
135
138
|
description_html: Job performance metrics for the last %{range}
|
|
139
|
+
latency_by_queue:
|
|
140
|
+
empty: No latency data yet
|
|
141
|
+
headers:
|
|
142
|
+
avg: Avg (ms)
|
|
143
|
+
count: Count
|
|
144
|
+
p95: P95 (ms)
|
|
145
|
+
queue: Queue
|
|
146
|
+
title: Latency by Queue
|
|
136
147
|
slowest:
|
|
137
148
|
empty: No job stats yet
|
|
138
149
|
headers:
|
|
@@ -143,9 +154,14 @@ en:
|
|
|
143
154
|
title: Slowest Job Classes (avg duration)
|
|
144
155
|
summary:
|
|
145
156
|
avg_duration: Avg Duration
|
|
157
|
+
avg_latency: Avg Latency
|
|
158
|
+
avg_retries: Avg Retries
|
|
146
159
|
dead_lettered: Dead Lettered
|
|
147
160
|
failed: Failed
|
|
148
161
|
max_duration: Max Duration
|
|
162
|
+
p50_latency: P50 Latency
|
|
163
|
+
p95_latency: P95 Latency
|
|
164
|
+
p99_latency: P99 Latency
|
|
149
165
|
succeeded: Succeeded
|
|
150
166
|
total_jobs: Total Jobs
|
|
151
167
|
time_ranges:
|
|
@@ -177,6 +193,8 @@ en:
|
|
|
177
193
|
timezone: 'Timezone:'
|
|
178
194
|
visible_at: 'Visible at:'
|
|
179
195
|
discard: Discard
|
|
196
|
+
discard_all: Discard All
|
|
197
|
+
discard_all_confirm: Discard all enqueued jobs and release their locks? This cannot be undone.
|
|
180
198
|
discard_confirm: Discard this message?
|
|
181
199
|
retry: Retry
|
|
182
200
|
retry_confirm: Reset visibility timeout and retry?
|
|
@@ -197,6 +215,7 @@ en:
|
|
|
197
215
|
index:
|
|
198
216
|
discard_all: Discard All
|
|
199
217
|
discard_all_confirm: Discard all failed jobs?
|
|
218
|
+
discard_all_enqueued_notice: Discarded %{count} enqueued jobs and released their locks.
|
|
200
219
|
retry_all: Retry All
|
|
201
220
|
retry_all_confirm: Retry all failed jobs?
|
|
202
221
|
title: Jobs
|
data/config/locales/es.yml
CHANGED
|
@@ -179,6 +179,8 @@ es:
|
|
|
179
179
|
title: Trabajos en Cola
|
|
180
180
|
discard: Descartar
|
|
181
181
|
discard_confirm: "¿Descartar este mensaje?"
|
|
182
|
+
discard_all: Descartar todos
|
|
183
|
+
discard_all_confirm: "¿Descartar todos los trabajos en cola y liberar sus bloqueos? Esta acción no se puede deshacer."
|
|
182
184
|
retry: Reintentar
|
|
183
185
|
retry_confirm: "¿Restablecer tiempo de visibilidad y reintentar?"
|
|
184
186
|
failed_table:
|
|
@@ -197,6 +199,7 @@ es:
|
|
|
197
199
|
index:
|
|
198
200
|
discard_all: Descartar Todo
|
|
199
201
|
discard_all_confirm: "¿Descartar todos los trabajos fallidos?"
|
|
202
|
+
discard_all_enqueued_notice: Se descartaron %{count} trabajos en cola y se liberaron sus bloqueos.
|
|
200
203
|
retry_all: Reintentar Todo
|
|
201
204
|
retry_all_confirm: "¿Reintentar todos los trabajos fallidos?"
|
|
202
205
|
title: Trabajos
|
data/config/locales/fi.yml
CHANGED
|
@@ -179,6 +179,8 @@ fi:
|
|
|
179
179
|
title: Jonotetut työt
|
|
180
180
|
discard: Hylkää
|
|
181
181
|
discard_confirm: Hylätä tämä viesti?
|
|
182
|
+
discard_all: Hylkää kaikki
|
|
183
|
+
discard_all_confirm: Hylkää kaikki jonossa olevat tehtävät ja vapauta lukot? Tätä ei voi perua.
|
|
182
184
|
retry: Yritä uudelleen
|
|
183
185
|
retry_confirm: Nollaa näkyvyysaika ja yritä uudelleen?
|
|
184
186
|
failed_table:
|
|
@@ -197,6 +199,7 @@ fi:
|
|
|
197
199
|
index:
|
|
198
200
|
discard_all: Hylkää kaikki
|
|
199
201
|
discard_all_confirm: Hylätäänkö kaikki epäonnistuneet työt?
|
|
202
|
+
discard_all_enqueued_notice: Hylättiin %{count} jonossa olevaa tehtävää ja vapautettiin lukot.
|
|
200
203
|
retry_all: Yritä uudelleen kaikki
|
|
201
204
|
retry_all_confirm: Yritetäänkö uudelleen kaikki epäonnistuneet työt?
|
|
202
205
|
title: Työt
|