pgbus 0.17.1 → 0.17.3

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 (156) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/README.md +83 -13
  4. data/Rakefile +26 -0
  5. data/app/assets/javascripts/pgbus/stream_source_element.js +34 -2
  6. data/app/assets/javascripts/pgbus/stream_source_element.min.js +6 -0
  7. data/app/assets/javascripts/pgbus/stream_source_element.min.js.map +1 -0
  8. data/app/controllers/concerns/pgbus/job_listing.rb +37 -0
  9. data/app/controllers/concerns/pgbus/process_listing.rb +17 -0
  10. data/app/controllers/pgbus/application_controller.rb +5 -2
  11. data/app/controllers/pgbus/batches_controller.rb +4 -1
  12. data/app/controllers/pgbus/dashboard_controller.rb +20 -6
  13. data/app/controllers/pgbus/dead_letter_controller.rb +27 -3
  14. data/app/controllers/pgbus/events_controller.rb +52 -3
  15. data/app/controllers/pgbus/frontends_controller.rb +25 -11
  16. data/app/controllers/pgbus/insights_controller.rb +19 -1
  17. data/app/controllers/pgbus/jobs_controller.rb +45 -26
  18. data/app/controllers/pgbus/locks_controller.rb +7 -2
  19. data/app/controllers/pgbus/outbox_controller.rb +4 -1
  20. data/app/controllers/pgbus/processes_controller.rb +3 -1
  21. data/app/controllers/pgbus/queues_controller.rb +16 -4
  22. data/app/controllers/pgbus/recurring_tasks_controller.rb +5 -7
  23. data/app/frontend/pgbus/application.min.js +2 -0
  24. data/app/frontend/pgbus/application.min.js.map +1 -0
  25. data/app/frontend/pgbus/modules/charts.js +137 -77
  26. data/app/frontend/pgbus/modules/charts.min.js +2 -0
  27. data/app/frontend/pgbus/modules/charts.min.js.map +1 -0
  28. data/app/frontend/pgbus/style.css +1 -1
  29. data/app/frontend/pgbus/tailwind.css +18 -0
  30. data/app/frontend/pgbus/vendor/turbo.min.js +54 -0
  31. data/app/frontend/pgbus/vendor/turbo.min.js.map +1 -0
  32. data/app/helpers/pgbus/application_helper.rb +155 -113
  33. data/app/helpers/pgbus/button_helper.rb +88 -0
  34. data/app/helpers/pgbus/dashboard_helper.rb +157 -0
  35. data/app/helpers/pgbus/events_helper.rb +40 -0
  36. data/app/helpers/pgbus/insights_helper.rb +39 -0
  37. data/app/helpers/pgbus/processes_helper.rb +79 -0
  38. data/app/views/layouts/pgbus/application.html.erb +0 -1
  39. data/app/views/pgbus/batches/_batches_table.html.erb +16 -13
  40. data/app/views/pgbus/batches/show.html.erb +3 -4
  41. data/app/views/pgbus/dashboard/_processes_table.html.erb +11 -11
  42. data/app/views/pgbus/dashboard/_queue_health.html.erb +42 -51
  43. data/app/views/pgbus/dashboard/_queues_table.html.erb +18 -16
  44. data/app/views/pgbus/dashboard/_recent_failures.html.erb +16 -12
  45. data/app/views/pgbus/dashboard/_stats_cards.html.erb +70 -36
  46. data/app/views/pgbus/dashboard/show.html.erb +1 -1
  47. data/app/views/pgbus/dead_letter/_error.html.erb +13 -0
  48. data/app/views/pgbus/dead_letter/_messages_table.html.erb +37 -96
  49. data/app/views/pgbus/dead_letter/_row.html.erb +75 -0
  50. data/app/views/pgbus/dead_letter/index.html.erb +6 -9
  51. data/app/views/pgbus/dead_letter/show.html.erb +37 -21
  52. data/app/views/pgbus/events/_list.html.erb +55 -0
  53. data/app/views/pgbus/events/_processed_table.html.erb +45 -0
  54. data/app/views/pgbus/events/_replay.html.erb +10 -0
  55. data/app/views/pgbus/events/_row.html.erb +162 -0
  56. data/app/views/pgbus/events/_tabs.html.erb +13 -0
  57. data/app/views/pgbus/events/index.html.erb +27 -48
  58. data/app/views/pgbus/events/show.html.erb +25 -7
  59. data/app/views/pgbus/insights/_chart_card.html.erb +21 -0
  60. data/app/views/pgbus/insights/_latency_table.html.erb +20 -0
  61. data/app/views/pgbus/insights/_status_table.html.erb +19 -0
  62. data/app/views/pgbus/insights/_throughput_table.html.erb +18 -0
  63. data/app/views/pgbus/insights/show.html.erb +83 -100
  64. data/app/views/pgbus/jobs/_list.html.erb +64 -0
  65. data/app/views/pgbus/jobs/_row.html.erb +107 -0
  66. data/app/views/pgbus/jobs/_tabs.html.erb +14 -0
  67. data/app/views/pgbus/jobs/index.html.erb +10 -10
  68. data/app/views/pgbus/jobs/show.html.erb +18 -20
  69. data/app/views/pgbus/locks/_concurrency.html.erb +25 -23
  70. data/app/views/pgbus/locks/_uniqueness.html.erb +20 -22
  71. data/app/views/pgbus/outbox/index.html.erb +30 -27
  72. data/app/views/pgbus/processes/_processes_table.html.erb +6 -25
  73. data/app/views/pgbus/processes/_row.html.erb +48 -0
  74. data/app/views/pgbus/processes/_status.html.erb +4 -0
  75. data/app/views/pgbus/queues/_queues_list.html.erb +28 -32
  76. data/app/views/pgbus/queues/show.html.erb +69 -118
  77. data/app/views/pgbus/recurring_tasks/_tasks_table.html.erb +75 -76
  78. data/app/views/pgbus/recurring_tasks/index.html.erb +1 -1
  79. data/app/views/pgbus/recurring_tasks/show.html.erb +45 -51
  80. data/app/views/pgbus/shared/_empty_row.html.erb +2 -0
  81. data/app/views/pgbus/shared/_pager.html.erb +34 -0
  82. data/config/i18n-tasks.yml +11 -2
  83. data/config/locales/da.yml +405 -113
  84. data/config/locales/de.yml +405 -113
  85. data/config/locales/en.yml +405 -113
  86. data/config/locales/es.yml +404 -112
  87. data/config/locales/fi.yml +404 -112
  88. data/config/locales/fr.yml +406 -114
  89. data/config/locales/it.yml +405 -113
  90. data/config/locales/ja.yml +405 -113
  91. data/config/locales/nb.yml +405 -113
  92. data/config/locales/nl.yml +405 -113
  93. data/config/locales/pt.yml +406 -114
  94. data/config/locales/sv.yml +404 -112
  95. data/config/routes.rb +1 -1
  96. data/lib/generators/pgbus/add_presence_generator.rb +2 -1
  97. data/lib/generators/pgbus/add_presence_seats_generator.rb +45 -0
  98. data/lib/generators/pgbus/templates/add_presence.rb.erb +32 -7
  99. data/lib/generators/pgbus/templates/add_presence_seats.rb.erb +38 -0
  100. data/lib/generators/pgbus/templates/initializer.rb.erb +4 -0
  101. data/lib/pgbus/active_job/executor.rb +16 -6
  102. data/lib/pgbus/cli/dlq.rb +32 -5
  103. data/lib/pgbus/client.rb +4 -2
  104. data/lib/pgbus/configuration/presence_settings.rb +103 -0
  105. data/lib/pgbus/configuration.rb +51 -2
  106. data/lib/pgbus/dead_letter_header.rb +129 -0
  107. data/lib/pgbus/doctor.rb +8 -4
  108. data/lib/pgbus/engine.rb +13 -7
  109. data/lib/pgbus/event_bus/handler.rb +18 -11
  110. data/lib/pgbus/event_bus/registry.rb +6 -0
  111. data/lib/pgbus/failed_event_recorder.rb +34 -0
  112. data/lib/pgbus/generators/migration_detector.rb +9 -1
  113. data/lib/pgbus/instrumentation.rb +2 -1
  114. data/lib/pgbus/mcp/base_tool.rb +25 -0
  115. data/lib/pgbus/mcp/tools/concurrency_tool.rb +23 -4
  116. data/lib/pgbus/mcp/tools/dlq_detail_tool.rb +4 -2
  117. data/lib/pgbus/mcp/tools/dlq_tool.rb +10 -4
  118. data/lib/pgbus/mcp/tools/locks_tool.rb +23 -5
  119. data/lib/pgbus/mcp/tools/recurring_tool.rb +33 -4
  120. data/lib/pgbus/outbox/poller.rb +1 -1
  121. data/lib/pgbus/process/consumer.rb +53 -3
  122. data/lib/pgbus/process/dispatcher.rb +1 -1
  123. data/lib/pgbus/process/supervisor.rb +32 -11
  124. data/lib/pgbus/process/worker.rb +45 -8
  125. data/lib/pgbus/recurring/scheduler.rb +2 -1
  126. data/lib/pgbus/streams/presence.rb +317 -81
  127. data/lib/pgbus/version.rb +1 -1
  128. data/lib/pgbus/web/data_source/dashboard_signals.rb +170 -0
  129. data/lib/pgbus/web/data_source/dead_letter.rb +142 -0
  130. data/lib/pgbus/web/data_source/event_list.rb +161 -0
  131. data/lib/pgbus/web/data_source/job_list.rb +323 -0
  132. data/lib/pgbus/web/data_source/list_counts.rb +80 -0
  133. data/lib/pgbus/web/data_source/process_list.rb +69 -0
  134. data/lib/pgbus/web/data_source/queue_summary.rb +122 -0
  135. data/lib/pgbus/web/data_source.rb +132 -254
  136. data/lib/pgbus/web/dead_letter_reason.rb +81 -0
  137. data/lib/pgbus/web/event_state.rb +71 -0
  138. data/lib/pgbus/web/health_app.rb +7 -3
  139. data/lib/pgbus/web/health_verdict.rb +78 -0
  140. data/lib/pgbus/web/insights_charts.rb +84 -0
  141. data/lib/pgbus/web/job_state.rb +111 -0
  142. data/lib/pgbus/web/process_activity.rb +97 -0
  143. data/lib/pgbus/web/process_state.rb +187 -0
  144. data/lib/pgbus/web/queue_summary.rb +104 -0
  145. data/lib/pgbus/web/stream_app.rb +18 -6
  146. data/lib/pgbus/web/streamer/connection.rb +7 -1
  147. data/lib/pgbus/web/streamer/falcon_connection.rb +7 -1
  148. data/lib/pgbus/web/streamer/heartbeat.rb +9 -3
  149. data/lib/pgbus/web/streamer/instance.rb +2 -1
  150. data/lib/pgbus/web/streamer/presence_tracker.rb +155 -0
  151. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +8 -37
  152. data/lib/pgbus/web/time_format.rb +128 -0
  153. metadata +55 -4
  154. data/app/views/pgbus/events/_pending_table.html.erb +0 -169
  155. data/app/views/pgbus/jobs/_enqueued_table.html.erb +0 -118
  156. data/app/views/pgbus/jobs/_failed_table.html.erb +0 -76
