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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/pgbus/api/insights_controller.rb +6 -1
  3. data/app/controllers/pgbus/frontends_controller.rb +68 -0
  4. data/app/controllers/pgbus/insights_controller.rb +2 -0
  5. data/app/controllers/pgbus/jobs_controller.rb +5 -0
  6. data/app/frontend/pgbus/application.js +90 -0
  7. data/app/frontend/pgbus/modules/charts.js +106 -0
  8. data/app/frontend/pgbus/style.css +2 -0
  9. data/app/frontend/pgbus/tailwind.css +64 -0
  10. data/app/frontend/pgbus/vendor/apexcharts.js +38 -0
  11. data/app/frontend/pgbus/vendor/turbo.js +6696 -0
  12. data/app/models/pgbus/job_stat.rb +85 -13
  13. data/app/views/layouts/pgbus/application.html.erb +20 -141
  14. data/app/views/pgbus/insights/show.html.erb +86 -80
  15. data/app/views/pgbus/jobs/_enqueued_table.html.erb +8 -1
  16. data/config/locales/da.yml +3 -0
  17. data/config/locales/de.yml +3 -0
  18. data/config/locales/en.yml +19 -0
  19. data/config/locales/es.yml +3 -0
  20. data/config/locales/fi.yml +3 -0
  21. data/config/locales/fr.yml +3 -0
  22. data/config/locales/it.yml +3 -0
  23. data/config/locales/ja.yml +3 -0
  24. data/config/locales/nb.yml +3 -0
  25. data/config/locales/nl.yml +3 -0
  26. data/config/locales/pt.yml +3 -0
  27. data/config/locales/sv.yml +3 -0
  28. data/config/routes.rb +6 -0
  29. data/lib/generators/pgbus/add_job_stats_latency_generator.rb +52 -0
  30. data/lib/generators/pgbus/templates/add_job_stats_latency.rb.erb +9 -0
  31. data/lib/pgbus/active_job/executor.rb +24 -5
  32. data/lib/pgbus/recurring/schedule.rb +86 -0
  33. data/lib/pgbus/version.rb +1 -1
  34. data/lib/pgbus/web/data_source.rb +107 -0
  35. 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
- create!(
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
- row = since(minutes.minutes.ago).pick(
71
- Arel.sql("COUNT(*)"),
72
- Arel.sql("COUNT(*) FILTER (WHERE status = 'success')"),
73
- Arel.sql("COUNT(*) FILTER (WHERE status = 'failed')"),
74
- Arel.sql("COUNT(*) FILTER (WHERE status = 'dead_lettered')"),
75
- Arel.sql("ROUND(AVG(duration_ms)::numeric, 1)"),
76
- Arel.sql("MAX(duration_ms)")
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
- <style>
8
- /* Prevent white flash during navigation in dark mode.
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
- /* Responsive tables: stack rows as cards on small screens */
14
- @media (max-width: 1023px) {
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
- dialog.showModal();
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
- return new Promise((resolve) => {
115
- dialog.addEventListener("close", () => {
116
- resolve(dialog.returnValue === "confirm");
117
- }, { once: true });
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
- // -- Toast notifications --
122
- function showToast(message, type = "success") {
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 src="https://cdn.jsdelivr.net/npm/apexcharts@4"></script>
97
- <script>
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
- if (throughputChart) throughputChart.destroy();
117
- if (statusChart) statusChart.destroy();
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
- var throughputData = data.throughput.map(function(p) {
120
- return { x: new Date(p.time).getTime(), y: p.count };
121
- });
173
+ let chartData = null;
122
174
 
123
- throughputChart = new ApexCharts(document.querySelector('#throughput-chart'), {
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
- var statusLabels = Object.keys(data.status_counts);
138
- var statusValues = Object.values(data.status_counts);
139
- var statusColors = statusLabels.map(function(s) {
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(function(data) { chartData = data; renderCharts(data); })
170
- .catch(function(err) {
171
- if (err.name === 'AbortError') return;
172
- var msg = '<p class="text-center text-sm text-gray-400 dark:text-gray-500 pt-24"><%= j(t("pgbus.insights.show.charts.failed_to_load")) %></p>';
173
- var el1 = document.querySelector('#throughput-chart');
174
- var el2 = document.querySelector('#status-chart');
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
- <h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-3"><%= t("pgbus.jobs.enqueued_table.title") %></h2>
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">
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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