pgbus 0.8.4 → 0.9.2

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/app/assets/javascripts/pgbus/stream_source_element.js +150 -5
  4. data/app/controllers/pgbus/batches_controller.rb +15 -0
  5. data/app/helpers/pgbus/application_helper.rb +12 -0
  6. data/app/views/layouts/pgbus/application.html.erb +2 -0
  7. data/app/views/pgbus/batches/_batches_table.html.erb +54 -0
  8. data/app/views/pgbus/batches/index.html.erb +8 -0
  9. data/app/views/pgbus/batches/show.html.erb +90 -0
  10. data/config/locales/da.yml +34 -0
  11. data/config/locales/de.yml +34 -0
  12. data/config/locales/en.yml +34 -0
  13. data/config/locales/es.yml +34 -0
  14. data/config/locales/fi.yml +34 -0
  15. data/config/locales/fr.yml +34 -0
  16. data/config/locales/it.yml +34 -0
  17. data/config/locales/ja.yml +34 -0
  18. data/config/locales/nb.yml +34 -0
  19. data/config/locales/nl.yml +34 -0
  20. data/config/locales/pt.yml +34 -0
  21. data/config/locales/sv.yml +34 -0
  22. data/config/routes.rb +1 -0
  23. data/lib/pgbus/client.rb +102 -4
  24. data/lib/pgbus/configuration.rb +84 -0
  25. data/lib/pgbus/execution_pools/async_pool.rb +44 -6
  26. data/lib/pgbus/process/supervisor.rb +2 -1
  27. data/lib/pgbus/process/worker.rb +38 -1
  28. data/lib/pgbus/serializer.rb +1 -1
  29. data/lib/pgbus/streams/coalescer.rb +88 -0
  30. data/lib/pgbus/streams/envelope.rb +20 -1
  31. data/lib/pgbus/streams/key.rb +35 -2
  32. data/lib/pgbus/streams/renderer.rb +67 -0
  33. data/lib/pgbus/streams.rb +150 -1
  34. data/lib/pgbus/version.rb +1 -1
  35. data/lib/pgbus/web/data_source.rb +96 -0
  36. data/lib/pgbus/web/stream_app.rb +8 -4
  37. data/lib/pgbus/web/streamer/connection.rb +15 -1
  38. data/lib/pgbus/web/streamer/falcon_connection.rb +9 -1
  39. data/lib/pgbus/web/streamer/heartbeat.rb +23 -1
  40. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +129 -14
  41. data/lib/pgbus.rb +11 -0
  42. data/lib/tasks/pgbus_queues.rake +54 -0
  43. metadata +10 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2fb58fdb0a36d1e0b4f4834cca266aa8771a2fa8c9419bb5aaa7e2a2e83c4725
4
- data.tar.gz: 4b9fe002b753689046de0833c8d1112da0cf03c43a6bf2a050df989a76653bb3
3
+ metadata.gz: 5027c420c26dac65291e11e07ca0291da540b7d2aef9d963e9fdebc2217e7943
4
+ data.tar.gz: 9e66b85229c55528b3b5b2093883935c1333733018508bb6d9f7f7836666363d
5
5
  SHA512:
