pgbus 0.3.3 → 0.3.4

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/pgbus/dead_letter_controller.rb +17 -0
  3. data/app/controllers/pgbus/jobs_controller.rb +36 -0
  4. data/app/controllers/pgbus/locks_controller.rb +25 -0
  5. data/app/frontend/pgbus/application.js +45 -0
  6. data/app/models/pgbus/job_lock.rb +16 -8
  7. data/app/models/pgbus/uniqueness_key.rb +36 -0
  8. data/app/views/pgbus/dead_letter/_messages_table.html.erb +22 -2
  9. data/app/views/pgbus/dead_letter/index.html.erb +9 -1
  10. data/app/views/pgbus/jobs/_enqueued_table.html.erb +36 -6
  11. data/app/views/pgbus/jobs/_failed_table.html.erb +35 -4
  12. data/app/views/pgbus/locks/index.html.erb +53 -28
  13. data/config/locales/da.yml +3 -7
  14. data/config/locales/de.yml +3 -7
  15. data/config/locales/en.yml +33 -7
  16. data/config/locales/es.yml +3 -7
  17. data/config/locales/fi.yml +3 -7
  18. data/config/locales/fr.yml +3 -7
  19. data/config/locales/it.yml +3 -7
  20. data/config/locales/ja.yml +3 -7
  21. data/config/locales/nb.yml +3 -7
  22. data/config/locales/nl.yml +3 -7
  23. data/config/locales/pt.yml +3 -7
  24. data/config/locales/sv.yml +3 -7
  25. data/config/routes.rb +12 -1
  26. data/lib/generators/pgbus/migrate_job_locks_generator.rb +56 -0
  27. data/lib/generators/pgbus/templates/add_uniqueness_keys.rb.erb +13 -0
  28. data/lib/generators/pgbus/templates/migrate_job_locks_to_uniqueness_keys.rb.erb +33 -0
  29. data/lib/pgbus/active_job/executor.rb +34 -20
  30. data/lib/pgbus/client.rb +18 -2
  31. data/lib/pgbus/process/dispatcher.rb +33 -10
  32. data/lib/pgbus/process/worker.rb +4 -1
  33. data/lib/pgbus/recurring/schedule.rb +38 -35
  34. data/lib/pgbus/stat_buffer.rb +92 -0
  35. data/lib/pgbus/uniqueness.rb +24 -39
  36. data/lib/pgbus/version.rb +1 -1
  37. data/lib/pgbus/web/data_source.rb +46 -15
  38. metadata +6 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cba1a8a8eaf3c351cd3c42a2af74a0355fdafd2dc26727227ee667037dfb0ed9
4
- data.tar.gz: 7fea4f964087fa5fcfb7381dc449344ff156d5607d49be3b855569ce5fafb12b
3
+ metadata.gz: 35114da580e6620d0e519cd3d7bf4340728273a2e945eb30d228464d8b8dcc2c
4
+ data.tar.gz: ce2d743e27e02e05b2d94a7632403670cbeb5bf305be1fab0b39823cb87ce4e2
5
5
  SHA512:
6
- metadata.gz: 10b2ca7d95e05b23597578f7b91e52a76a450df4a30b80f38289048ad440153349388112a029c451d899a1118710e8d0e46c0453d1278f6c385f27b99299aa54
7
- data.tar.gz: 51adda432c7ec2cf2977e62e37432982954e8da267e4687bc42303d690e3adda492bcb9663769259f29f419cc9ba0a2eb616993f8b78338725cdf6396ccaa9e2
6
+ metadata.gz: 6f917b1f6c754fd12b687d714653e87a8766156498c468a9708b05aa26e51d0f852de3e0b6a9445b1f02f2569d18bd28e35c76cba40adcc09b089fa4bb2b7093
7
+ data.tar.gz: 1f54ebbd27831d925d2b73de1ac763461d1712e965cf79f1dc20c1c6cc9a99c1955cb777072a0aeee0b6ef4c8d0423bcb063b67cd6291faf3510c4efadda8069
@@ -46,5 +46,22 @@ module Pgbus
46
46
  count = data_source.discard_all_dlq
47
47
  redirect_to dead_letter_index_path, notice: "Discarded #{count} DLQ messages."
48
48
  end
