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 +4 -4
- data/README.md +236 -0
- data/app/controllers/pgbus/events_controller.rb +98 -2
- data/app/views/pgbus/events/_pending_table.html.erb +148 -0
- data/app/views/pgbus/events/index.html.erb +21 -1
- data/config/locales/da.yml +109 -0
- data/config/locales/de.yml +109 -0
- data/config/locales/en.yml +47 -0
- data/config/locales/es.yml +109 -0
- data/config/locales/fi.yml +109 -0
- data/config/locales/fr.yml +109 -0
- data/config/locales/it.yml +109 -0
- data/config/locales/ja.yml +109 -0
- data/config/locales/nb.yml +109 -0
- data/config/locales/nl.yml +109 -0
- data/config/locales/pt.yml +109 -0
- data/config/locales/sv.yml +109 -0
- data/config/routes.rb +7 -0
- data/lib/pgbus/event_bus/publisher.rb +5 -3
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +147 -2
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3c1610b9a423eb2d8cc6797176ec23ce9ddadab1b313f8c1ee985327a6af0c42
|
|
4
|
+
data.tar.gz: b7369612f3ac54bc3bef2e9fbec4afcc8c31a632edd667ab91d03d750f1c3e4c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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: "
|
|
18
|
+
redirect_to events_path, notice: t("pgbus.events.flash.replayed")
|
|
18
19
|
else
|
|
19
|
-
redirect_to events_path, alert: "
|
|
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
|
-
<
|
|
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>
|