@@ -2,16 +2,18 @@
2
2
 
3
3
  module Pgbus
4
4
  class DashboardController < ApplicationController
5
+ include ProcessListing
6
+
5
7
  def show
6
8
  case params[:frame]
7
9
  when "stats"
8
- @stats = data_source.summary_stats
10
+ load_stats
9
11
  render_frame("pgbus/dashboard/stats_cards")
10
12
  when "queues"
11
- @queues = data_source.queues_with_metrics
13
+ load_queues
12
14
  render_frame("pgbus/dashboard/queues_table")
13
15
  when "processes"
14
- @processes = data_source.processes
16
+ load_processes
15
17
  render_frame("pgbus/dashboard/processes_table")
16
18
  when "failures"
17
19
  @recent_failures = data_source.failed_events(page: 1, per_page: 5)
@@ -20,12 +22,24 @@ module Pgbus
20
22
  @health = data_source.queue_health_stats
21
23
  render_frame("pgbus/dashboard/queue_health")
22
24
  else
23
- @stats = data_source.summary_stats
24
- @queues = data_source.queues_with_metrics
25
- @processes = data_source.processes
25
+ load_stats
26
+ load_queues
27
+ load_processes
26
28
  @recent_failures = data_source.failed_events(page: 1, per_page: 5)