6
- metadata.gz: 397c99fe5da35f6e75720c27c9b1e002ce744720cadba773166674a8bf86eb88ca2151c63bfa0c042eff7ff9f2f5d7026c9d344e655f63f9a93f268f47f1225a
7
- data.tar.gz: 33f98b8663e708d44ae5936a37fbeb3ca7966b80f358f9713d86b71ac95390a4eca0ab88c5faf2c008a203d8a88a43cdc0f73809bf2b95baadacfdc36e0a11a3
6
+ metadata.gz: 9c476c7de9c0db40e0d2183a11f7d502704a468e68f49b28934619cfab07f1130f3988d6f0fbfa6e77a57d6450aa183746b301367e0d4af7dcd2779319f96ff7
7
+ data.tar.gz: ed62152e6c2c361c5b8f38c61e31ea4e3af5d031cf68f71d0ea95177ab04f538bfbb4fefc84f5d08a2acd8efb23b0024d6433d4a6db2272e7c05521a59e83a3b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ### Added
4
+
5
+ - **Streams: optional publish-side coalescing for high-frequency broadcasts.** A chatty reactive component (live cursor, typing indicator, progress bar) can fan out many small broadcasts per second. Pass `coalesce:` (a window in milliseconds, or `true` for the 50ms default) with `target:` to `broadcast`/`broadcast_render` to batch per `(stream, target)` and publish only the *latest* frame within the window — superseded frames never hit the bus (no PGMQ insert, no NOTIFY, no fan-out). Last-write-wins, so it is opt-in and only safe for idempotent `replace`/`update` of a stable target (exactly the high-frequency case). The new `Pgbus::Streams::Coalescer` is a process-wide, thread-safe, trailing-edge-with-max-wait debounce: the first submit per window schedules the flush (bounding latency to one window) and later submits only overwrite the buffered payload; the flush re-enters the normal broadcast path, so a coalesced frame is just a deferred ordinary broadcast that still composes with `visible_to`/`exclude`/`event`/`durable`. Refs #171.
6
+
7
+ - **Streams: typed SSE event names on broadcasts.** A broadcast can set the SSE `event:` field — `Pgbus.stream(name).broadcast(html, event: "presence")` (and `broadcast_render(..., event:)`) — while keeping the payload a Turbo Stream, so clients route on the typed name instead of sniffing the HTML. The default (`nil` or `"turbo-stream"`) is omitted from the JSONB payload to avoid redundancy, but is still set on the SSE frame's `event:` line (the connection adapter falls back to `turbo-stream`), so default consumers still get the standard `message`/turbo-stream path. The event flows through the JSONB payload → `StreamEnvelope.event` → the SSE frame's `event:` line (both the Puma and Falcon connection adapters). On the client, `<pgbus-stream-source>` dispatches a typed broadcast as a generic `pgbus:event` (`{ event, data, msgId }`) and a named `pgbus:<event>` (`{ data, msgId }`) for `addEventListener` ergonomics; declare typed event names via the element's `listen-events` attribute so they survive the EventSource reconnect path. Refs #170.
8
+
9
+ - **Streams: optional connection-driven presence.** Streams matching `config.streams_presence_patterns` (exact string or Regexp, mirroring `streams_durable_patterns`) now auto-join a member on SSE connect, auto-leave on disconnect, and refresh `last_seen_at` on the keepalive heartbeat — no explicit `join`/`leave`/sweeper wiring. Identity comes from the connection's authorize-hook context: the built-in extractor handles a Hash with `:member_id`/`:id` (plus optional `:metadata`) or any object responding to `#id`, and `config.streams_presence_member` accepts a custom `->(context) { { id:, metadata: } }`. Membership work runs on the dispatcher thread (which already releases AR connections each pass); the heartbeat posts a batched `PresenceTouchMessage` per tick. Presence failures are logged and swallowed so a presence-DB hiccup can't knock a live SSE connection out of the registry. Anonymous connections (no derivable member) are simply skipped. Refs #169.
10
+
11
+ - **Streams: msg_id (revision) exposed to the client for optimistic-UI reconciliation.** Each delivered turbo-stream frame already carries its monotonic `msg_id` as the SSE `id:`; `<pgbus-stream-source>` now surfaces it two ways: the standard `message` MessageEvent sets `lastEventId` to the msg_id (Turbo ignores it; a reactive runtime listening for `message` reads the revision with no pgbus-specific API), and a new `pgbus:message` CustomEvent carries `{ msgId, data }` (msgId is a Number when numeric; a negative value marks an ephemeral frame). Documents the reconciliation pattern: track the highest applied msgId per target and skip the morph if a newer revision was already applied, so a late echo can't clobber a newer optimistic edit. Complements #165 (exclude handles the actor; the revision handles out-of-order delivery for everyone else). Refs #168.
12
+
13
+ - **Streams: `Stream#broadcast_render` — render a renderable and broadcast it in one call.** `Pgbus.stream(name).broadcast_render(renderable:, target:, action: :replace, exclude:, visible_to:, durable:)` renders a Phlex component, a ViewComponent, or a pre-rendered HTML string into a complete `<turbo-stream action target><template>…</template></turbo-stream>` tag and broadcasts it atomically — removing the off-request render + tag-building boilerplate (and the easy-to-get-wrong view context) from every call site. `action` defaults to `:replace`; content-less actions (`remove`) emit no `<template>`. `exclude:`/`visible_to:`/`durable:` forward to `#broadcast`, so actor-echo suppression and audience filtering compose. Self-contained (no turbo-rails/ActionView/Phlex dependency); the renderable is resolved via `String` → `#call` (Phlex) → `#render_in` (ViewComponent) → `#to_s`. Refs #166.
14
+
15
+ - **Streams: actor-echo suppression via `exclude:` on broadcasts.** A broadcast can now name a connection id to skip: `Pgbus.stream(name).broadcast(html, exclude: connection_id)` (and `visible_to:` composes with it). The dispatcher skips delivery to that one SSE connection, so an actor who triggered a change does not receive the echo of its own broadcast — it already applied the change via its action's HTTP response, and re-applying the SSE echo would double-apply (re-run animations, clobber optimistic edits). Every server-minted SSE connection now exposes its id to the page: the server sends a `pgbus:connected` frame right after the open handshake, and `<pgbus-stream-source>` captures it onto the element's `connection-id` attribute and re-dispatches it as a `pgbus:connected` event (also surfaced in `pgbus:open`'s detail). A page reads its connection id and sends it back as the `X-Pgbus-Connection` header on action requests; the broadcaster passes `exclude: request.headers["X-Pgbus-Connection"]`. Reuses the existing per-connection delivery (Filters) path. Refs #165.
16
+
17
+ - **Streams: `Pgbus.stream_key` is idempotent for an already-built key, plus `Pgbus.stream_key!`.** A single `String` argument is now treated as a pre-built pgbus stream key and returned unchanged (after the queue-name budget check) instead of tripping the colon-separator guard. This lets a consumer hold one `stream_key` value and pass it to both `turbo_stream_from` and the broadcaster without `stream_key("chat:lobby")` raising `ArgumentError`. The guard still fires for the genuinely ambiguous multi-fragment join (`stream_key("a:b", :c)`), and `Symbol`/record fragments with colons are still rejected (a colon there never came from `stream_key`). `Pgbus.stream_key!(key)` accepts a pre-built key explicitly (String required, budget enforced). Refs #167.
18
+
3
19
  ### Breaking Changes
4
20
 
5
21
  - **Queue names must be alphanumeric and underscores only.** Queue names containing dashes (e.g., `my-app-queue`) will now raise `ArgumentError`. Rename to underscored form (e.g., `my_app_queue`) before upgrading. This restriction prevents SQL injection via PGMQ queue identifiers, which are interpolated into table names and cannot be parameterized.
@@ -17,12 +17,32 @@
17
17
  // channel — compatibility shim; ignored
18
18
  //
19
19
  // Events (dispatched on the element):
20
- // pgbus:open { lastEventId }
20
+ // message (MessageEvent) data = turbo-stream HTML;
21
+ // lastEventId = the frame's msg_id (revision)
22
+ // pgbus:message { msgId, data } — same frame, for optimistic-UI
23
+ // reconciliation (skip morph if a newer rev for the
24
+ // target was already applied). See #168.
25
+ // pgbus:event { event, data, msgId } — a typed broadcast (any SSE
26
+ // event name other than turbo-stream). See #170.
27
+ // pgbus:<event> { data, msgId } — the same typed broadcast, named
28
+ // for ergonomic addEventListener("pgbus:presence").
29
+ // pgbus:open { lastEventId, connectionId }
30
+ // pgbus:connected { connectionId }
21
31
  // pgbus:replay-start { fromId, toId }
22
32
  // pgbus:replay-end {}
23
33
  // pgbus:gap-detected { lastSeenId, archiveOldestId }
24
34
  // pgbus:close { code, reason }
25
35
  //
36
+ // Connection id (issue #165 — actor-echo suppression): the server sends a
37
+ // `pgbus:connected` frame right after the open handshake carrying the
38
+ // server-minted connection id. This element captures it, reflects it onto
39
+ // the `connection-id` attribute, and re-dispatches it as `pgbus:connected`.
40
+ // A page reads it (from the element or a `<meta name="pgbus-connection-id">`
41
+ // the app mirrors it to) and sends it back as the `X-Pgbus-Connection`
42
+ // header on action requests. The broadcaster then passes
43
+ // `exclude: request.headers["X-Pgbus-Connection"]` so the actor does not
44
+ // receive the echo of its own broadcast.
45
+ //
26
46
  // The element integrates with Turbo via connectStreamSource /
27
47
  // disconnectStreamSource + dispatching MessageEvent("message") so Turbo
28
48
  // Stream HTML is automatically consumed by the existing StreamObserver.
@@ -48,9 +68,17 @@ class PgbusStreamSourceElement extends HTMLElement {
48
68
  this.abortController = null
49
69
  this.eventSource = null
50
70
  this.lastEventId = null
71
+ this.connectionId = null
51
72
  this.closed = false
52
73
  }
53
74
 
75
+ // The server-minted connection id for this SSE connection, or null
76
+ // until the `pgbus:connected` frame arrives. Public read accessor so
77
+ // a reactive runtime can grab it without poking at attributes.
78
+ get pgbusConnectionId() {
79
+ return this.connectionId
80
+ }
81
+
54
82
  connectedCallback() {
55
83
  this.closed = false
56
84
  connectStreamSource(this)
@@ -80,6 +108,12 @@ class PgbusStreamSourceElement extends HTMLElement {
80
108
  // the URL. Parses the SSE event stream by hand because EventSource
81
109
  // doesn't expose custom query strings uniformly across browsers.
82
110
  async openFetchStream() {
111
+ // Each transport open is a fresh connection: the server mints a new id
112
+ // and sends a new pgbus:connected frame. Clear the previous id so
113
+ // pgbus:open (which fires before that frame arrives) can't surface a
114
+ // stale connection id — a stale id would produce a wrong X-Pgbus-Connection
115
+ // exclude header during reconnect windows.
116
+ this.resetConnectionId()
83
117
  const url = this.buildUrl({ includeSince: true })
84
118
  this.abortController = new AbortController()
85
119
 
@@ -99,7 +133,7 @@ class PgbusStreamSourceElement extends HTMLElement {
99
133
 
100
134
  this.setAttribute("connected", "")
101
135
  this.dispatchEvent(new CustomEvent("pgbus:open", {
102
- detail: { lastEventId: this.lastEventId }
136
+ detail: { lastEventId: this.lastEventId, connectionId: this.connectionId }
103
137
  }))
104
138
 
105
139
  const reader = response.body.getReader()
@@ -137,13 +171,17 @@ class PgbusStreamSourceElement extends HTMLElement {
137
171
  switchToEventSource() {
138
172
  if (this.closed) return
139
173
 
174
+ // Fresh connection on reconnect — drop the previous connection id so
175
+ // pgbus:open can't emit a stale one before the new pgbus:connected
176
+ // frame lands. See openFetchStream.
177
+ this.resetConnectionId()
140
178
  const url = this.buildUrl({ includeSince: true })
141
179
  this.eventSource = new EventSource(url, { withCredentials: true })
142
180
 
143
181
  this.eventSource.addEventListener("open", () => {
144
182
  this.setAttribute("connected", "")
145
183
  this.dispatchEvent(new CustomEvent("pgbus:open", {
146
- detail: { lastEventId: this.lastEventId }
184
+ detail: { lastEventId: this.lastEventId, connectionId: this.connectionId }
147
185
  }))
148
186
  })
149
187
 
@@ -153,7 +191,11 @@ class PgbusStreamSourceElement extends HTMLElement {
153
191
 
154
192
  this.eventSource.addEventListener("turbo-stream", (event) => {
155
193
  this.lastEventId = event.lastEventId
156
- this.dispatchEvent(new MessageEvent("message", { data: event.data }))
194
+ this.emitTurboStream(event.data, event.lastEventId)
195
+ })
196
+
197
+ this.eventSource.addEventListener("pgbus:connected", (event) => {
198
+ this.handleConnected(event.data)
157
199
  })
158
200
 
159
201
  this.eventSource.addEventListener("pgbus:gap-detected", (event) => {
@@ -166,6 +208,15 @@ class PgbusStreamSourceElement extends HTMLElement {
166
208
  detail: { code: "shutdown", reason: "worker restart" }
167
209
  }))
168
210
  })
211
+
212
+ // Typed broadcasts (issue #170): EventSource only fires listeners
213
+ // registered by name, so we register one per declared typed event.
214
+ for (const name of this.declaredTypedEvents()) {
215
+ this.eventSource.addEventListener(name, (event) => {
216
+ if (event.lastEventId) this.lastEventId = event.lastEventId
217
+ this.emitTypedEvent(name, event.data, event.lastEventId)
218
+ })
219
+ }
169
220
  }
170
221
 
171
222
  // Parses a single SSE event block (: comment | id: ... | event: ... | data: ...)
@@ -185,7 +236,9 @@ class PgbusStreamSourceElement extends HTMLElement {
185
236
  if (id !== null) this.lastEventId = id
186
237
 
187
238
  if (event === "turbo-stream") {
188
- this.dispatchEvent(new MessageEvent("message", { data }))
239
+ this.emitTurboStream(data, id)
240
+ } else if (event === "pgbus:connected") {
241
+ this.handleConnected(data)
189
242
  } else if (event === "pgbus:gap-detected") {
190
243
  this.dispatchEvent(new CustomEvent("pgbus:gap-detected", {
191
244
  detail: this.safeJsonParse(data)
@@ -194,9 +247,101 @@ class PgbusStreamSourceElement extends HTMLElement {
194
247
  this.dispatchEvent(new CustomEvent("pgbus:close", {
195
248
  detail: { code: "shutdown", reason: "worker restart" }
196
249
  }))
250
+ } else {
251
+ // A typed broadcast (issue #170): event: presence | reactive | ...
252
+ this.emitTypedEvent(event, data, id)
197
253
  }
198
254
  }
199
255
 
256
+ // Captures the server-minted connection id from a `pgbus:connected`
257
+ // frame: stores it, reflects it onto the `connection-id` attribute (so
258
+ // it's visible in the DOM / to MutationObservers), and re-dispatches it
259
+ // as a `pgbus:connected` CustomEvent. Idempotent across reconnects — the
260
+ // server mints a fresh id per connection, so a reconnect updates it.
261
+ handleConnected(data) {
262
+ const detail = this.safeJsonParse(data)
263
+ const connectionId = detail && detail.connectionId
264
+ if (!connectionId) return
265
+
266
+ this.connectionId = connectionId
267
+ this.setAttribute("connection-id", connectionId)
268
+ this.dispatchEvent(new CustomEvent("pgbus:connected", {
269
+ detail: { connectionId }
270
+ }))
271
+ }
272
+
273
+ // Clears the cached connection id and its reflected attribute. Called at
274
+ // the start of every transport open so a reconnect doesn't carry the
275
+ // previous connection's id into pgbus:open before the new pgbus:connected
276
+ // frame arrives.
277
+ resetConnectionId() {
278
+ this.connectionId = null
279
+ this.removeAttribute("connection-id")
280
+ }
281
+
282
+ // Delivers a turbo-stream frame to two audiences (issue #168):
283
+ //
284
+ // 1. Turbo's StreamObserver, via a `message` MessageEvent whose `data`
285
+ // is the turbo-stream HTML. The MessageEvent's standard
286
+ // `lastEventId` field carries the frame's msg_id, so a reactive
287
+ // runtime that listens for `message` can read the revision without
288
+ // any pgbus-specific API. (Turbo ignores lastEventId.)
289
+ //
290
+ // 2. A reactive runtime doing optimistic-UI reconciliation, via a
291
+ // `pgbus:message` CustomEvent carrying { msgId, data }. msgId is the
292
+ // monotonic per-stream revision (a negative value marks an ephemeral
293
+ // frame that was never persisted). Pattern: track the highest msgId
294
+ // you have applied per target; when a frame arrives, skip the morph
295
+ // if you have already applied a newer revision for that target —
296
+ // this stops a late echo from clobbering a newer optimistic edit.
297
+ // Complements #165: exclude handles the actor; this handles
298
+ // out-of-order delivery for everyone else.
299
+ //
300
+ // msgId is parsed to a Number when numeric so consumers can compare
301
+ // revisions with `>` directly; left as-is otherwise.
302
+ emitTurboStream(data, id) {
303
+ const msgId = id === null || id === undefined || id === "" ? null
304
+ : (Number.isNaN(Number(id)) ? id : Number(id))
305
+
306
+ const message = new MessageEvent("message", { data, lastEventId: id == null ? "" : String(id) })
307
+ this.dispatchEvent(message)
308
+
309
+ this.dispatchEvent(new CustomEvent("pgbus:message", {
310
+ detail: { msgId, data }
311
+ }))
312
+ }
313
+
314
+ // Delivers a typed broadcast (issue #170) — a frame whose SSE event name
315
+ // is something other than turbo-stream (e.g. "presence", "reactive").
316
+ // The payload is still whatever the broadcaster sent (usually a Turbo
317
+ // Stream); the typed name lets a client route without sniffing the HTML.
318
+ // Dispatched two ways for ergonomics:
319
+ // - a generic `pgbus:event` { event, data, msgId } (one listener for all)
320
+ // - a named `pgbus:<event>` { data, msgId } (addEventListener by name)
321
+ emitTypedEvent(event, data, id) {
322
+ const msgId = id === null || id === undefined || id === "" ? null
323
+ : (Number.isNaN(Number(id)) ? id : Number(id))
324
+
325
+ this.dispatchEvent(new CustomEvent("pgbus:event", {
326
+ detail: { event, data, msgId }
327
+ }))
328
+ this.dispatchEvent(new CustomEvent(`pgbus:${event}`, {
329
+ detail: { data, msgId }
330
+ }))
331
+ }
332
+
333
+ // Typed event names the EventSource (reconnect) path should listen for,
334
+ // declared by the app via the `listen-events` attribute (comma- or
335
+ // space-separated). EventSource only invokes listeners registered by
336
+ // name, so unlike the fetch path it cannot route unknown typed events
337
+ // generically; declaring them here keeps typed delivery working across
338
+ // reconnects. The fetch (initial) path always routes typed events.
339
+ declaredTypedEvents() {
340
+ const raw = this.getAttribute("listen-events")
341
+ if (!raw) return []
342
+ return raw.split(/[\s,]+/).filter((name) => name && name !== "turbo-stream")
343
+ }
344
+
200
345
  buildUrl({ includeSince }) {
201
346
  const src = this.getAttribute("src")
202
347
  if (!includeSince || !this.lastEventId) return src
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class BatchesController < ApplicationController
5
+ def index
6
+ @batches = data_source.batches
7
+ render_frame("pgbus/batches/batches_table") if params[:frame] == "list"
8
+ end
9
+
10
+ def show
11
+ @batch = data_source.batch_detail(params[:id])
12
+ redirect_to batches_path, alert: t("pgbus.batches.show.not_found") unless @batch
13
+ end
14
+ end
15
+ end
@@ -84,6 +84,18 @@ module Pgbus
84
84
  class: "inline-flex items-center rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800")
85
85
  end
86
86
 
87
+ BATCH_BADGE_BASE = "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
88
+ BATCH_BADGE_CSS = {
89
+ "finished" => "#{BATCH_BADGE_BASE} bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
90
+ "processing" => "#{BATCH_BADGE_BASE} bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
91
+ "pending" => "#{BATCH_BADGE_BASE} bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
92
+ }.freeze
93
+
94
+ def pgbus_batch_status_badge(status)
95
+ css = BATCH_BADGE_CSS[status] || BATCH_BADGE_CSS["pending"]
96
+ tag.span(I18n.t("pgbus.helpers.batch_status.#{status}", default: status), class: css)
97
+ end
98
+
87
99
  def pgbus_parse_message(message)
88
100
  return {} unless message
89
101
 
@@ -129,6 +129,7 @@
129
129
  <%= pgbus_nav_link t("pgbus.layout.nav.recurring"), pgbus.recurring_tasks_path %>
130
130
  <%= pgbus_nav_link t("pgbus.layout.nav.processes"), pgbus.processes_path %>
131
131
  <%= pgbus_nav_link t("pgbus.layout.nav.events"), pgbus.events_path %>
132
+ <%= pgbus_nav_link t("pgbus.layout.nav.batches"), pgbus.batches_path %>
132
133
  <%= pgbus_nav_link t("pgbus.layout.nav.dlq"), pgbus.dead_letter_index_path %>
133
134
  <%= pgbus_nav_link t("pgbus.layout.nav.outbox"), pgbus.outbox_index_path %>
134
135
  <%= pgbus_nav_link t("pgbus.layout.nav.locks"), pgbus.locks_path %>
@@ -188,6 +189,7 @@
188
189
  <%= pgbus_mobile_nav_link t("pgbus.layout.nav.recurring"), pgbus.recurring_tasks_path %>
189
190
  <%= pgbus_mobile_nav_link t("pgbus.layout.nav.processes"), pgbus.processes_path %>
190
191
  <%= pgbus_mobile_nav_link t("pgbus.layout.nav.events"), pgbus.events_path %>
192
+ <%= pgbus_mobile_nav_link t("pgbus.layout.nav.batches"), pgbus.batches_path %>
191
193
  <%= pgbus_mobile_nav_link t("pgbus.layout.nav.dlq"), pgbus.dead_letter_index_path %>
192
194
  <%= pgbus_mobile_nav_link t("pgbus.layout.nav.outbox"), pgbus.outbox_index_path %>
193
195
  <%= pgbus_mobile_nav_link t("pgbus.layout.nav.locks"), pgbus.locks_path %>
@@ -0,0 +1,54 @@
1
+ <turbo-frame id="batches-list">
2
+ <div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700">
3
+ <table class="pgbus-table min-w-full divide-y divide-gray-200 dark:divide-gray-700">
4
+ <thead class="bg-gray-50 dark:bg-gray-900">
5
+ <tr>
6
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.batches.index.headers.batch_id") %></th>
7
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.batches.index.headers.description") %></th>
8
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.batches.index.headers.status") %></th>
9
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.batches.index.headers.progress") %></th>
10
+ <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.batches.index.headers.jobs") %></th>
11
+ <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500 dark:text-gray-400"><%= t("pgbus.batches.index.headers.created") %></th>
12
+ </tr>
13
+ </thead>
14
+ <tbody class="divide-y divide-gray-100 dark:divide-gray-700">
15
+ <% @batches.each do |batch| %>
16
+ <tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
17
+ <td data-label="Batch ID" class="px-4 py-3 text-sm">
18
+ <%= link_to batch[:batch_id][0..7], pgbus.batch_path(batch[:batch_id]),
19
+ class: "font-mono text-indigo-600 dark:text-indigo-400 hover:underline",
20
+ data: { turbo_frame: "_top" } %>
21
+ </td>
22
+ <td data-label="Description" class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 max-w-xs truncate">
23
+ <%= batch[:description] || "—" %>
24
+ </td>
25
+ <td data-label="Status" class="px-4 py-3 text-sm">
26
+ <%= pgbus_batch_status_badge(batch[:status]) %>
27
+ </td>
28
+ <td data-label="Progress" class="px-4 py-3 text-sm">
29
+ <div class="flex items-center space-x-2">
30
+ <div class="w-24 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
31
+ <div class="h-2 rounded-full <%= batch[:discarded_jobs].to_i > 0 ? 'bg-amber-500' : 'bg-green-500' %>"
32
+ style="width: <%= batch[:progress_pct] %>%"></div>
33
+ </div>
34
+ <span class="text-xs text-gray-500 dark:text-gray-400 font-mono"><%= batch[:progress_pct] %>%</span>
35
+ </div>
36
+ </td>
37
+ <td data-label="Jobs" class="px-4 py-3 text-sm text-right font-mono text-gray-500 dark:text-gray-400">
38
+ <%= batch[:completed_jobs] %>/<%= batch[:total_jobs] %>
39
+ <% if batch[:discarded_jobs].to_i > 0 %>
40
+ <span class="text-red-500">(<%= batch[:discarded_jobs] %> dlq)</span>
41
+ <% end %>
42
+ </td>
43
+ <td data-label="Created" class="px-4 py-3 text-sm text-right text-gray-500 dark:text-gray-400">
44
+ <%= pgbus_time_ago(batch[:created_at]) %>
45
+ </td>
46
+ </tr>
47
+ <% end %>
48
+ <% if @batches.empty? %>
49
+ <tr><td colspan="6" class="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500"><%= t("pgbus.batches.index.empty") %></td></tr>
50
+ <% end %>
51
+ </tbody>
52
+ </table>
53
+ </div>
54
+ </turbo-frame>
@@ -0,0 +1,8 @@
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.batches.index.title") %></h1>
4
+ <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= t("pgbus.batches.index.description") %></p>
5
+ </div>
6
+ </div>
7
+
8
+ <%= render "pgbus/batches/batches_table" %>
@@ -0,0 +1,90 @@
1
+ <div class="mb-6">
2
+ <div class="flex items-center space-x-2 mb-2">
3
+ <%= link_to t("pgbus.batches.show.back"), pgbus.batches_path,
4
+ class: "text-sm text-indigo-600 dark:text-indigo-400 hover:underline" %>
5
+ </div>
6
+ <h1 class="text-2xl font-bold text-gray-900 dark:text-white">
7
+ <%= t("pgbus.batches.show.title") %>
8
+ <span class="font-mono text-lg"><%= @batch[:batch_id][0..7] %></span>
9
+ <%= pgbus_batch_status_badge(@batch[:status]) %>
10
+ </h1>
11
+ <% if @batch[:description] %>
12
+ <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= @batch[:description] %></p>
13
+ <% end %>
14
+ </div>
15
+
16
+ <!-- Progress -->
17
+ <div class="mb-6 overflow-hidden rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700 p-6">
18
+ <h2 class="text-sm font-semibold text-gray-900 dark:text-white mb-4"><%= t("pgbus.batches.show.progress") %></h2>
19
+
20
+ <div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-4 mb-3">
21
+ <div class="h-4 rounded-full transition-all duration-300 <%= @batch[:discarded_jobs].to_i > 0 ? 'bg-amber-500' : 'bg-green-500' %>"
22
+ style="width: <%= @batch[:progress_pct] %>%"></div>
23
+ </div>
24
+
25
+ <div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
26
+ <div>
27
+ <dt class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"><%= t("pgbus.batches.show.total_jobs") %></dt>
28
+ <dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white font-mono"><%= @batch[:total_jobs] %></dd>
29
+ </div>
30
+ <div>
31
+ <dt class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"><%= t("pgbus.batches.show.completed") %></dt>
32
+ <dd class="mt-1 text-2xl font-semibold text-green-600 dark:text-green-400 font-mono"><%= @batch[:completed_jobs] %></dd>
33
+ </div>
34
+ <div>
35
+ <dt class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"><%= t("pgbus.batches.show.discarded") %></dt>
36
+ <dd class="mt-1 text-2xl font-semibold <%= @batch[:discarded_jobs].to_i > 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-white' %> font-mono"><%= @batch[:discarded_jobs] %></dd>
37
+ </div>
38
+ <div>
39
+ <dt class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"><%= t("pgbus.batches.show.remaining") %></dt>
40
+ <dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white font-mono"><%= @batch[:total_jobs] - @batch[:completed_jobs] - @batch[:discarded_jobs] %></dd>
41
+ </div>
42
+ </div>
43
+ </div>
44
+
45
+ <!-- Details -->
46
+ <div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700">
47
+ <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
48
+ <h2 class="text-sm font-semibold text-gray-900 dark:text-white"><%= t("pgbus.batches.show.details") %></h2>
49
+ </div>
50
+ <dl class="divide-y divide-gray-100 dark:divide-gray-700">
51
+ <div class="px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
52
+ <dt class="text-sm font-medium text-gray-500 dark:text-gray-400"><%= t("pgbus.batches.show.batch_id") %></dt>
53
+ <dd class="mt-1 text-sm font-mono text-gray-900 dark:text-white sm:col-span-2 sm:mt-0"><%= @batch[:batch_id] %></dd>
54
+ </div>
55
+ <div class="px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
56
+ <dt class="text-sm font-medium text-gray-500 dark:text-gray-400"><%= t("pgbus.batches.show.created_at") %></dt>
57
+ <dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0"><%= @batch[:created_at] %></dd>
58
+ </div>
59
+ <% if @batch[:finished_at] %>
60
+ <div class="px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
61
+ <dt class="text-sm font-medium text-gray-500 dark:text-gray-400"><%= t("pgbus.batches.show.finished_at") %></dt>
62
+ <dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0"><%= @batch[:finished_at] %></dd>
63
+ </div>
64
+ <% end %>
65
+ <% if @batch[:on_finish_class] %>
66
+ <div class="px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
67
+ <dt class="text-sm font-medium text-gray-500 dark:text-gray-400"><%= t("pgbus.batches.show.on_finish") %></dt>
68
+ <dd class="mt-1 text-sm font-mono text-gray-900 dark:text-white sm:col-span-2 sm:mt-0"><%= @batch[:on_finish_class] %></dd>
69
+ </div>
70
+ <% end %>
71
+ <% if @batch[:on_success_class] %>
72
+ <div class="px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
73
+ <dt class="text-sm font-medium text-gray-500 dark:text-gray-400"><%= t("pgbus.batches.show.on_success") %></dt>
74
+ <dd class="mt-1 text-sm font-mono text-gray-900 dark:text-white sm:col-span-2 sm:mt-0"><%= @batch[:on_success_class] %></dd>
75
+ </div>
76
+ <% end %>
77
+ <% if @batch[:on_discard_class] %>
78
+ <div class="px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
79
+ <dt class="text-sm font-medium text-gray-500 dark:text-gray-400"><%= t("pgbus.batches.show.on_discard") %></dt>
80
+ <dd class="mt-1 text-sm font-mono text-gray-900 dark:text-white sm:col-span-2 sm:mt-0"><%= @batch[:on_discard_class] %></dd>
81
+ </div>
82
+ <% end %>
83
+ <% if @batch[:properties].present? %>
84
+ <div class="px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
85
+ <dt class="text-sm font-medium text-gray-500 dark:text-gray-400"><%= t("pgbus.batches.show.properties") %></dt>
86
+ <dd class="mt-1 text-sm font-mono text-gray-900 dark:text-white sm:col-span-2 sm:mt-0 break-all"><%= @batch[:properties] %></dd>
87
+ </div>
88
+ <% end %>
89
+ </dl>
90
+ </div>
@@ -1,6 +1,35 @@
1
1
  ---
2
2
  da:
3
3
  pgbus:
4
+ batches:
5
+ index:
6
+ description: Jobbatches med fremdriftssporing og callbacks
7
+ empty: Ingen batches fundet
8
+ headers:
9
+ batch_id: Batch-ID
10
+ created: Oprettet
11
+ description: Beskrivelse
12
+ jobs: Jobs
13
+ progress: Fremgang
14
+ status: Status
15
+ title: Batches
16
+ show:
17
+ back: Tilbage til batches
18
+ batch_id: Batch-ID
19
+ completed: Fuldført
20
+ created_at: Oprettet den
21
+ details: Detaljer
22
+ discarded: Kasseret
23
+ finished_at: Afsluttet den
24
+ not_found: Batch ikke fundet
25
+ on_discard: Ved kassering
26
+ on_finish: Ved afslutning
27
+ on_success: Ved succes
28
+ progress: Fremgang
29
+ properties: Egenskaber
30
+ remaining: Resterende
31
+ title: Batch
32
+ total_jobs: Samlet antal jobs
4
33
  dashboard:
5
34
  processes_table:
6
35
  empty: Ingen processer kører
@@ -189,6 +218,10 @@ da:
189
218
  not_found: Begivenhed ikke fundet
190
219
  title: Begivenhed %{event_id}
191
220
  helpers:
221
+ batch_status:
222
+ finished: Afsluttet
223
+ pending: Afventer
224
+ processing: Behandler
192
225
  bulk_select_all: Vælg alle
193
226
  bulk_select_row: Vælg %{id}
194
227
  bulk_selected: valgt
@@ -346,6 +379,7 @@ da:
346
379
  layout:
347
380
  brand: Pgbus
348
381
  nav:
382
+ batches: Batches
349
383
  dashboard: Dashboard
350
384
  dlq: DLQ
351
385
  events: Begivenheder
@@ -1,6 +1,35 @@
1
1
  ---
2
2
  de:
3
3
  pgbus:
4
+ batches:
5
+ index:
6
+ description: Job-Stapel mit Fortschrittsverfolgung und Callbacks
7
+ empty: Keine Stapel gefunden
8
+ headers:
9
+ batch_id: Stapel-ID
10
+ created: Erstellt
11
+ description: Beschreibung
12
+ jobs: Jobs
13
+ progress: Fortschritt
14
+ status: Status
15
+ title: Stapel
16
+ show:
17
+ back: Zurück zu Stapeln
18
+ batch_id: Stapel-ID
19
+ completed: Abgeschlossen
20
+ created_at: Erstellt am
21
+ details: Details
22
+ discarded: Verworfen
23
+ finished_at: Beendet am
24
+ not_found: Stapel nicht gefunden
25
+ on_discard: Bei Verwerfung
26
+ on_finish: Bei Beendigung
27
+ on_success: Bei Erfolg
28
+ progress: Fortschritt
29
+ properties: Eigenschaften
30
+ remaining: Verbleibend
31
+ title: Stapel
32
+ total_jobs: Gesamtanzahl Jobs
4
33
  dashboard:
5
34
  processes_table:
6
35
  empty: Keine Prozesse laufen
@@ -189,6 +218,10 @@ de:
189
218
  not_found: Ereignis nicht gefunden
190
219
  title: Ereignis %{event_id}
191
220
  helpers:
221
+ batch_status:
222
+ finished: Abgeschlossen
223
+ pending: Ausstehend
224
+ processing: In Bearbeitung
192
225
  bulk_select_all: Alle auswählen
193
226
  bulk_select_row: "%{id} auswählen"
194
227
  bulk_selected: ausgewählt
@@ -346,6 +379,7 @@ de:
346
379
  layout:
347
380
  brand: Pgbus
348
381
  nav:
382
+ batches: Stapel
349
383
  dashboard: Dashboard
350
384
  dlq: DLQ
351
385
  events: Ereignisse
@@ -1,6 +1,35 @@
1
1
  ---
2
2
  en:
3
3
  pgbus:
4
+ batches:
5
+ index:
6
+ description: Job batches with progress tracking and callbacks
7
+ empty: No batches found
8
+ headers:
9
+ batch_id: Batch ID
10
+ created: Created
11
+ description: Description
12
+ jobs: Jobs
13
+ progress: Progress
14
+ status: Status
15
+ title: Batches
16
+ show:
17
+ back: Back to batches
18
+ batch_id: Batch ID
19
+ completed: Completed
20
+ created_at: Created At
21
+ details: Details
22
+ discarded: Discarded
23
+ finished_at: Finished At
24
+ not_found: Batch not found
25
+ on_discard: On Discard
26
+ on_finish: On Finish
27
+ on_success: On Success
28
+ progress: Progress
29
+ properties: Properties
30
+ remaining: Remaining
31
+ title: Batch
32
+ total_jobs: Total Jobs
4
33
  dashboard:
5
34
  processes_table:
6
35
  empty: No processes running
@@ -189,6 +218,10 @@ en:
189
218
  not_found: Event not found
190
219
  title: Event %{event_id}
191
220
  helpers:
221
+ batch_status:
222
+ finished: Finished
223
+ pending: Pending
224
+ processing: Processing
192
225
  bulk_select_all: Select all
193
226
  bulk_select_row: Select %{id}
194
227
  bulk_selected: selected
@@ -346,6 +379,7 @@ en:
346
379
  layout:
347
380
  brand: Pgbus
348
381
  nav:
382
+ batches: Batches
349
383
  dashboard: Dashboard
350
384
  dlq: DLQ
351
385
  events: Events