49
+
50
+ def discard_selected
51
+ selections = Array(params[:messages]).reject { |s| s[:queue_name].blank? || s[:msg_id].blank? }
52
+ if selections.empty?
53
+ redirect_to dead_letter_index_path, alert: t("pgbus.dead_letter.index.none_selected")
54
+ return
55
+ end
56
+
57
+ count = 0
58
+ selections.each do |sel|
59
+ queue_name = sel[:queue_name].to_s
60
+ next unless queue_name.end_with?(Pgbus.configuration.dead_letter_queue_suffix)
61
+
62
+ count += 1 if data_source.discard_dlq_message(queue_name, sel[:msg_id])
63
+ end
64
+ redirect_to dead_letter_index_path, notice: t("pgbus.dead_letter.index.discarded_selected", count: count)
65
+ end
49
66
  end
50
67
  end
@@ -49,5 +49,41 @@ module Pgbus
49
49
  count = data_source.discard_all_enqueued
50
50
  redirect_to jobs_path, notice: t("pgbus.jobs.index.discard_all_enqueued_notice", count: count)
51
51
  end
52
+
53
+ def discard_selected_failed
54
+ ids = Array(params[:ids]).map(&:to_i).reject(&:zero?)
55
+ if ids.empty?
56
+ redirect_to jobs_path, alert: t("pgbus.jobs.index.none_selected")
57
+ return
58
+ end
59
+
60
+ count = 0
61
+ ids.each do |id|
62
+ count += 1 if data_source.discard_failed_event(id)
63
+ end
64
+ redirect_to jobs_path, notice: t("pgbus.jobs.index.discarded_selected", count: count)
65
+ end
66
+
67
+ def discard_selected_enqueued
68
+ selections = Array(params[:messages]).filter_map do |s|
69
+ next unless s.respond_to?(:[])
70
+
71
+ queue_name = s[:queue_name]
72
+ msg_id = s[:msg_id]
73
+ next if queue_name.blank? || msg_id.blank?
74
+
75
+ { queue_name: queue_name, msg_id: msg_id }
76
+ end
77
+ if selections.empty?
78
+ redirect_to jobs_path, alert: t("pgbus.jobs.index.none_selected")
79
+ return
80
+ end
81
+
82
+ count = 0
83
+ selections.each do |sel|
84
+ count += 1 if data_source.discard_job(sel[:queue_name], sel[:msg_id])
85
+ end
86
+ redirect_to jobs_path, notice: t("pgbus.jobs.index.discarded_selected", count: count)
87
+ end
52
88
  end
53
89
  end
@@ -5,5 +5,30 @@ module Pgbus
5
5
  def index
6
6
  @locks = data_source.job_locks
7
7
  end
8
+
9
+ def discard
10
+ count = data_source.discard_lock(params[:id])
11
+ if count.positive?
12
+ redirect_to locks_path, notice: t("pgbus.locks.index.lock_discarded")
13
+ else
14
+ redirect_to locks_path, alert: t("pgbus.locks.index.lock_discard_failed")
15
+ end
16
+ end
17
+
18
+ def discard_selected
19
+ keys = Array(params[:lock_keys]).reject(&:blank?)
20
+ if keys.empty?
21
+ redirect_to locks_path, alert: t("pgbus.locks.index.none_selected")
22
+ return
23
+ end
24
+
25
+ count = data_source.discard_locks(keys)
26
+ redirect_to locks_path, notice: t("pgbus.locks.index.locks_discarded", count: count)
27
+ end
28
+
29
+ def discard_all
30
+ count = data_source.discard_all_locks
31
+ redirect_to locks_path, notice: t("pgbus.locks.index.all_locks_discarded", count: count)
32
+ end
8
33
  end
9
34
  end