27
29
  @health = data_source.queue_health_stats
28
30
  end
29
31
  end
32
+
33
+ private
34
+
35
+ def load_stats
36
+ @stats = data_source.summary_stats
37
+ @signals = data_source.dashboard_signals
38
+ end
39
+
40
+ def load_queues
41
+ @queues = data_source.queues_with_metrics
42
+ @queue_lines = data_source.queue_status_lines
43
+ end
30
44
  end
31
45
  end
@@ -2,17 +2,28 @@
2
2
 
3
3
  module Pgbus
4
4
  class DeadLetterController < ApplicationController
5
+ ERROR_CLASS_MAX = 200
6
+
5
7
  def index
6
8
  @page = page_param
7
9
  @per_page = per_page
8
- @messages = data_source.dlq_messages(page: @page, per_page: @per_page)
9
- @total_count = data_source.dlq_total_count
10
+ @dlq = dlq_param
11
+ @error_class = error_class_param
12
+ filters = { dlq: @dlq, error_class: @error_class }
13
+ @messages = data_source.dlq_messages(page: @page, per_page: @per_page, **filters)
14
+ @total_count = data_source.dlq_total_count(**filters)
10
15
  @total_pages = (@total_count.to_f / @per_page).ceil
16
+ @dlq_counts = data_source.dlq_counts_by_queue
17
+ @dlq_rows = @messages.map { |m| [m, Pgbus::Web::DeadLetterReason.present(m)] }
18
+ # One place builds every list URL (frame source, pager, chips), so the
19
+ # filters survive refresh and paging.
20
+ @list_path = ->(extra = {}) { pgbus.dead_letter_index_path(filters.merge(extra).compact) }
11
21
  render_frame("pgbus/dead_letter/messages_table") if params[:frame] == "list"
12
22
  end
13
23
 
14
24
  def show
15
- @message = data_source.dlq_message_detail(params[:id].to_i)
25
+ @message = data_source.dlq_message_detail(params[:id].to_i, queue_name: dlq_param(:queue_name))
26
+ @reason = Pgbus::Web::DeadLetterReason.present(@message) if @message
16
27
  end
17
28
 
18
29
  def retry
@@ -63,5 +74,18 @@ module Pgbus
63
74
  end
64
75
  redirect_to dead_letter_index_path, notice: t("pgbus.dead_letter.index.discarded_selected", count: count)
65
76
  end
77
+
78
+ private
79
+
80
+ # A full DLQ name or nil; the data source also checks it is a known DLQ.
81
+ def dlq_param(key = :dlq)
82
+ name = params[key].to_s
83
+ name if name.end_with?(Pgbus::DEAD_LETTER_SUFFIX) && name.match?(/\A\w+\z/)
84
+ end
85
+
86
+ def error_class_param
87
+ value = params[:error_class].to_s
88
+ value if value.present? && value.length <= ERROR_CLASS_MAX
89
+ end
66
90
  end
67
91
  end
@@ -2,14 +2,35 @@
2
2
 
3
3
  module Pgbus
4
4
  class EventsController < ApplicationController
5
+ # Tabs of the pending list; "all" is no filter.
6
+ STATES = ["all", *Web::EventState::STATES].freeze
7
+
8
+ # One pending-events list (the job list scoped to the handler queues) and
9
+ # the processed-events audit, each in its own auto-refreshing frame with
10
+ # its own pager (page / processed_page), issue #494.
5
11
  def index
6
- @events = data_source.processed_events(page: page_param, per_page: per_page)
7
- @subscribers = data_source.registered_subscribers
8
- @pending = data_source.pending_events(page: page_param, per_page: per_page)
12
+ @state = STATES.include?(params[:state]) ? params[:state] : "all"
13
+ @queue = params[:queue].presence
14
+ @page = page_param
15
+ @processed_page = page_param(:processed_page)
16
+ @per_page = per_page
17
+ if params[:frame] == "processed"
18
+ load_processed_events
19
+ return render_frame("pgbus/events/processed_table")
20
+ end
21
+
22
+ load_pending_events
23
+ return render_frame("pgbus/events/list") if params[:frame] == "list"
24
+
25
+ load_processed_events
9
26
  end
