pgbus 0.2.8 → 0.3.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.
@@ -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">
@@ -93,91 +93,30 @@
93
93
  </table>
94
94
  </div>
95
95
 
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;
96
+ <script type="module" nonce="<%= content_security_policy_nonce %>">
97
+ import { renderCharts, observeThemeChanges } from "charts";
101
98
 
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
- }
99
+ const i18n = {
100
+ seriesName: "<%= j(t("pgbus.insights.show.charts.series_name")) %>",
101
+ noData: "<%= j(t("pgbus.insights.show.charts.no_data")) %>",
102
+ failedToLoad: "<%= j(t("pgbus.insights.show.charts.failed_to_load")) %>",
103
+ };
112
104
 
113
- function renderCharts(data) {
114
- var t = getThemeColors();
105
+ let chartData = null;
115
106
 
116
- if (throughputChart) throughputChart.destroy();
117
- if (statusChart) statusChart.destroy();
107
+ observeThemeChanges(() => chartData, i18n);
118
108
 
119
- var throughputData = data.throughput.map(function(p) {
120
- return { x: new Date(p.time).getTime(), y: p.count };
121
- });
122
-
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();
136
-
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);
109
+ fetch("<%= pgbus.api_insights_path(minutes: @minutes) %>")
110
+ .then(r => {
111
+ if (!r.ok) throw new Error("HTTP " + r.status);
167
112
  return r.json();
168
113
  })
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');
114
+ .then(data => { chartData = data; renderCharts(data, i18n); })
115
+ .catch(err => {
116
+ const msg = '<p class="text-center text-sm text-gray-400 dark:text-gray-500 pt-24">' + i18n.failedToLoad + "</p>";
117
+ const el1 = document.querySelector("#throughput-chart");
118
+ const el2 = document.querySelector("#status-chart");
175
119
  if (el1) el1.innerHTML = msg;
176
120
  if (el2) el2.innerHTML = msg;
177
121
  });
178
-
179
- new MutationObserver(function() {
180
- if (chartData) renderCharts(chartData);
181
- }).observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
182
- })();
183
122
  </script>
data/config/routes.rb CHANGED
@@ -60,4 +60,9 @@ Pgbus::Engine.routes.draw do
60
60
  get :stats, to: "stats#show"
61
61
  get :insights, to: "insights#show"
62
62
  end
63
+
64
+ scope :frontend, controller: :frontends, defaults: { version: Pgbus::VERSION.tr(".", "-") } do
65
+ get "modules/:version/:id", action: :module, as: :frontend_module, constraints: { format: "js" }
66
+ get "static/:version/:id", action: :static, as: :frontend_static
67
+ end
63
68
  end
@@ -12,11 +12,13 @@ module Pgbus
12
12
  def initialize(queues:, threads: 5, config: Pgbus.configuration,
13
13
  single_active_consumer: false, consumer_priority: 0)
14
14
  @queues = Array(queues)
15
+ @wildcard = @queues.include?("*")
15
16
  @threads = threads
16
17
  @config = config
17
18
  @single_active_consumer = single_active_consumer
18
19
  @consumer_priority = consumer_priority
19
20
  @lifecycle = Lifecycle.new
21
+ @last_wildcard_resolve = nil
20
22
  @jobs_processed = Concurrent::AtomicFixnum.new(0)
21
23
  @jobs_failed = Concurrent::AtomicFixnum.new(0)
22
24
  @in_flight = Concurrent::AtomicFixnum.new(0)
@@ -53,6 +55,7 @@ module Pgbus
53
55
  loop do
54
56
  process_signals
55
57
  check_recycle
58
+ refresh_wildcard_queues
56
59
 
57
60
  break if @lifecycle.stopped?
58
61
  break if @lifecycle.draining? && @pool.queue_length.zero?
@@ -79,6 +82,8 @@ module Pgbus
79
82
  @pool.kill
80
83
  end
81
84
 
85
+ WILDCARD_REFRESH_INTERVAL = 30 # seconds
86
+
82
87
  private
83
88
 
84
89
  def claim_and_execute
@@ -125,7 +130,11 @@ module Pgbus
125
130
  fetch_multi(active_queues, qty)
126
131
  end
127
132
  rescue StandardError => e
128
- Pgbus.logger.error { "[Pgbus] Error fetching messages: #{e.message}" }
133
+ if e.message.include?("does not exist") && e.message.include?("pgmq.q_")
134
+ evict_missing_queues(e)
135
+ else
136
+ Pgbus.logger.error { "[Pgbus] Error fetching messages: #{e.message}" }
137
+ end
129
138
  []
130
139
  end
131
140
 