@@ -69,6 +69,51 @@ function renderFlashToasts() {
69
69
  renderFlashToasts();
70
70
  document.addEventListener("turbo:load", renderFlashToasts);
71
71
 
72
+ // -- Bulk checkbox selection --
73
+ function initBulkSelect() {
74
+ document.querySelectorAll("[data-bulk-select-all]").forEach(selectAll => {
75
+ if (selectAll.dataset.bulkInitialized === "true") return;
76
+ selectAll.dataset.bulkInitialized = "true";
77
+
78
+ const scope = selectAll.closest("[data-bulk-scope]") || document;
79
+ const checkboxes = () => scope.querySelectorAll("input[data-bulk-item]");
80
+ // Look for bulk-actions in the parent page container (outside the table scope)
81
+ const pageScope = scope.closest("[data-bulk-page]") || scope.closest("turbo-frame") || scope.parentElement?.closest("div") || document;
82
+ const countEl = pageScope.querySelector("[data-bulk-count]");
83
+ const actions = pageScope.querySelector("[data-bulk-actions]");
84
+
85
+ function updateUI() {
86
+ const checked = scope.querySelectorAll("input[data-bulk-item]:checked");
87
+ if (countEl) countEl.textContent = checked.length;
88
+ if (actions) actions.classList.toggle("hidden", checked.length === 0);
89
+
90
+ const all = checkboxes();
91
+ selectAll.checked = all.length > 0 && checked.length === all.length;
92
+ selectAll.indeterminate = checked.length > 0 && checked.length < all.length;
93
+ }
94
+
95
+ let inSelectAll = false;
96
+ selectAll.addEventListener("change", () => {
97
+ inSelectAll = true;
98
+ checkboxes().forEach(cb => {
99
+ cb.checked = selectAll.checked;
100
+ // Dispatch change event so inline onchange handlers fire (e.g., hidden input sync)
101
+ cb.dispatchEvent(new Event("change", { bubbles: false }));
102
+ });
103
+ inSelectAll = false;
104
+ updateUI();
105
+ });
106
+
107
+ scope.addEventListener("change", (e) => {
108
+ if (e.target.matches("input[data-bulk-item]")) updateUI();
109
+ });
110
+ });
111
+
112
+ }
113
+ initBulkSelect();
114
+ document.addEventListener("turbo:load", initBulkSelect);
115
+ document.addEventListener("turbo:frame-load", initBulkSelect);
116
+
72
117
  // -- Auto-refresh --
73
118
  const refreshInterval = parseInt(document.body?.dataset.pgbusRefreshInterval || "0", 10);
74
119
  if (refreshInterval > 0) {
@@ -62,23 +62,31 @@ module Pgbus
62
62
  !result.nil?
63
63
  end
64
64
 
65
- # Reap orphaned locks: locks in 'executing' state whose owner_pid
66
- # has no healthy entry in pgbus_processes.
67
- # Returns the number of orphaned locks released.
68
- # Reap orphaned locks by matching (pid, hostname) against live process entries.
69
- # A lock is orphaned if no healthy process exists with the same pid AND hostname.
65
+ # Reap orphaned locks whose owner is no longer alive, plus stale queued
66
+ # locks that were never claimed by a worker.
67
+ # Returns the total number of orphaned locks released.
70
68
  def self.reap_orphaned!
69
+ reaped = 0
70
+
71
+ # 1. Executing locks whose owner process has no healthy heartbeat
71
72
  alive_workers = ProcessEntry
72
73
  .where("last_heartbeat_at >= ?", Time.current - Process::Heartbeat::ALIVE_THRESHOLD)
73
74
  .pluck(:pid, :hostname)
74
75
 
75
- orphaned = executing.select do |lock|
76
+ orphaned_executing = executing.select do |lock|
76
77
  alive_workers.none? { |pid, hostname| pid == lock.owner_pid && hostname == lock.owner_hostname }
77
78
  end
78
79
 
79
- return 0 if orphaned.empty?
80
+ reaped += where(id: orphaned_executing.map(&:id)).delete_all if orphaned_executing.any?
81
+
82
+ # 2. Queued locks older than the visibility timeout that were never
83
+ # claimed. These are left behind when enqueue fails after lock
84
+ # acquisition (e.g. network error, process crash).
85
+ threshold = Pgbus.configuration.visibility_timeout
86
+ stale_queued = queued_locks.where("locked_at < ?", Time.current - threshold)
87
+ reaped += stale_queued.delete_all if stale_queued.exists?
80
88
 
81
- where(id: orphaned.map(&:id)).delete_all
89
+ reaped
82
90
  end
83
91
 
84
92
  # Last-resort cleanup: delete locks whose expires_at has passed.
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class UniquenessKey < BusRecord
5
+ self.table_name = "pgbus_uniqueness_keys"
6
+ self.primary_key = "lock_key"
7
+
8
+ # Atomically try to acquire a uniqueness lock via INSERT ... ON CONFLICT.
9
+ # PostgreSQL's unique index on lock_key guarantees at most one caller wins.
10
+ # Returns true if acquired (row inserted), false if already locked.
11
+ def self.acquire!(lock_key, queue_name:, msg_id:) # rubocop:disable Naming/PredicateMethod
12
+ connection.exec_query(
13
+ "INSERT INTO #{table_name} (lock_key, queue_name, msg_id) " \
14
+ "VALUES ($1, $2, $3) ON CONFLICT (lock_key) DO NOTHING RETURNING lock_key",
15
+ "UniquenessKey Acquire", [lock_key, queue_name, msg_id]
16
+ ).rows.any?
17
+ end
18
+
19
+ # Release a uniqueness lock after job completion or DLQ.
20
+ def self.release!(lock_key)
21
+ connection.exec_delete(
22
+ "DELETE FROM #{table_name} WHERE lock_key = $1",
23
+ "UniquenessKey Release", [lock_key]
24
+ )
25
+ end
26
+
27
+ # Check if a key is currently locked.
28
+ def self.locked?(lock_key)
29
+ result = connection.select_value(
30
+ "SELECT 1 FROM #{table_name} WHERE lock_key = $1 LIMIT 1",
31
+ "UniquenessKey Check", [lock_key]
32
+ )
33
+ !result.nil?
34
+ end
35
+ end
36
+ end
@@ -1,8 +1,19 @@
1
1
  <turbo-frame id="dlq-messages" data-auto-refresh data-src="<%= pgbus.dead_letter_index_path(frame: 'list') %>">
2
- <div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700">
2
+ <%# Bulk form outside table to avoid nested form issues %>
3
+ <form id="bulk-discard-dlq-form" action="<%= pgbus.discard_selected_dead_letter_index_path %>" method="post" data-turbo-frame="_top" class="hidden">
4
+ <%= hidden_field_tag :authenticity_token, form_authenticity_token %>
5
+ </form>
6
+ <div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700" data-bulk-scope="dlq">
3
7
  <table class="pgbus-table min-w-full divide-y divide-gray-200 dark:divide-gray-700">
4
8
  <thead class="bg-gray-50 dark:bg-gray-900">
5
9
  <tr>
10
+ <% if @messages.any? %>
11
+ <th class="w-10 px-4 py-3">
12
+ <input type="checkbox" data-bulk-select-all
13
+ aria-label="<%= t("pgbus.helpers.bulk_select_all") %>"
14
+ class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600">
15
+ </th>
16
+ <% end %>
6
17
  <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"><%= t("pgbus.dead_letter.messages_table.headers.id") %></th>
7
18
  <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"><%= t("pgbus.dead_letter.messages_table.headers.job_class") %></th>
8
19
  <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"><%= t("pgbus.dead_letter.messages_table.headers.source_queue") %></th>
@@ -16,6 +27,15 @@
16
27
  <% dlq_suffix = Pgbus.configuration.dead_letter_queue_suffix %>
17
28
  <% source_queue = m[:queue_name].to_s.delete_suffix(dlq_suffix) %>
18
29
  <tr>
30
+ <td class="w-10 px-4 py-3 align-top">
31
+ <input type="checkbox" data-bulk-item
32
+ aria-label="<%= t("pgbus.helpers.bulk_select_row", id: m[:msg_id]) %>"
33
+ data-queue-name="<%= m[:queue_name] %>" data-msg-id="<%= m[:msg_id] %>"
34
+ class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600 mt-1"
35
+ onchange="this.nextElementSibling.disabled = !this.checked; this.nextElementSibling.nextElementSibling.disabled = !this.checked">
36
+ <input type="hidden" name="messages[][queue_name]" value="<%= m[:queue_name] %>" form="bulk-discard-dlq-form" disabled>
37
+ <input type="hidden" name="messages[][msg_id]" value="<%= m[:msg_id] %>" form="bulk-discard-dlq-form" disabled>
38
+ </td>
19
39
  <td colspan="5" class="p-0">
20
40
  <details class="group">
21
41
  <summary class="flex cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 dark:bg-gray-900 list-none">
@@ -69,7 +89,7 @@
69
89
  </tr>
70
90
  <% end %>
71
91
  <% if @messages.empty? %>
72
- <tr><td colspan="5" class="px-4 py-8 text-center text-sm text-gray-400"><%= t("pgbus.dead_letter.messages_table.empty") %></td></tr>
92
+ <tr><td colspan="6" class="px-4 py-8 text-center text-sm text-gray-400"><%= t("pgbus.dead_letter.messages_table.empty") %></td></tr>
73
93
  <% end %>
74
94
  </tbody>
75
95
  </table>
@@ -1,7 +1,15 @@
1
1
  <div class="flex items-center justify-between mb-6">
2
2
  <h1 class="text-2xl font-bold text-gray-900 dark:text-white"><%= t("pgbus.dead_letter.index.title") %></h1>
3
3
  <% if @messages.any? %>
4
- <div class="flex space-x-2">
4
+ <div class="flex items-center space-x-2">
5
+ <div data-bulk-actions class="hidden flex items-center space-x-2">
6
+ <span class="text-sm text-gray-500 dark:text-gray-400"><span data-bulk-count>0</span> <%= t("pgbus.helpers.bulk_selected") %></span>
7
+ <button type="submit" form="bulk-discard-dlq-form"
8
+ class="rounded-md bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-500"
9
+ data-turbo-confirm="<%= t("pgbus.dead_letter.index.discard_selected_confirm") %>">
10
+ <%= t("pgbus.dead_letter.index.discard_selected") %>
11
+ </button>
12
+ </div>
5
13
  <%= button_to t("pgbus.dead_letter.index.retry_all"), pgbus.retry_all_dead_letter_index_path, method: :post,
6
14
  class: "rounded-md bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-500",
7
15
  data: { turbo_confirm: t("pgbus.dead_letter.index.retry_all_confirm") } %>
@@ -1,17 +1,38 @@
1
1
  <turbo-frame id="jobs-enqueued" data-auto-refresh data-src="<%= pgbus.jobs_path(request.query_parameters.merge(frame: 'enqueued')) %>">
2
- <div>
2
+ <div data-bulk-page>
3
3
  <div class="flex items-center justify-between mb-3">
4
4
  <h2 class="text-lg font-semibold text-gray-900 dark:text-white"><%= t("pgbus.jobs.enqueued_table.title") %></h2>
5
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" } %>
6
+ <div class="flex items-center space-x-2">
7
+ <div data-bulk-actions class="hidden flex items-center space-x-2">
8
+ <span class="text-sm text-gray-500 dark:text-gray-400"><span data-bulk-count>0</span> <%= t("pgbus.helpers.bulk_selected") %></span>
9
+ <button type="submit" form="bulk-discard-enqueued-form"
10
+ class="rounded-md bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-500"
11
+ data-turbo-confirm="<%= t("pgbus.jobs.index.discard_selected_confirm") %>">
12
+ <%= t("pgbus.jobs.index.discard_selected") %>
13
+ </button>
14
+ </div>
15
+ <%= button_to t("pgbus.jobs.enqueued_table.discard_all"), pgbus.discard_all_enqueued_jobs_path, method: :post,
16
+ class: "rounded-md bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-500",
17
+ data: { turbo_confirm: t("pgbus.jobs.enqueued_table.discard_all_confirm"), turbo_frame: "_top" } %>
18
+ </div>
9
19
  <% end %>
10
20
  </div>
11
- <div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700">
21
+ <%# Bulk form outside table to avoid nested form issues %>
22
+ <form id="bulk-discard-enqueued-form" action="<%= pgbus.discard_selected_enqueued_jobs_path %>" method="post" data-turbo-frame="_top" class="hidden">
23
+ <%= hidden_field_tag :authenticity_token, form_authenticity_token %>
24
+ </form>
25
+ <div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700" data-bulk-scope="enqueued">
12
26
  <table class="pgbus-table min-w-full divide-y divide-gray-200 dark:divide-gray-700">
13
27
  <thead class="bg-gray-50 dark:bg-gray-900">
14
28
  <tr>
29
+ <% if @jobs.any? %>
30
+ <th class="w-10 px-4 py-3">
31
+ <input type="checkbox" data-bulk-select-all
32
+ aria-label="<%= t("pgbus.helpers.bulk_select_all") %>"
33
+ class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600">
34
+ </th>
35
+ <% end %>
15
36
  <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"><%= t("pgbus.jobs.enqueued_table.headers.id") %></th>
16
37
  <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"><%= t("pgbus.jobs.enqueued_table.headers.job_class") %></th>
17
38
  <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"><%= t("pgbus.jobs.enqueued_table.headers.queue") %></th>
@@ -23,6 +44,15 @@
23
44
  <% @jobs.each do |j| %>
24
45
  <% payload = pgbus_parse_message(j[:message]) %>
25
46
  <tr>
47
+ <td class="w-10 px-4 py-3 align-top">
48
+ <input type="checkbox" data-bulk-item
49
+ aria-label="<%= t("pgbus.helpers.bulk_select_row", id: j[:msg_id]) %>"
50
+ data-queue-name="<%= j[:queue_name] %>" data-msg-id="<%= j[:msg_id] %>"
51
+ class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600 mt-1"
52
+ onchange="this.nextElementSibling.disabled = !this.checked; this.nextElementSibling.nextElementSibling.disabled = !this.checked">
53
+ <input type="hidden" name="messages[][queue_name]" value="<%= j[:queue_name] %>" form="bulk-discard-enqueued-form" disabled>
54
+ <input type="hidden" name="messages[][msg_id]" value="<%= j[:msg_id] %>" form="bulk-discard-enqueued-form" disabled>
55
+ </td>
26
56
  <td colspan="5" class="p-0">
27
57
  <details class="group">
28
58
  <summary class="flex cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 dark:bg-gray-900 list-none">
@@ -80,7 +110,7 @@
80
110
  </tr>
81
111
  <% end %>
82
112
  <% if @jobs.empty? %>
83
- <tr><td colspan="5" class="px-4 py-8 text-center text-sm text-gray-400"><%= t("pgbus.jobs.enqueued_table.empty") %></td></tr>
113
+ <tr><td colspan="6" class="px-4 py-8 text-center text-sm text-gray-400"><%= t("pgbus.jobs.enqueued_table.empty") %></td></tr>
84
114
  <% end %>
85
115
  </tbody>
86
116
  </table>
@@ -1,10 +1,35 @@
1
1
  <turbo-frame id="jobs-failed" data-auto-refresh data-src="<%= pgbus.jobs_path(frame: 'failed') %>">
2
- <div class="mb-8">
3
- <h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-3"><%= t("pgbus.jobs.failed_table.title") %></h2>
4
- <div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700">
2
+ <div class="mb-8" data-bulk-page>
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.failed_table.title") %></h2>
5
+ <% if @failed.any? %>
6
+ <div class="flex items-center space-x-2">
7
+ <div data-bulk-actions class="hidden flex items-center space-x-2">
8
+ <span class="text-sm text-gray-500 dark:text-gray-400"><span data-bulk-count>0</span> <%= t("pgbus.helpers.bulk_selected") %></span>
9
+ <button type="submit" form="bulk-discard-failed-form"
10
+ class="rounded-md bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-500"
11
+ data-turbo-confirm="<%= t("pgbus.jobs.index.discard_selected_confirm") %>">
12
+ <%= t("pgbus.jobs.index.discard_selected") %>
13
+ </button>
14
+ </div>
15
+ </div>
16
+ <% end %>
17
+ </div>
18
+ <%# Bulk form outside table to avoid nested form issues %>
19
+ <form id="bulk-discard-failed-form" action="<%= pgbus.discard_selected_failed_jobs_path %>" method="post" data-turbo-frame="_top" class="hidden">
20
+ <%= hidden_field_tag :authenticity_token, form_authenticity_token %>
21
+ </form>
22
+ <div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700" data-bulk-scope="failed">
5
23
  <table class="pgbus-table min-w-full divide-y divide-gray-200 dark:divide-gray-700">
6
24
  <thead class="bg-gray-50 dark:bg-gray-900">
7
25
  <tr>
26
+ <% if @failed.any? %>
27
+ <th class="w-10 px-4 py-3">
28
+ <input type="checkbox" data-bulk-select-all
29
+ aria-label="<%= t("pgbus.helpers.bulk_select_all") %>"
30
+ class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600">
31
+ </th>
32
+ <% end %>
8
33
  <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"><%= t("pgbus.jobs.failed_table.headers.id") %></th>
9
34
  <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"><%= t("pgbus.jobs.failed_table.headers.queue") %></th>
10
35
  <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"><%= t("pgbus.jobs.failed_table.headers.error") %></th>
@@ -16,6 +41,12 @@
16
41
  <tbody class="divide-y divide-gray-100 dark:divide-gray-700">
17
42
  <% @failed.each do |f| %>
18
43
  <tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 dark:bg-gray-900">
44
+ <td class="w-10 px-4 py-3">
45
+ <input type="checkbox" name="ids[]" value="<%= f["id"] %>" form="bulk-discard-failed-form"
46
+ aria-label="<%= t("pgbus.helpers.bulk_select_row", id: f["id"]) %>"
47
+ data-bulk-item
48
+ class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600">
49
+ </td>
19
50
  <td data-label="ID" class="px-4 py-3 text-sm font-mono text-gray-900 dark:text-white">
20
51
  <%= link_to f["id"], pgbus.job_path(f["id"]), class: "text-indigo-600 hover:text-indigo-500", data: { turbo_frame: "_top" } %>
21
52
  </td>
@@ -36,7 +67,7 @@
36
67
  </tr>
37
68
  <% end %>
38
69
  <% if @failed.empty? %>
39
- <tr><td colspan="6" class="px-4 py-8 text-center text-sm text-gray-400"><%= t("pgbus.jobs.failed_table.empty") %></td></tr>
70
+ <tr><td colspan="7" class="px-4 py-8 text-center text-sm text-gray-400"><%= t("pgbus.jobs.failed_table.empty") %></td></tr>
40
71
  <% end %>
41
72
  </tbody>
42
73
  </table>
@@ -1,52 +1,77 @@
1
- <div class="mb-6">
2
- <h1 class="text-2xl font-bold text-gray-900 dark:text-white"><%= t("pgbus.locks.index.title") %></h1>
3
- <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= t("pgbus.locks.index.description") %></p>
1
+ <div class="flex items-center justify-between mb-6">
2
+ <div>
3
+ <h1 class="text-2xl font-bold text-gray-900 dark:text-white"><%= t("pgbus.locks.index.title") %></h1>
4
+ <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= t("pgbus.locks.index.description") %></p>
5
+ </div>
6
+ <% if @locks.any? %>
7
+ <div class="flex items-center space-x-2">
8
+ <div data-bulk-actions class="hidden flex items-center space-x-2">
9
+ <span class="text-sm text-gray-500 dark:text-gray-400"><span data-bulk-count>0</span> <%= t("pgbus.helpers.bulk_selected") %></span>
10
+ <button type="submit" form="bulk-discard-locks-form"
11
+ class="rounded-md bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-500"
12
+ data-turbo-confirm="<%= t("pgbus.locks.index.discard_selected_confirm") %>">
13
+ <%= t("pgbus.locks.index.discard_selected") %>
14
+ </button>
15
+ </div>
16
+ <%= button_to t("pgbus.locks.index.discard_all"), pgbus.discard_all_locks_path, method: :post,
17
+ class: "rounded-md bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-500",
18
+ data: { turbo_confirm: t("pgbus.locks.index.discard_all_confirm") } %>
19
+ </div>
20
+ <% end %>
4
21
  </div>
5
22
 
6
- <div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700">
23
+ <%# Bulk form lives outside the table to avoid nested form issues with button_to %>
24
+ <form id="bulk-discard-locks-form" action="<%= pgbus.discard_selected_locks_path %>" method="post" data-turbo-frame="_top" class="hidden">
25
+ <%= hidden_field_tag :authenticity_token, form_authenticity_token %>
26
+ </form>
27
+
28
+ <div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700" data-bulk-scope="locks">
7
29
  <table class="pgbus-table min-w-full divide-y divide-gray-200 dark:divide-gray-700">
8
30
  <thead class="bg-gray-50 dark:bg-gray-900">
9
31
  <tr>
32
+ <% if @locks.any? %>
33
+ <th class="w-10 px-4 py-3">
34
+ <input type="checkbox" data-bulk-select-all
35
+ aria-label="<%= t("pgbus.helpers.bulk_select_all") %>"
36
+ class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600">
37
+ </th>
38
+ <% end %>
10
39
  <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.locks.index.headers.lock_key") %></th>
11
- <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.locks.index.headers.job_class") %></th>
12
- <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.locks.index.headers.state") %></th>
13
- <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.locks.index.headers.owner") %></th>
40
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.locks.index.headers.queue_name", default: "Queue") %></th>
41
+ <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.locks.index.headers.msg_id", default: "Message ID") %></th>
14
42
  <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.locks.index.headers.age") %></th>
15
- <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.locks.index.headers.expires") %></th>
43
+ <% if @locks.any? %>
44
+ <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500 dark:text-gray-400"></th>
45
+ <% end %>
16
46
  </tr>
17
47
  </thead>
18
48
  <tbody class="divide-y divide-gray-100 dark:divide-gray-700">
19
49
  <% @locks.each do |lock| %>
20
50
  <tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
21
- <td data-label="Lock Key" class="px-4 py-3 text-sm font-mono text-gray-700 dark:text-gray-300 max-w-xs truncate"><%= lock[:lock_key] %></td>
22
- <td data-label="Job Class" class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300"><%= lock[:job_class] %></td>
23
- <td data-label="State" class="px-4 py-3 text-sm">
24
- <% if lock[:state] == "executing" %>
25
- <span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2.5 py-0.5 text-xs font-medium text-blue-800 dark:text-blue-300"><%= t("pgbus.locks.index.executing") %></span>
26
- <% else %>
27
- <span class="inline-flex items-center rounded-full bg-yellow-100 dark:bg-yellow-900/50 px-2.5 py-0.5 text-xs font-medium text-yellow-800 dark:text-yellow-300"><%= t("pgbus.locks.index.queued") %></span>
28
- <% end %>
29
- </td>
30
- <td data-label="Owner" class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
31
- <% if lock[:owner_pid] %>
32
- <span class="font-mono"><%= lock[:owner_pid] %></span>
33
- <% if lock[:owner_hostname] %>
34
- <span class="text-xs text-gray-400 dark:text-gray-500">@<%= lock[:owner_hostname] %></span>
35
- <% end %>
36
- <% else %>
37
- <span class="text-gray-400 dark:text-gray-500">—</span>
38
- <% end %>
51
+ <td class="w-10 px-4 py-3">
52
+ <input type="checkbox" name="lock_keys[]" value="<%= lock[:lock_key] %>"
53
+ aria-label="<%= t("pgbus.helpers.bulk_select_row", id: lock[:lock_key]) %>"
54
+ form="bulk-discard-locks-form" data-bulk-item
55
+ class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600">
39
56
  </td>
57
+ <td data-label="Lock Key" class="px-4 py-3 text-sm font-mono text-gray-700 dark:text-gray-300 max-w-xs truncate"><%= lock[:lock_key] %></td>
58
+ <td data-label="Queue" class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300"><%= lock[:queue_name] %></td>
59
+ <td data-label="Message ID" class="px-4 py-3 text-sm text-right font-mono text-gray-500 dark:text-gray-400"><%= lock[:msg_id] %></td>
40
60
  <td data-label="Age" class="px-4 py-3 text-sm text-right text-gray-500 dark:text-gray-400">
41
61
  <% if lock[:age_seconds] %>
42
62
  <%= pgbus_duration(lock[:age_seconds]) %>
43
63
  <% end %>
44
64
  </td>
45
- <td data-label="Expires" class="px-4 py-3 text-sm text-right text-gray-500 dark:text-gray-400"><%= pgbus_time_ago_future(lock[:expires_at]) %></td>
65
+ <td class="px-4 py-3 text-sm text-right">
66
+ <%= button_to t("pgbus.locks.index.discard"), pgbus.discard_lock_path(lock[:lock_key]),
67
+ method: :post,
68
+ class: "text-xs text-red-600 hover:text-red-800 font-medium",
69
+ data: { turbo_confirm: t("pgbus.locks.index.discard_confirm"), turbo_frame: "_top" } %>
70
+ </td>
46
71
  </tr>
47
72
  <% end %>
48
73
  <% if @locks.empty? %>
49
- <tr><td colspan="6" class="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500"><%= t("pgbus.locks.index.empty") %></td></tr>
74
+ <tr><td colspan="5" class="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500"><%= t("pgbus.locks.index.empty") %></td></tr>
50
75
  <% end %>
51
76
  </tbody>
52
77
  </table>
@@ -239,16 +239,12 @@ da:
239
239
  index:
240
240
  description: Aktive unikke låse forhindrer duplikeret jobudførelse
241
241
  empty: Ingen aktive låse
242
- executing: Udfører
243
242
  headers:
244
243
  age: Alder
245
- expires: Udløber
246
- job_class: Jobklasse
247
244
  lock_key: Låsenøgle
248
- owner: Ejer
249
- state: Status
250
- queued: I kø
251
- title: Joblåse
245
+ msg_id: Besked-ID
246
+ queue_name:
247
+ title: Unikhedsnøgler
252
248
  outbox:
253
249
  index:
254
250
  description: Transaktionelle udbakke-poster, der venter på publicering til PGMQ
@@ -239,16 +239,12 @@ de:
239
239
  index:
240
240
  description: Aktive Einzigartigkeitssperren verhindern doppelte Auftragserstellung
241
241
  empty: Keine aktiven Sperren
242
- executing: Ausführen
243
242
  headers:
244
243
  age: Alter
245
- expires: Läuft ab
246
- job_class: Auftragsklasse
247
244
  lock_key: Sperrschlüssel
248
- owner: Besitzer
249
- state: Status
250
- queued: In Warteschlange
251
- title: Auftragssperren
245
+ msg_id: Nachrichten-ID
246
+ queue_name: Warteschlange
247
+ title: Eindeutigkeitsschlüssel
252
248
  outbox:
253
249
  index:
254
250
  description: Transaktionale Postausgangseinträge ausstehend zur Veröffentlichung an PGMQ