10
27
 
11
28
  def show
12
29
  @event = data_source.processed_event(params[:id])
30
+ return unless @event
31
+
32
+ @claim = Web::EventState.processed(@event, now: Time.now, claim_window: EventBus::Handler.claim_ownership_window)
33
+ @replay_state = data_source.event_replay_states([@event])[@event["id"]]
13
34
  end
14
35
 
15
36
  def replay
@@ -53,6 +74,10 @@ module Pgbus
53
74
  return reject_unknown_queue(:payload_update_failed) unless registered_queue?(queue_name)
54
75
 
55
76
  new_payload = params[:payload].to_s
77
+ # The editor shows the payload through PayloadFilter: saving a marker
78
+ # back would overwrite the redacted secret for good.
79
+ return redirect_to(events_path, alert: t("pgbus.events.flash.payload_redacted")) if new_payload.include?(Web::PayloadFilter::FILTERED)
80
+
56
81
  if data_source.edit_event_payload(queue_name, params[:id], new_payload)
57
82
  redirect_to events_path, notice: t("pgbus.events.flash.payload_updated")
58
83
  else
@@ -104,6 +129,30 @@ module Pgbus
104
129
 
105
130
  private
106
131
 
132
+ def load_pending_events
133
+ @rows = data_source.event_rows(state: (@state unless @state == "all"), queue_name: @queue,
134
+ page: @page, per_page: @per_page)
135
+ @counts = data_source.event_state_counts(queue_name: @queue)
136
+ @ahead = data_source.events_ahead(@rows)
137
+ @state_context = data_source.event_list_context
138
+ @subscribers = data_source.registered_subscribers
139
+ end
140
+
141
+ def load_processed_events
142
+ @events = data_source.processed_events(page: @processed_page, per_page: @per_page)
143
+ @processed_count = data_source.list_count(:processed_events)
144
+ @replay_states = data_source.event_replay_states(@events)
145
+ @claim_window = EventBus::Handler.claim_ownership_window
146
+ end
147
+
148
+ # Every list link keeps the other list's page, so paging one frame never
149
+ # resets the other.
150
+ def events_list_path(extra = {})
151
+ events_path({ state: (@state unless @state == "all"), queue: @queue, page: (@page if @page > 1),
152
+ processed_page: (@processed_page if @processed_page > 1) }.merge(extra).compact)
153
+ end
154
+ helper_method :events_list_path
155
+
107
156
  def registered_queues
108
157
  @registered_queues ||= data_source.handler_queue_physical_names
109
158
  end
@@ -10,20 +10,21 @@ module Pgbus
10
10
  STATIC_ASSETS = {
11
11
  css: {
12
12
  style: FRONTEND_ROOT.join("style.css")
13
- },
14
- js: {
15
- apexcharts: FRONTEND_ROOT.join("vendor", "apexcharts.js")
16
13
  }
17
14
  }.freeze
18
15
 
16
+ # Modules are served minified (rake frontend:js), each with its .min.js.map
17
+ # beside it. ApexCharts ships minified already; charts.js imports it on
18
+ # demand, so only Insights downloads it.
19
19
  MODULE_OVERRIDES = {
20
- application: FRONTEND_ROOT.join("application.js"),
21
- turbo: FRONTEND_ROOT.join("vendor", "turbo.js")
20
+ application: FRONTEND_ROOT.join("application.min.js"),
21
+ turbo: FRONTEND_ROOT.join("vendor", "turbo.min.js"),
22
+ apexcharts: FRONTEND_ROOT.join("vendor", "apexcharts.js")
22
23
  }.freeze
23
24
 
24
25
  def self.js_modules
25
- @js_modules ||= FRONTEND_ROOT.join("modules").children.select(&:file?)
26
- .to_h { |f| [File.basename(f.basename.to_s, ".js").to_sym, f] }
26
+ @js_modules ||= FRONTEND_ROOT.join("modules").glob("*.min.js").sort
27
+ .to_h { |f| [f.basename(".min.js").to_s.to_sym, f] }
27
28
  .merge(MODULE_OVERRIDES)
28
29
  end
29
30
 
@@ -43,18 +44,31 @@ module Pgbus
43
44
  render file: file
44
45
  end
45
46
 
47
+ # GET modules/:version/<id>.js serves the module; <id>.map its sourcemap,
48
+ # the URL each minified module's sourceMappingURL comment resolves to.
46
49
  def module
47
50
  validate_version!
48
- raise ActionController::RoutingError, "Not Found" if params[:format] != "js"
49
-
50
51
  file = self.class.js_modules[params[:id]&.to_sym]
51
- raise ActionController::RoutingError, "Not Found" unless file&.exist?
52
52
 
53
- render file: file
53
+ case params[:format]
54
+ when "js" then serve(file)
55
+ when "map" then serve(sourcemap_for(file), content_type: "application/json")
56
+ else raise ActionController::RoutingError, "Not Found"
57
+ end
54
58
  end
55
59
 
56
60
  private
57
61
 
62
+ def serve(file, **)
63
+ raise ActionController::RoutingError, "Not Found" unless file&.exist?
64
+
65
+ render(file: file, **)
66
+ end
67
+
68
+ def sourcemap_for(file)
69
+ Pathname.new("#{file}.map") if file.to_s.end_with?(".min.js")
70
+ end
71
+
58
72
  def validate_version!
59
73
  return if params[:version] == CURRENT_VERSION
60
74
 
@@ -7,7 +7,8 @@ module Pgbus
7
7
  @summary = data_source.job_stats_summary(minutes: @minutes)