@@ -184,9 +193,8 @@ module Pgbus
184
193
  end
185
194
 
186
195
  # Resolve "*" to all non-DLQ queues from pgmq.meta, stripping the prefix.
187
- # Called once at startup. If no wildcard, this is a no-op.
188
196
  def resolve_wildcard_queues
189
- return unless @queues.include?("*")
197
+ return unless @wildcard
190
198
 
191
199
  dlq_suffix = config.dead_letter_queue_suffix
192
200
  prefix = "#{config.queue_prefix}_"
@@ -201,12 +209,39 @@ module Pgbus
201
209
  Pgbus.logger.warn { "[Pgbus] Wildcard queue '*' resolved to no queues — falling back to default" }
202
210
  @queues = [config.default_queue]
203
211
  else
212
+ if @last_wildcard_resolve && resolved != @queues
213
+ Pgbus.logger.info { "[Pgbus] Wildcard queues changed: #{@queues.join(", ")} → #{resolved.join(", ")}" }
214
+ end
204
215
  @queues = resolved
205
- Pgbus.logger.info { "[Pgbus] Wildcard queue '*' resolved to: #{@queues.join(", ")}" }
216
+ Pgbus.logger.info { "[Pgbus] Wildcard queue '*' resolved to: #{@queues.join(", ")}" } unless @last_wildcard_resolve
206
217
  end
218
+ @last_wildcard_resolve = monotonic_now
207
219
  rescue StandardError => e
208
220
  Pgbus.logger.error { "[Pgbus] Failed to resolve wildcard queues: #{e.message} — falling back to default" }
209
- @queues = [config.default_queue]
221
+ @queues = [config.default_queue] unless @last_wildcard_resolve
222
+ end
223
+
224
+ # Periodically re-resolve wildcard queues to pick up new queues and
225
+ # drop deleted ones without requiring a worker restart.
226
+ def refresh_wildcard_queues
227
+ return unless @wildcard
228
+ return if @last_wildcard_resolve && (monotonic_now - @last_wildcard_resolve) < WILDCARD_REFRESH_INTERVAL
229
+
230
+ resolve_wildcard_queues
231
+ end
232
+
233
+ # When a "relation does not exist" error occurs, the queue was deleted.
234
+ # Extract the queue name from the error and remove it from the active list.
235
+ def evict_missing_queues(error)
236
+ prefix = "#{config.queue_prefix}_"
237
+ if error.message =~ /pgmq\.q_(\w+)/
238
+ physical_name = Regexp.last_match(1)
239
+ logical_name = physical_name.delete_prefix(prefix)
240
+ if @queues.delete(logical_name)
241
+ Pgbus.logger.warn { "[Pgbus] Evicted deleted queue '#{logical_name}' (#{physical_name}) from worker" }
242
+ end
243
+ end
244
+ Pgbus.logger.error { "[Pgbus] Queue table missing: #{error.message}" }
210
245
  end
211
246
 
212
247
  def check_recycle
@@ -287,6 +322,10 @@ module Pgbus
287
322
  restore_signals
288
323
  Pgbus.logger.info { "[Pgbus] Worker stopped. Processed: #{@jobs_processed.value}, Failed: #{@jobs_failed.value}" }
289
324
  end
325
+
326
+ def monotonic_now
327
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
328
+ end
290
329
  end
291
330
  end
292
331
  end
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.2.8"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgbus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.8
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -127,6 +127,7 @@ files:
127
127
  - app/controllers/pgbus/dashboard_controller.rb
128
128
  - app/controllers/pgbus/dead_letter_controller.rb
129
129
  - app/controllers/pgbus/events_controller.rb
130
+ - app/controllers/pgbus/frontends_controller.rb
130
131
  - app/controllers/pgbus/insights_controller.rb
131
132
  - app/controllers/pgbus/jobs_controller.rb
132
133
  - app/controllers/pgbus/locale_controller.rb
@@ -135,6 +136,12 @@ files:
135
136
  - app/controllers/pgbus/processes_controller.rb
136
137
  - app/controllers/pgbus/queues_controller.rb
137
138
  - app/controllers/pgbus/recurring_tasks_controller.rb
139
+ - app/frontend/pgbus/application.js
140
+ - app/frontend/pgbus/modules/charts.js
141
+ - app/frontend/pgbus/style.css
142
+ - app/frontend/pgbus/tailwind.css
143
+ - app/frontend/pgbus/vendor/apexcharts.js
144
+ - app/frontend/pgbus/vendor/turbo.js
138
145
  - app/helpers/pgbus/application_helper.rb
139
146
  - app/models/pgbus/application_record.rb
140
147
  - app/models/pgbus/batch_entry.rb