pgbus 0.7.3 → 0.7.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d07ecda9d90b73fa2ab8d9a34bf775b305bba9349dd5d49c99b587ea5b614bc
4
- data.tar.gz: af072bf9e30ead0928db95896380384c49d8a49a59994864693aea80dd249bda
3
+ metadata.gz: 3c1610b9a423eb2d8cc6797176ec23ce9ddadab1b313f8c1ee985327a6af0c42
4
+ data.tar.gz: b7369612f3ac54bc3bef2e9fbec4afcc8c31a632edd667ab91d03d750f1c3e4c
5
5
  SHA512:
6
- metadata.gz: 49eccf16ff1d0dcdec75f111bda474b343f3cf154b14569d19138f1b666457d71482037e390f4faa7136e21ead14b32a8a9408d18256843992e83476424a333c
7
- data.tar.gz: 0d78309574c68a90b59b276b7648ac17cd8c74f3dbedebbd3d6cabe70a062a4d879cac19da8b2ab6ac78584f671ec7eda1db37fa459ecd761b81f0cd5c81ded0
6
+ metadata.gz: 906567f5015468dad875c7d4948979fb78b095e1433838cbb3761eff02d54b3186a251a44e8d52508a3fd6b8cb540648d79eded23301561346f4d97878c63b11
7
+ data.tar.gz: 24cdb2845692e0172681fa899f5b8dac743ef00d214b0e09f4f9a0836f042026f0cceda1fab76851a52028b8b50101df9158907d487752dd301f4b28aed9466c
data/README.md CHANGED
@@ -37,6 +37,13 @@ PostgreSQL-native job processing and event bus for Rails, built on [PGMQ](https:
37
37
  - [Structured logging](#structured-logging)
38
38
  - [Queue health monitoring](#queue-health-monitoring)
39
39
  - [Real-time broadcasts](#real-time-broadcasts-turbo-streams-replacement)
40
+ - [Testing](#testing)
41
+ - [RSpec setup](#rspec-setup)
42
+ - [Minitest / TestUnit setup](#minitest--testunit-setup)
43
+ - [Event bus assertions](#event-bus-assertions)
44
+ - [RSpec matchers](#rspec-matchers)
45
+ - [Testing modes](#testing-modes)
46
+ - [SSE streams in tests](#sse-streams-in-tests)
40
47
  - [Operations](#operations)
41
48
  - [CLI](#cli)
42
49
  - [Dashboard](#dashboard)
@@ -1058,6 +1065,234 @@ The Insights tab gains a "Real-time Streams" section with counts of broadcasts /
1058
1065
 
1059
1066
  Overhead on a real Puma + PGMQ setup (`bundle exec rake bench:streams`): the most visible cost is an INSERT per connect/disconnect pair, which shows up under thundering-herd connect scenarios (K=50 concurrent connects: ~+20% per-connect latency). Steady-state broadcast and fanout numbers stay in the run-to-run noise band. Enable it if Insights is useful; leave it off if the write traffic worries you.
1060
1067
 
1068
+ ## Testing
1069
+
1070
+ Pgbus ships opt-in test helpers for both RSpec and Minitest. The testing module is **never autoloaded** by Zeitwerk -- you must `require` it explicitly, so it cannot leak into production.
1071
+
1072
+ ### RSpec setup
1073
+
1074
+ Add one line to your `spec/rails_helper.rb` (or `spec/spec_helper.rb`):
1075
+
1076
+ ```ruby
1077
+ # spec/rails_helper.rb
1078
+ require "pgbus/testing/rspec"
1079
+ ```
1080
+
1081
+ This does three things:
1082
+
1083
+ 1. Loads `Pgbus::Testing` with the in-memory `EventStore` and mode management
1084
+ 2. Registers the `have_published_event` matcher
1085
+ 3. Includes `Pgbus::Testing::Assertions` into all example groups
1086
+
1087
+ You still need to activate a testing mode and clear the store per test. Add a `before`/`after` block:
1088
+
1089
+ ```ruby
1090
+ # spec/rails_helper.rb
1091
+ require "pgbus/testing/rspec"
1092
+
1093
+ RSpec.configure do |config|
1094
+ config.before { Pgbus::Testing.fake! }
1095
+ config.after do
1096
+ Pgbus::Testing.disabled!
1097
+ Pgbus::Testing.store.clear!
1098
+ end
1099
+ end
1100
+ ```
1101
+
1102
+ Or scope it to specific groups:
1103
+
1104
+ ```ruby
1105
+ RSpec.configure do |config|
1106
+ config.before(:each, :pgbus) { Pgbus::Testing.fake! }
1107
+ config.after(:each, :pgbus) do
1108
+ Pgbus::Testing.disabled!
1109
+ Pgbus::Testing.store.clear!
1110
+ end
1111
+ end
1112
+
1113
+ # Usage:
1114
+ RSpec.describe OrderService, :pgbus do
1115
+ it "publishes an event" do
1116
+ expect { described_class.create!(attrs) }
1117
+ .to have_published_event("orders.created")
1118
+ end
1119
+ end
1120
+ ```
1121
+
1122
+ ### Minitest / TestUnit setup
1123
+
1124
+ Add the require and include to your `test/test_helper.rb`:
1125
+
1126
+ ```ruby
1127
+ # test/test_helper.rb
1128
+ require "pgbus/testing/minitest"
1129
+
1130
+ class ActiveSupport::TestCase
1131
+ include Pgbus::Testing::MinitestHelpers
1132
+ end
1133
+ ```
1134
+
1135
+ `MinitestHelpers` hooks into Minitest's lifecycle automatically:
1136
+
1137
+ - **`before_setup`** -- activates `:fake` mode and clears the event store before each test
1138
+ - Includes all assertion helpers (`assert_pgbus_published`, `assert_no_pgbus_published`, `pgbus_published_events`, `perform_published_events`)
1139
+
1140
+ No additional `setup`/`teardown` blocks are needed -- the module handles it.
1141
+
1142
+ ### Event bus assertions
1143
+
1144
+ Both RSpec and Minitest share the same assertion helpers via `Pgbus::Testing::Assertions`:
1145
+
1146
+ ```ruby
1147
+ # Assert that a block publishes exactly N events
1148
+ assert_pgbus_published(count: 1, routing_key: "orders.created") do
1149
+ OrderService.create!(attrs)
1150
+ end
1151
+
1152
+ # Assert that a block publishes zero events
1153
+ assert_no_pgbus_published(routing_key: "orders.created") do
1154
+ OrderService.preview(attrs)
1155
+ end
1156
+
1157
+ # Inspect captured events directly
1158
+ events = pgbus_published_events(routing_key: "orders.created")
1159
+ assert_equal 1, events.size
1160
+ assert_equal({ "id" => 42 }, events.first.payload)
1161
+
1162
+ # Capture events, then dispatch them to registered handlers
1163
+ perform_published_events do
1164
+ OrderService.create!(attrs)
1165
+ end
1166
+ # After the block, all captured events have been dispatched to their
1167
+ # matching handlers synchronously -- useful for testing side effects
1168
+ ```
1169
+
1170
+ ### RSpec matchers
1171
+
1172
+ The `have_published_event` matcher supports chainable constraints:
1173
+
1174
+ ```ruby
1175
+ # Basic: assert any event was published with the given routing key
1176
+ expect { publish_order(order) }
1177
+ .to have_published_event("orders.created")
1178
+
1179
+ # With payload matching (uses RSpec's values_match?, so hash_including works)
1180
+ expect { publish_order(order) }
1181
+ .to have_published_event("orders.created")
1182
+ .with_payload(hash_including("id" => order.id))
1183
+
1184
+ # With header matching
1185
+ expect { publish_order(order) }
1186
+ .to have_published_event("orders.created")
1187
+ .with_headers(hash_including("x-tenant" => "acme"))
1188
+
1189
+ # Exact count
1190
+ expect { publish_order(order) }
1191
+ .to have_published_event("orders.created")
1192
+ .exactly(1)
1193
+
1194
+ # Combine all constraints
1195
+ expect { publish_order(order) }
1196
+ .to have_published_event("orders.created")
1197
+ .with_payload(hash_including("id" => order.id))
1198
+ .with_headers(hash_including("x-tenant" => "acme"))
1199
+ .exactly(1)
1200
+
1201
+ # Negated
1202
+ expect { publish_order(order) }
1203
+ .not_to have_published_event("orders.cancelled")
1204
+ ```
1205
+
1206
+ ### Testing modes
1207
+
1208
+ Three modes control how `Pgbus::EventBus::Publisher.publish` behaves:
1209
+
1210
+ | Mode | Behavior | Use case |
1211
+ |------|----------|----------|
1212
+ | `:fake` | Captures events in-memory, no PGMQ calls, no handler dispatch | Most unit/integration tests |
1213
+ | `:inline` | Captures events AND immediately dispatches to matching handlers | Testing side effects (handler logic) |
1214
+ | `:disabled` | Pass-through to real publisher (production behavior) | Default; integration tests with real PGMQ |
1215
+
1216
+ Switch modes globally or scoped to a block:
1217
+
1218
+ ```ruby
1219
+ # Global (persists until changed)
1220
+ Pgbus::Testing.fake!
1221
+ Pgbus::Testing.inline!
1222
+ Pgbus::Testing.disabled!
1223
+
1224
+ # Scoped (restores previous mode after block)
1225
+ Pgbus::Testing.inline! do
1226
+ OrderService.create!(attrs) # handlers fire synchronously
1227
+ end
1228
+ # mode is restored to whatever it was before
1229
+
1230
+ # Query current mode
1231
+ Pgbus::Testing.fake? # => true/false
1232
+ Pgbus::Testing.inline? # => true/false
1233
+ Pgbus::Testing.disabled? # => true/false
1234
+ ```
1235
+
1236
+ The `:inline` mode skips delayed publishes (`delay: > 0`) -- those are captured in the store but not dispatched. Use `Pgbus::Testing.store.drain!` to manually dispatch all captured events including delayed ones.
1237
+
1238
+ ### SSE streams in tests
1239
+
1240
+ When using `use_transactional_fixtures = true` (the default in Rails), pgbus SSE streams are incompatible with transactional test isolation. The `rack.hijack` mechanism spawns background threads that acquire their own database connections outside the test transaction, which causes:
1241
+
1242
+ - Connection pool exhaustion after enough system tests
1243
+ - CI hangs (tests freeze waiting for a connection)
1244
+ - `Errno::EPIPE` errors when the browser navigates away
1245
+
1246
+ **Automatic fix with `Pgbus::Testing`:** When you activate `:fake` or `:inline` mode (as shown above), pgbus automatically enables `streams_test_mode`. The SSE endpoint returns a stub response (valid SSE headers + a comment + immediate close) without hijacking, without spawning background threads, and without acquiring any database connections. The `<pgbus-stream-source>` custom element still renders and connects, but no PGMQ polling occurs.
1247
+
1248
+ If you're using the RSpec or Minitest setup shown above, **you don't need to do anything extra** -- streams are safe automatically.
1249
+
1250
+ **Manual configuration** (if you don't use `Pgbus::Testing`):
1251
+
1252
+ ```ruby
1253
+ # config/initializers/pgbus.rb
1254
+ Pgbus.configure do |c|
1255
+ c.streams_test_mode = true if Rails.env.test?
1256
+ end
1257
+ ```
1258
+
1259
+ Or toggle it per test:
1260
+
1261
+ ```ruby
1262
+ # RSpec
1263
+ before { Pgbus.configuration.streams_test_mode = true }
1264
+ after { Pgbus.configuration.streams_test_mode = false }
1265
+
1266
+ # Minitest
1267
+ setup { Pgbus.configuration.streams_test_mode = true }
1268
+ teardown { Pgbus.configuration.streams_test_mode = false }
1269
+ ```
1270
+
1271
+ **What `streams_test_mode` does:** The `StreamApp` short-circuits after signature verification and authorization checks but before any hijack, streaming body, or capacity logic. It returns:
1272
+
1273
+ ```
1274
+ HTTP/1.1 200 OK
1275
+ Content-Type: text/event-stream
1276
+ Cache-Control: no-cache, no-transform
1277
+
1278
+ : pgbus test mode — connection accepted, no polling
1279
+ ```
1280
+
1281
+ This is a valid SSE response that the browser's EventSource will accept. No `Streamer` singleton is created, no PG LISTEN connection is opened, and no dispatcher/heartbeat/listener threads are spawned.
1282
+
1283
+ **Testing actual stream delivery:** If you need to verify end-to-end SSE message delivery in integration tests, disable `streams_test_mode` and use the `PumaTestHarness` from the pgbus test support:
1284
+
1285
+ ```ruby
1286
+ require "pgbus/testing"
1287
+
1288
+ Pgbus::Testing.disabled! do
1289
+ # streams_test_mode is automatically disabled
1290
+ # Use real Puma + real PGMQ for end-to-end stream tests
1291
+ end
1292
+ ```
1293
+
1294
+ See `spec/integration/streams/` in the pgbus source for examples of integration tests that exercise the full SSE pipeline with a real Puma server.
1295
+
1061
1296
  ## Operations
1062
1297
 
1063
1298
  Day-to-day running of Pgbus: starting and stopping processes, observing what is happening on the dashboard, the database tables Pgbus relies on, and how to migrate from an existing job backend.
@@ -1240,6 +1475,7 @@ PostgreSQL + PGMQ
1240
1475
  | `web_live_updates` | `true` | Enable Turbo Frames auto-refresh on dashboard |
1241
1476
  | `stats_enabled` | `true` | Record job execution stats for insights dashboard |
1242
1477
  | `stats_retention` | `30.days` | How long to keep job stats. Accepts seconds, Duration, or `nil` to disable cleanup |
1478
+ | `streams_test_mode` | `false` | Return a stub SSE response without hijack or background threads. Auto-enabled by `Pgbus::Testing.fake!`/`.inline!`. See [SSE streams in tests](#sse-streams-in-tests). |
1243
1479
  | `streams_stats_enabled` | `false` | Record stream broadcast/connect/disconnect stats (opt-in, can be high volume) |
1244
1480
  | `streams_path` | `nil` | Custom URL path for the SSE endpoint (nil = auto-detected from engine mount) |
1245
1481
  | `execution_mode` | `:threads` | Global execution mode (`:threads` or `:async`). Per-worker override via capsule config. |
@@ -5,6 +5,7 @@ module Pgbus
5
5
  def index
6
6
  @events = data_source.processed_events(page: page_param, per_page: per_page)
7
7
  @subscribers = data_source.registered_subscribers
8
+ @pending = data_source.pending_events(page: page_param, per_page: per_page)
8
9
  end
9
10
 
10
11
  def show
@@ -14,10 +15,105 @@ module Pgbus
14
15
  def replay
15
16
  event = data_source.processed_event(params[:id])
16
17
  if event && data_source.replay_event(event)
17
- redirect_to events_path, notice: "Event replayed."
18
+ redirect_to events_path, notice: t("pgbus.events.flash.replayed")
18
19
  else
19
- redirect_to events_path, alert: "Event not found or could not be replayed."
20
+ redirect_to events_path, alert: t("pgbus.events.flash.replay_failed")
20
21
  end
21
22
  end
23
+
24
+ def discard
25
+ queue_name = params[:queue_name].to_s
26
+ return reject_unknown_queue(:discard_failed) unless registered_queue?(queue_name)
27
+
28
+ if data_source.discard_event(queue_name, params[:id])
29
+ redirect_to events_path, notice: t("pgbus.events.flash.discarded")
30
+ else
31
+ redirect_to events_path, alert: t("pgbus.events.flash.discard_failed")
32
+ end
33
+ end
34
+
35
+ def mark_handled
36
+ queue_name = params[:queue_name].to_s
37
+ return reject_unknown_queue(:mark_handled_failed) unless registered_queue?(queue_name)
38
+
39
+ # Resolve the handler class server-side from the subscriber registry
40
+ # instead of trusting params[:handler_class] — otherwise a crafted POST
41
+ # could create ProcessedEvent markers for arbitrary class names and
42
+ # corrupt replay/idempotency state.
43
+ handler_class = data_source.handler_class_for_queue(queue_name)
44
+ if handler_class && data_source.mark_event_handled(queue_name, params[:id], handler_class)
45
+ redirect_to events_path, notice: t("pgbus.events.flash.marked_handled")
46
+ else
47
+ redirect_to events_path, alert: t("pgbus.events.flash.mark_handled_failed")
48
+ end
49
+ end
50
+
51
+ def edit_payload
52
+ queue_name = params[:queue_name].to_s
53
+ return reject_unknown_queue(:payload_update_failed) unless registered_queue?(queue_name)
54
+
55
+ new_payload = params[:payload].to_s
56
+ if data_source.edit_event_payload(queue_name, params[:id], new_payload)
57
+ redirect_to events_path, notice: t("pgbus.events.flash.payload_updated")
58
+ else
59
+ redirect_to events_path, alert: t("pgbus.events.flash.payload_update_failed")
60
+ end
61
+ end
62
+
63
+ def reroute
64
+ source_queue = params[:queue_name].to_s
65
+ target_queue = params[:target_queue].to_s
66
+ # Reject rerouting to/from any queue that isn't a registered handler —
67
+ # the UI dropdown is not a real server-side constraint, so a crafted
68
+ # POST could otherwise touch arbitrary queues.
69
+ allowed = registered_queue?(source_queue) && registered_queue?(target_queue)
70
+ return reject_unknown_queue(:reroute_failed) unless allowed
71
+
72
+ if data_source.reroute_event(source_queue, params[:id], target_queue)
73
+ redirect_to events_path, notice: t("pgbus.events.flash.rerouted")
74
+ else
75
+ redirect_to events_path, alert: t("pgbus.events.flash.reroute_failed")
76
+ end
77
+ end
78
+
79
+ def discard_selected
80
+ # Guard against non-hash entries in params[:messages] — a crafted POST
81
+ # can otherwise raise NoMethodError on [:queue_name]. Mirrors the
82
+ # hardening already used in jobs_controller#discard_selected_enqueued.
83
+ # Also whitelist queue_name per selection so operators can't archive
84
+ # arbitrary non-handler queues through this endpoint.
85
+ allowed = registered_queues
86
+ selections = Array(params[:messages]).filter_map do |s|
87
+ next unless s.respond_to?(:[])
88
+
89
+ queue_name = s[:queue_name]
90
+ msg_id = s[:msg_id]
91
+ next if queue_name.blank? || msg_id.blank?
92
+ next unless allowed.include?(queue_name.to_s)
93
+
94
+ { queue_name: queue_name.to_s, msg_id: msg_id }
95
+ end
96
+ if selections.empty?
97
+ redirect_to events_path, alert: t("pgbus.events.flash.none_selected")
98
+ return
99
+ end
100
+
101
+ count = data_source.discard_selected_events(selections)
102
+ redirect_to events_path, notice: t("pgbus.events.flash.discarded_selected", count: count)
103
+ end
104
+
105
+ private
106
+
107
+ def registered_queues
108
+ @registered_queues ||= data_source.handler_queue_physical_names
109
+ end
110
+
111
+ def registered_queue?(name)
112
+ registered_queues.include?(name)
113
+ end
114
+
115
+ def reject_unknown_queue(flash_key)
116
+ redirect_to events_path, alert: t("pgbus.events.flash.#{flash_key}")
117
+ end
22
118
  end
23
119
  end
@@ -0,0 +1,148 @@
1
+ <turbo-frame id="pending-events">
2
+ <%# Bulk form outside table to avoid nested form issues %>
3
+ <form id="bulk-discard-events-form" action="<%= pgbus.discard_selected_events_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="events">
7
+ <table class="pgbus-table min-w-full divide-y divide-gray-200 dark:divide-gray-700">
8
+ <thead class="bg-gray-50 dark:bg-gray-900">
9
+ <tr>
10
+ <% if @pending.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 %>
17
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"><%= t("pgbus.events.index.pending_headers.id") %></th>
18
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"><%= t("pgbus.events.index.pending_headers.routing_key") %></th>
19
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"><%= t("pgbus.events.index.pending_headers.handler_queue") %></th>
20
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"><%= t("pgbus.events.index.pending_headers.enqueued") %></th>
21
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"><%= t("pgbus.events.index.pending_headers.reads") %></th>
22
+ </tr>
23
+ </thead>
24
+ <tbody class="divide-y divide-gray-100 dark:divide-gray-700">
25
+ <% @pending.each do |m| %>
26
+ <%# Preserve the raw message so corrupt payloads stay visible in the
27
+ edit textarea and full-JSON panel. pgbus_parse_message swallows
28
+ JSON::ParserError and returns {}, which would otherwise hide
29
+ exactly the data the operator is trying to inspect/repair.
30
+ Also coerce scalar JSON values (numbers, booleans, nil, arrays)
31
+ to {} for hash-style access below — otherwise payload["foo"]
32
+ raises NoMethodError. %>
33
+ <% payload_raw = pgbus_parse_message(m[:message]) %>
34
+ <% payload = payload_raw.is_a?(Hash) ? payload_raw : {} %>
35
+ <% parse_ok = m[:message].blank? || payload_raw != {} || m[:message].to_s.strip == "{}" %>
36
+ <tr>
37
+ <td class="w-10 px-4 py-3 align-top">
38
+ <input type="checkbox" data-bulk-item data-pgbus-action="bulk-row-toggle"
39
+ aria-label="<%= t("pgbus.helpers.bulk_select_row", id: m[:msg_id]) %>"
40
+ data-queue-name="<%= m[:queue_name] %>" data-msg-id="<%= m[:msg_id] %>"
41
+ class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600 mt-1">
42
+ <input type="hidden" name="messages[][queue_name]" value="<%= m[:queue_name] %>" form="bulk-discard-events-form" disabled>
43
+ <input type="hidden" name="messages[][msg_id]" value="<%= m[:msg_id] %>" form="bulk-discard-events-form" disabled>
44
+ </td>
45
+ <td colspan="5" class="p-0">
46
+ <details class="group">
47
+ <summary class="flex cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 dark:bg-gray-900 list-none">
48
+ <span class="w-16 shrink-0 px-4 py-3 text-sm font-mono text-gray-900 dark:text-white"><%= m[:msg_id] %></span>
49
+ <span class="flex-1 px-4 py-3 text-sm font-mono text-indigo-600 truncate"><%= payload["routing_key"] || payload["event_id"] || "—" %></span>
50
+ <span class="w-40 shrink-0 px-4 py-3 text-sm text-gray-700"><%= m[:queue_name] %></span>
51
+ <span class="w-28 shrink-0 px-4 py-3 text-sm text-gray-500"><%= pgbus_time_ago(m[:enqueued_at]) %></span>
52
+ <span class="w-16 shrink-0 px-4 py-3 text-sm text-gray-500"><%= m[:read_ct] %></span>
53
+ </summary>
54
+ <div class="px-4 pb-4 bg-gray-50 dark:bg-gray-900 border-t border-gray-100">
55
+ <div class="flex items-center justify-between mt-3 mb-3">
56
+ <span class="text-xs font-mono text-gray-400"><%= t("pgbus.events.pending_table.event_id") %> <%= payload["event_id"] %></span>
57
+ <div class="flex space-x-2">
58
+ <%# Mark Handled — handler_class is resolved server-side
59
+ from the subscriber registry, so we only send queue_name. %>
60
+ <%= button_to t("pgbus.events.pending_table.mark_handled"),
61
+ pgbus.mark_handled_event_path(m[:msg_id], queue_name: m[:queue_name]),
62
+ method: :post,
63
+ class: "rounded-md bg-green-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-green-500",
64
+ data: { turbo_confirm: t("pgbus.events.pending_table.mark_handled_confirm"), turbo_frame: "_top" } %>
65
+ <%# Reroute — dropdown with available handlers %>
66
+ <details class="relative inline-block">
67
+ <summary class="rounded-md bg-yellow-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-yellow-500 cursor-pointer list-none">
68
+ <%= t("pgbus.events.pending_table.reroute") %>
69
+ </summary>
70
+ <div class="absolute right-0 z-10 mt-1 w-64 rounded-md bg-white dark:bg-gray-800 shadow-lg ring-1 ring-gray-200 dark:ring-gray-700 p-2">
71
+ <p class="text-xs text-gray-500 mb-2"><%= t("pgbus.events.pending_table.reroute_label") %></p>
72
+ <% @subscribers.each do |s| %>
73
+ <% next if s[:physical_queue_name] == m[:queue_name] %>
74
+ <%= button_to s[:handler_class],
75
+ pgbus.reroute_event_path(m[:msg_id], queue_name: m[:queue_name], target_queue: s[:physical_queue_name]),
76
+ method: :post,
77
+ class: "block w-full text-left rounded px-2 py-1 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700",
78
+ data: { turbo_confirm: t("pgbus.events.pending_table.reroute_confirm"), turbo_frame: "_top" } %>
79
+ <% end %>
80
+ </div>
81
+ </details>
82
+ <%# Discard %>
83
+ <%= button_to t("pgbus.events.pending_table.discard"),
84
+ pgbus.discard_event_path(m[:msg_id], queue_name: m[:queue_name]),
85
+ method: :post,
86
+ class: "rounded-md bg-red-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-red-500",
87
+ data: { turbo_confirm: t("pgbus.events.pending_table.discard_confirm"), turbo_frame: "_top" } %>
88
+ </div>
89
+ </div>
90
+ <%# Payload display %>
91
+ <div class="grid grid-cols-2 gap-4 mb-3">
92
+ <div>
93
+ <span class="text-xs font-medium text-gray-500"><%= t("pgbus.events.pending_table.arguments") %></span>
94
+ <pre class="text-xs text-gray-700 bg-white dark:bg-gray-800 rounded p-2 mt-1 overflow-x-auto max-h-40"><%= parse_ok ? (JSON.pretty_generate(payload["payload"] || payload_raw) rescue "—") : m[:message] %></pre>
95
+ </div>
96
+ <div>
97
+ <span class="text-xs font-medium text-gray-500"><%= t("pgbus.events.pending_table.metadata") %></span>
98
+ <div class="text-xs text-gray-600 bg-white dark:bg-gray-800 rounded p-2 mt-1 space-y-1">
99
+ <% if m[:read_ct] %><p><strong><%= t("pgbus.events.pending_table.metadata_labels.read_count") %></strong> <%= m[:read_ct] %></p><% end %>
100
+ <% if m[:vt] %><p><strong><%= t("pgbus.events.pending_table.metadata_labels.visible_at") %></strong> <%= m[:vt] %></p><% end %>
101
+ <% if m[:last_read_at] %><p><strong><%= t("pgbus.events.pending_table.metadata_labels.last_read") %></strong> <%= m[:last_read_at] %></p><% end %>
102
+ </div>
103
+ </div>
104
+ </div>
105
+ <%# Edit Payload form %>
106
+ <details class="mt-2">
107
+ <summary class="text-xs font-medium text-indigo-600 cursor-pointer hover:text-indigo-500"><%= t("pgbus.events.pending_table.edit_payload") %></summary>
108
+ <div class="mt-2 p-3 bg-white dark:bg-gray-800 rounded ring-1 ring-gray-200 dark:ring-gray-700">
109
+ <%= form_with url: pgbus.edit_payload_event_path(m[:msg_id]), method: :post, local: true, data: { turbo_frame: "_top" } do |f| %>
110
+ <%= hidden_field_tag :queue_name, m[:queue_name] %>
111
+ <label class="text-xs font-medium text-gray-500"><%= t("pgbus.events.pending_table.edit_payload_label") %></label>
112
+ <textarea name="payload" rows="6"
113
+ class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-900 dark:text-white text-xs font-mono shadow-sm focus:border-indigo-500 focus:ring-indigo-500"><%= parse_ok ? (JSON.pretty_generate(payload_raw) rescue m[:message]) : m[:message] %></textarea>
114
+ <div class="mt-2 flex justify-end">
115
+ <button type="submit"
116
+ class="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500"
117
+ data-turbo-confirm="<%= t("pgbus.events.pending_table.edit_payload_confirm") %>">
118
+ <%= t("pgbus.events.pending_table.edit_payload") %>
119
+ </button>
120
+ </div>
121
+ <% end %>
122
+ </div>
123
+ </details>
124
+ <%# Full JSON %>
125
+ <details class="mt-2">
126
+ <summary class="text-xs font-medium text-gray-500 cursor-pointer hover:text-gray-700"><%= t("pgbus.events.pending_table.full_json_payload") %></summary>
127
+ <pre class="text-xs text-gray-600 bg-white dark:bg-gray-800 rounded p-2 mt-1 overflow-x-auto max-h-96"><%= parse_ok ? (JSON.pretty_generate(payload_raw) rescue m[:message]) : m[:message] %></pre>
128
+ </details>
129
+ <% if m[:headers] %>
130
+ <details class="mt-2">
131
+ <summary class="text-xs font-medium text-gray-500 cursor-pointer hover:text-gray-700"><%= t("pgbus.events.pending_table.headers_section") %></summary>
132
+ <pre class="text-xs text-gray-600 bg-white dark:bg-gray-800 rounded p-2 mt-1 overflow-x-auto"><%= JSON.pretty_generate(JSON.parse(m[:headers])) rescue m[:headers] %></pre>
133
+ </details>
134
+ <% end %>
135
+ </div>
136
+ </details>
137
+ </td>
138
+ </tr>
139
+ <% end %>
140
+ <% if @pending.empty? %>
141
+ <%# Header only renders the bulk-select column when rows are present,
142
+ so the empty-state colspan must match (5 without, 6 with). %>
143
+ <tr><td colspan="5" class="px-4 py-8 text-center text-sm text-gray-400"><%= t("pgbus.events.index.pending_empty") %></td></tr>
144
+ <% end %>
145
+ </tbody>
146
+ </table>
147
+ </div>
148
+ </turbo-frame>
@@ -1,4 +1,18 @@
1
- <h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-6"><%= t("pgbus.events.index.title") %></h1>
1
+ <div class="flex items-center justify-between mb-6">
2
+ <h1 class="text-2xl font-bold text-gray-900 dark:text-white"><%= t("pgbus.events.index.title") %></h1>
3
+ <% if @pending.any? %>
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-events-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.events.index.discard_selected_confirm") %>">
10
+ <%= t("pgbus.events.index.discard_selected") %>
11
+ </button>
12
+ </div>
13
+ </div>
14
+ <% end %>
15
+ </div>
2
16
 
3
17
  <!-- Registered Subscribers -->
4
18
  <div class="mb-8">
@@ -28,6 +42,12 @@
28
42
  </div>
29
43
  </div>
30
44
 
45
+ <!-- Pending Events (Unprocessed messages in handler queues) -->
46
+ <div class="mb-8">
47
+ <h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-3"><%= t("pgbus.events.index.pending_title") %></h2>
48
+ <%= render "pgbus/events/pending_table" %>
49
+ </div>
50
+
31
51
  <!-- Processed Events (Audit Trail) -->
32
52
  <div>
33
53
  <h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-3"><%= t("pgbus.events.index.processed_title") %></h2>