8
8
  @slowest = data_source.slowest_job_classes(minutes: @minutes)
9
9
  @latency_by_queue = data_source.latency_by_queue(minutes: @minutes)
10
- @latency_available = Pgbus::JobStat.latency_columns?
10
+ @latency_available = data_source.latency_available?
11
+ load_charts
11
12
 
12
13
  @live_stream_metrics = data_source.live_stream_metrics
13
14
 
@@ -17,5 +18,22 @@ module Pgbus
17
18
  @stream_summary = data_source.stream_stats_summary(minutes: @minutes)
18
19
  @top_streams = data_source.top_streams(minutes: @minutes)
19
20
  end
21
+
22
+ private
23
+
24
+ # The chart series, read once: the view renders their captions and data
25
+ # tables, and embeds the same series for charts.js (issue #492), so the
26
+ # page no longer fetches /pgbus/api/insights to draw them.
27
+ def load_charts
28
+ throughput = data_source.job_throughput(minutes: @minutes)
29
+ status_counts = data_source.job_status_counts(minutes: @minutes)
30
+ latency_trend = @latency_available ? data_source.latency_trend(minutes: @minutes) : []
31
+ @charts = {
32
+ throughput: Web::InsightsCharts.throughput(throughput, minutes: @minutes),
33
+ status: Web::InsightsCharts.status(status_counts),
34
+ latency: Web::InsightsCharts.latency(latency_trend, minutes: @minutes)
35
+ }
36
+ @chart_payload = { throughput: throughput, status_counts: status_counts, latency_trend: latency_trend }
37
+ end
20
38
  end
21
39
  end
@@ -2,17 +2,14 @@
2
2
 
3
3
  module Pgbus
4
4
  class JobsController < ApplicationController
5
+ include JobListing
6
+
5
7
  def index
6
- if params[:frame] == "failed"
7
- @failed = data_source.failed_events(page: page_param, per_page: per_page)
8
- render_frame("pgbus/jobs/failed_table")
9
- elsif params[:frame] == "enqueued"
10
- @jobs = params[:status] == "failed" ? [] : data_source.jobs(queue_name: params[:queue], page: page_param, per_page: per_page)
11
- render_frame("pgbus/jobs/enqueued_table")
12
- else
13
- @jobs = params[:status] == "failed" ? [] : data_source.jobs(queue_name: params[:queue], page: page_param, per_page: per_page)
14
- @failed = data_source.failed_events(page: page_param, per_page: per_page)
15
- end
8
+ @queue = params[:queue].presence
9
+ load_job_list(queue_name: @queue, list_path: ->(extra) { jobs_path({ queue: @queue }.merge(extra)) },
10
+ exclude: (data_source.handler_queue_physical_names unless @queue))
11
+ @failed_total = @queue ? data_source.failed_events_count : @counts["retrying"]
12
+ render_frame("pgbus/jobs/list") if params[:frame] == "list"
16
13
  end
17
14
 
18
15
  def show
@@ -21,17 +18,17 @@ module Pgbus
21
18
 
22
19
  def retry
23
20
  if data_source.retry_failed_event(params[:id])
24
- redirect_to jobs_path, notice: "Job re-enqueued."
21
+ redirect_after_failed_event_action notice: "Job re-enqueued."
25
22
  else
26
- redirect_to jobs_path, alert: "Could not retry job."
23
+ redirect_after_failed_event_action alert: "Could not retry job."
27
24
  end
28
25
  end
29
26
 
30
27
  def discard
31
28
  if data_source.discard_failed_event(params[:id])
32
- redirect_to jobs_path, notice: "Job discarded."
29
+ redirect_after_failed_event_action notice: "Job discarded."
33
30
  else
34
- redirect_to jobs_path, alert: "Could not discard job."
31
+ redirect_after_failed_event_action alert: "Could not discard job."
35
32
  end
36
33
  end
37
34
 
@@ -47,7 +44,7 @@ module Pgbus
47
44
 
48
45
  def discard_all_enqueued
49
46
  count = data_source.discard_all_enqueued
50
- redirect_to jobs_path, notice: t("pgbus.jobs.index.discard_all_enqueued_notice", count: count)
47
+ redirect_back fallback_location: jobs_path, notice: t("pgbus.jobs.index.discard_all_enqueued_notice", count: count)
51
48
  end
52
49
 
53
50
  def discard_selected_failed
@@ -64,8 +61,40 @@ module Pgbus
64
61
  redirect_to jobs_path, notice: t("pgbus.jobs.index.discarded_selected", count: count)
65
62
  end
66
63
 
64
+ # The unified list posts every selected row here: queue messages as
65
+ # messages[], rows backed by a failed event (retrying, orphaned) as ids[]
66
+ # so discarding them also clears the failed-event row.
67
67
  def discard_selected_enqueued
68
- selections = Array(params[:messages]).filter_map do |s|
68
+ selections = selected_messages
69
+ ids = Array(params[:ids]).map(&:to_i).reject(&:zero?)
70
+ if selections.empty? && ids.empty?
71
+ redirect_back fallback_location: jobs_path, alert: t("pgbus.jobs.index.none_selected")
72
+ return
73
+ end
74
+
75
+ count = ids.count { |id| data_source.discard_failed_event(id) }
76
+ count += selections.count { |sel| data_source.discard_job(sel[:queue_name], sel[:msg_id]) }
77
+ redirect_back fallback_location: jobs_path, notice: t("pgbus.jobs.index.discarded_selected", count: count)
78
+ end
79
+
80
+ private
81
+
82
+ # Back to the page the action came from (a queue page, the Jobs list),
83
+ # except the job's own detail page: its failed event is gone now.
84
+ def redirect_after_failed_event_action(**flash)
85
+ return redirect_to(jobs_path, **flash) if referer_path == job_path(params[:id])
86
+
87
+ redirect_back(fallback_location: jobs_path, **flash)
88
+ end
89
+
90
+ def referer_path
91
+ URI.parse(request.referer.to_s).path
92
+ rescue URI::InvalidURIError
93
+ nil
94
+ end
95
+
96
+ def selected_messages
97
+ Array(params[:messages]).filter_map do |s|
69
98
  next unless s.respond_to?(:[])
70
99
 
71
100
  queue_name = s[:queue_name]
@@ -74,16 +103,6 @@ module Pgbus
74
103
 
75
104
  { queue_name: queue_name, msg_id: msg_id }
76
105
  end
77
- if selections.empty?
78
- redirect_to jobs_path, alert: t("pgbus.jobs.index.none_selected")
79
- return
80
- end
81
-
82
- count = 0
83
- selections.each do |sel|
84
- count += 1 if data_source.discard_job(sel[:queue_name], sel[:msg_id])
85
- end
86
- redirect_to jobs_path, notice: t("pgbus.jobs.index.discarded_selected", count: count)
87
106
  end
88
107
  end
89
108
  end
@@ -3,10 +3,15 @@
3
3
  module Pgbus
4
4
  class LocksController < ApplicationController
5
5
  def index
6
- @concurrency = data_source.concurrency_stats
6
+ @per_page = per_page
7
+ @page = page_param
8
+ @keys_page = page_param(:keys_page)
9
+ @concurrency = data_source.concurrency_stats(page: @keys_page, per_page: @per_page)
10
+ @keys_count = data_source.list_count(:concurrency_keys)
7
11
  return render_frame("pgbus/locks/concurrency") if params[:frame] == "concurrency"
8
12
 
9
- @locks = data_source.job_locks
13
+ @locks = data_source.job_locks(page: @page, per_page: @per_page)
14
+ @locks_count = data_source.list_count(:job_locks)
10
15
  end
11
16
 
12
17
  def discard
@@ -4,7 +4,10 @@ module Pgbus
4
4
  class OutboxController < ApplicationController
5
5
  def index
6
6
  @stats = data_source.outbox_stats
7
- @entries = data_source.outbox_entries(page: page_param, per_page: per_page)
7
+ @page = page_param
8
+ @per_page = per_page
9
+ @entries = data_source.outbox_entries(page: @page, per_page: @per_page)
10
+ @entries_count = data_source.list_count(:outbox)
8
11
  end
9
12
  end
10
13
  end
@@ -2,8 +2,10 @@
2
2
 
3
3
  module Pgbus
4
4
  class ProcessesController < ApplicationController
5
+ include ProcessListing
6
+
5
7
  def index
6
- @processes = data_source.processes
8
+ load_processes
7
9
  render_frame("pgbus/processes/processes_table") if params[:frame] == "list"
8
10
  end
9
11
  end
@@ -6,13 +6,25 @@ module Pgbus
6
6
  @queues = data_source.queues_with_metrics
7
7
  end
8
8
 
9
+ include JobListing
10
+
9
11
  def show
10
- @queue = data_source.queue_detail(params[:name])
12
+ name = params[:name]
13
+ # The unified list leaves DLQ tables out (they are the Dead Letter page's).
14
+ @dlq = name.end_with?(Pgbus::DEAD_LETTER_SUFFIX)
15
+ load_job_list(queue_name: name, list_path: ->(extra) { queue_path({ name: name }.merge(extra)) }) unless @dlq
16
+ # The auto-refresh poll needs only the list: skip the queue's metrics scan.
17
+ render_frame("pgbus/jobs/list") and return if params[:frame] == "list" && !@dlq
18
+
19
+ @queue = data_source.queue_detail(name)
11
20
  redirect_to queues_path, alert: "Queue not found." and return unless @queue
12
21
 
13
- @paused = data_source.queue_paused?(params[:name])
14
- @messages = data_source.jobs(queue_name: params[:name], page: page_param, per_page: per_page)
15
- @health = data_source.queue_health_detail(params[:name])
22
+ @paused = data_source.queue_paused?(name)
23
+ @pause_state = data_source.queue_pause_state(name)
24
+ @drainers = data_source.queue_drainers(name)
25
+ @summary = Web::QueueSummary.present(@queue, @pause_state, @drainers,
26
+ max_retries: Pgbus.configuration.max_retries)
27
+ @health = data_source.queue_health_detail(name)
16
28
  end
17
29
 
18
30
  def purge
@@ -3,13 +3,11 @@
3
3
  module Pgbus
4
4
  class RecurringTasksController < ApplicationController
5
5
  def index
6
- case params[:frame]
7
- when "recurring_tasks"
8
- @recurring_tasks = data_source.recurring_tasks
9
- render_frame("pgbus/recurring_tasks/tasks_table")
10
- else
11
- @recurring_tasks = data_source.recurring_tasks
12
- end
6
+ @page = page_param
7
+ @per_page = per_page
8
+ @recurring_tasks = data_source.recurring_tasks(page: @page, per_page: @per_page)
9
+ @recurring_tasks_count = data_source.list_count(:recurring_tasks)
10
+ render_frame("pgbus/recurring_tasks/tasks_table") if params[:frame] == "recurring_tasks"
13
11
  end
14
12
 
15
13
  def show
@@ -0,0 +1,2 @@
1
+ import*as m from"turbo";m.config.forms.confirm=(e,o)=>{let a=document.getElementById("pgbus-confirm-dialog"),n=document.getElementById("pgbus-confirm-message"),d=document.getElementById("pgbus-confirm-title"),t=document.getElementById("pgbus-confirm-btn"),c=document.getElementById("pgbus-confirm-icon"),r=o.getAttribute("data-turbo-method")==="delete",s=document.getElementById("pgbus-i18n");if(d.textContent=r?s?.dataset.deleteTitle||"Delete":s?.dataset.confirmTitle||"Are you sure?",n.textContent=e,t.className="rounded-md px-4 py-2 text-sm font-medium text-white focus:outline-none focus:ring-2",r)t.classList.add("bg-red-600","hover:bg-red-500","focus:ring-red-500"),t.textContent=s?.dataset.deleteLabel||"Delete",c.className="flex-shrink-0 flex items-center justify-center h-10 w-10 rounded-full bg-red-100 dark:bg-red-900/30";else t.classList.add("bg-yellow-500","hover:bg-yellow-400","focus:ring-yellow-500"),t.textContent=s?.dataset.confirmLabel||"Confirm",c.className="flex-shrink-0 flex items-center justify-center h-10 w-10 rounded-full bg-yellow-100 dark:bg-yellow-900/30";return a.showModal(),new Promise((g)=>{a.addEventListener("close",()=>{g(a.returnValue==="confirm")},{once:!0})})};function f(e,o="success"){let a=document.getElementById("pgbus-toast-container");if(!a)return;let n=document.createElement("div"),d={success:"bg-green-50 dark:bg-green-900/30 text-green-800 dark:text-green-300 border-green-200 dark:border-green-800",error:"bg-red-50 dark:bg-red-900/30 text-red-800 dark:text-red-300 border-red-200 dark:border-red-800",info:"bg-blue-50 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 border-blue-200 dark:border-blue-800"};n.className=`rounded-md border p-3 text-sm shadow-lg transition-all duration-300 ${d[o]||d.info}`,n.textContent=e,a.appendChild(n),setTimeout(()=>{n.style.opacity="0",n.style.transform="translateX(100%)",setTimeout(()=>n.remove(),300)},5000)}function b(){document.querySelectorAll("template[data-pgbus-toast]").forEach((e)=>{f(e.content.textContent.trim(),e.dataset.pgbusToast),e.remove()})}b();document.addEventListener("turbo:load",b);function u(){document.querySelectorAll("[data-bulk-select-all]").forEach((e)=>{if(e.dataset.bulkInitialized==="true")return;e.dataset.bulkInitialized="true";let o=e.closest("[data-bulk-scope]")||document,a=()=>o.querySelectorAll("input[data-bulk-item]"),n=o.closest("[data-bulk-page]")||o.closest("turbo-frame")||o.parentElement?.closest("div")||document,d=n.querySelector("[data-bulk-count]"),t=n.querySelector("[data-bulk-actions]");function c(){let r=o.querySelectorAll("input[data-bulk-item]:checked");if(d)d.textContent=r.length;if(t)t.classList.toggle("hidden",r.length===0);let s=a();e.checked=s.length>0&&r.length===s.length,e.indeterminate=r.length>0&&r.length<s.length}let l=!1;e.addEventListener("change",()=>{l=!0,a().forEach((r)=>{r.checked=e.checked,r.dispatchEvent(new Event("change",{bubbles:!1}))}),l=!1,c()}),o.addEventListener("change",(r)=>{if(r.target.matches("input[data-bulk-item]"))c()})})}u();document.addEventListener("turbo:load",u);document.addEventListener("turbo:frame-load",u);var i=parseInt(document.body?.dataset.pgbusRefreshInterval||"0",10);if(i>0){let o=function(){if(document.querySelector("input[data-bulk-item]:checked, input[data-bulk-select-all]:checked"))return!0;if(document.querySelector("turbo-frame[data-auto-refresh] details[open]"))return!0;return!1},a=function(){if(document.hidden)return;if(o())return;document.querySelectorAll("turbo-frame[data-auto-refresh]").forEach((t)=>{try{if(!t.src&&t.dataset.src)t.src=t.dataset.src;if(t.src)t.reload()}catch(c){}})},n=function(){e=setInterval(a,i)},d=function(){clearInterval(e)},e;document.addEventListener("visibilitychange",()=>document.hidden?d():n()),n()}
2
+ //# sourceMappingURL=application.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["src/application.js"],"sourcesContent":["import * as Turbo from \"turbo\";\n\n// -- Custom confirm dialog (replaces browser confirm) --\nTurbo.config.forms.confirm = (message, element) => {\n const dialog = document.getElementById(\"pgbus-confirm-dialog\");\n const messageEl = document.getElementById(\"pgbus-confirm-message\");\n const titleEl = document.getElementById(\"pgbus-confirm-title\");\n const confirmBtn = document.getElementById(\"pgbus-confirm-btn\");\n const iconEl = document.getElementById(\"pgbus-confirm-icon\");\n\n const turboMethod = element.getAttribute(\"data-turbo-method\");\n const isDelete = turboMethod === \"delete\";\n\n const i18n = document.getElementById(\"pgbus-i18n\");\n titleEl.textContent = isDelete\n ? (i18n?.dataset.deleteTitle || \"Delete\")\n : (i18n?.dataset.confirmTitle || \"Are you sure?\");\n messageEl.textContent = message;\n\n confirmBtn.className = \"rounded-md px-4 py-2 text-sm font-medium text-white focus:outline-none focus:ring-2\";\n if (isDelete) {\n confirmBtn.classList.add(\"bg-red-600\", \"hover:bg-red-500\", \"focus:ring-red-500\");\n confirmBtn.textContent = i18n?.dataset.deleteLabel || \"Delete\";\n iconEl.className = \"flex-shrink-0 flex items-center justify-center h-10 w-10 rounded-full bg-red-100 dark:bg-red-900/30\";\n } else {\n confirmBtn.classList.add(\"bg-yellow-500\", \"hover:bg-yellow-400\", \"focus:ring-yellow-500\");\n confirmBtn.textContent = i18n?.dataset.confirmLabel || \"Confirm\";\n iconEl.className = \"flex-shrink-0 flex items-center justify-center h-10 w-10 rounded-full bg-yellow-100 dark:bg-yellow-900/30\";\n }\n\n dialog.showModal();\n\n return new Promise((resolve) => {\n dialog.addEventListener(\"close\", () => {\n resolve(dialog.returnValue === \"confirm\");\n }, { once: true });\n });\n};\n\n// -- Toast notifications --\nfunction showToast(message, type = \"success\") {\n const container = document.getElementById(\"pgbus-toast-container\");\n if (!container) return;\n const toast = document.createElement(\"div\");\n\n const colors = {\n success: \"bg-green-50 dark:bg-green-900/30 text-green-800 dark:text-green-300 border-green-200 dark:border-green-800\",\n error: \"bg-red-50 dark:bg-red-900/30 text-red-800 dark:text-red-300 border-red-200 dark:border-red-800\",\n info: \"bg-blue-50 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 border-blue-200 dark:border-blue-800\",\n };\n\n toast.className = `rounded-md border p-3 text-sm shadow-lg transition-all duration-300 ${colors[type] || colors.info}`;\n toast.textContent = message;\n container.appendChild(toast);\n\n setTimeout(() => {\n toast.style.opacity = \"0\";\n toast.style.transform = \"translateX(100%)\";\n setTimeout(() => toast.remove(), 300);\n }, 5000);\n}\n\nfunction renderFlashToasts() {\n document.querySelectorAll(\"template[data-pgbus-toast]\").forEach(tpl => {\n showToast(tpl.content.textContent.trim(), tpl.dataset.pgbusToast);\n tpl.remove();\n });\n}\nrenderFlashToasts();\ndocument.addEventListener(\"turbo:load\", renderFlashToasts);\n\n// -- Bulk checkbox selection --\nfunction initBulkSelect() {\n document.querySelectorAll(\"[data-bulk-select-all]\").forEach(selectAll => {\n if (selectAll.dataset.bulkInitialized === \"true\") return;\n selectAll.dataset.bulkInitialized = \"true\";\n\n const scope = selectAll.closest(\"[data-bulk-scope]\") || document;\n const checkboxes = () => scope.querySelectorAll(\"input[data-bulk-item]\");\n // Look for bulk-actions in the parent page container (outside the table scope)\n const pageScope = scope.closest(\"[data-bulk-page]\") || scope.closest(\"turbo-frame\") || scope.parentElement?.closest(\"div\") || document;\n const countEl = pageScope.querySelector(\"[data-bulk-count]\");\n const actions = pageScope.querySelector(\"[data-bulk-actions]\");\n\n function updateUI() {\n const checked = scope.querySelectorAll(\"input[data-bulk-item]:checked\");\n if (countEl) countEl.textContent = checked.length;\n if (actions) actions.classList.toggle(\"hidden\", checked.length === 0);\n\n const all = checkboxes();\n selectAll.checked = all.length > 0 && checked.length === all.length;\n selectAll.indeterminate = checked.length > 0 && checked.length < all.length;\n }\n\n let inSelectAll = false;\n selectAll.addEventListener(\"change\", () => {\n inSelectAll = true;\n checkboxes().forEach(cb => {\n cb.checked = selectAll.checked;\n // Dispatch change event so inline onchange handlers fire (e.g., hidden input sync)\n cb.dispatchEvent(new Event(\"change\", { bubbles: false }));\n });\n inSelectAll = false;\n updateUI();\n });\n\n scope.addEventListener(\"change\", (e) => {\n if (e.target.matches(\"input[data-bulk-item]\")) updateUI();\n });\n });\n\n}\ninitBulkSelect();\ndocument.addEventListener(\"turbo:load\", initBulkSelect);\ndocument.addEventListener(\"turbo:frame-load\", initBulkSelect);\n\n// -- Auto-refresh (pauses when user is interacting) --\nconst refreshInterval = parseInt(document.body?.dataset.pgbusRefreshInterval || \"0\", 10);\nif (refreshInterval > 0) {\n let timer;\n\n function hasUserInteraction() {\n // Pause when any checkbox is checked\n const checked = document.querySelector(\"input[data-bulk-item]:checked, input[data-bulk-select-all]:checked\");\n if (checked) return true;\n // Pause when any job detail row is expanded\n const openDetails = document.querySelector(\"turbo-frame[data-auto-refresh] details[open]\");\n if (openDetails) return true;\n return false;\n }\n\n function refreshFrames() {\n if (document.hidden) return;\n if (hasUserInteraction()) return;\n document.querySelectorAll(\"turbo-frame[data-auto-refresh]\")\n .forEach(frame => {\n try {\n if (!frame.src && frame.dataset.src) frame.src = frame.dataset.src;\n if (frame.src) frame.reload();\n } catch (_) { /* Turbo may abort in-flight fetches during navigation */ }\n });\n }\n function start() { timer = setInterval(refreshFrames, refreshInterval); }\n function stop() { clearInterval(timer); }\n document.addEventListener(\"visibilitychange\", () => document.hidden ? stop() : start());\n start();\n}\n"],"mappings":"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","names":[]}