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
@@ -1,21 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "time"
4
-
5
3
  module Pgbus
6
4
  module ApplicationHelper
7
- def pgbus_time_ago(time)
8
- return "—" unless time
5
+ # A moment as "5m ago" / "in 2h" / "now", with the exact value in the
6
+ # tooltip and the UTC value in datetime (issue #497). clock: true adds
7
+ # the wall-clock time: "in 2h (21:40)".
8
+ def pgbus_time(value, clock: false)
9
+ return Web::TimeFormat::NONE if value.blank?
9
10
 
10
- time = Time.parse(time) if time.is_a?(String)
11
- seconds = (Time.now - time).to_i
11
+ relative = Web::TimeFormat.relative(value)
12
+ return value.to_s unless relative
12
13
 
13
- case seconds
14
- when 0..59 then "#{seconds}s ago"
15
- when 60..3599 then "#{seconds / 60}m ago"
16
- when 3600..86_399 then "#{seconds / 3600}h ago"
17
- else "#{seconds / 86_400}d ago"
18
- end
14
+ text = clock ? "#{relative} (#{Web::TimeFormat.clock(value)})" : relative
15
+ tag.time(text, datetime: Web::TimeFormat.iso(value), title: Web::TimeFormat.absolute(value))
16
+ end
17
+
18
+ # The exact moment, then the relative one: for expanded rows and show
19
+ # pages, where the absolute is what an operator debugs with.
20
+ def pgbus_timestamp(value)
21
+ return Web::TimeFormat::NONE if value.blank?
22
+ return value.to_s unless Web::TimeFormat.coerce(value)
23
+
24
+ safe_join([pgbus_absolute_time(value), " (#{Web::TimeFormat.relative(value)})"])
25
+ end
26
+
27
+ def pgbus_absolute_time(value)
28
+ return Web::TimeFormat::NONE if value.blank?
29
+ return value.to_s unless Web::TimeFormat.coerce(value)
30
+
31
+ tag.time(Web::TimeFormat.absolute(value), datetime: Web::TimeFormat.iso(value))
19
32
  end
20
33
 
21
34
  def pgbus_number(n)
@@ -29,43 +42,6 @@ module Pgbus
29
42
  end
30
43
  end
31
44
 
32
- # Order the rate keys are rendered in, and their i18n label suffixes.
33
- WORKER_RATE_KEYS = %w[processed failed dequeued].freeze
34
-
35
- # Metadata keys rendered specially elsewhere (throughput badge, health
36
- # status) and therefore excluded from the generic key/value dump.
37
- WORKER_INTERNAL_METADATA_KEYS = %w[rates jobs_processed jobs_failed in_flight loop_tick_at].freeze
38
-
39
- # The subset of a process's metadata to render as generic key/value badges:
40
- # everything except the throughput/health keys that have dedicated rendering.
41
- def pgbus_display_metadata(metadata)
42
- return {} unless metadata.is_a?(Hash)
43
-
44
- metadata.reject { |k, _| WORKER_INTERNAL_METADATA_KEYS.include?(k.to_s) }
45
- end
46
-
47
- # Render a worker's per-second throughput rates (from heartbeat metadata)
48
- # as a compact human-readable string, e.g. "12.4/s processed · 0.2/s failed".
49
- # Zero rates are omitted; returns nil when there are no non-zero rates so
50
- # callers can fall back to the raw metadata rendering.
51
- def pgbus_worker_rates(metadata)
52
- return nil unless metadata.is_a?(Hash)
53
-
54
- rates = metadata["rates"]
55
- return nil unless rates.is_a?(Hash)
56
-
57
- parts = WORKER_RATE_KEYS.filter_map do |key|
58
- value = rates[key].to_f
59
- next if value.zero?
60
-
61
- label = I18n.t("pgbus.processes.processes_table.rates.#{key}", default: key)
62
- "#{value}/s #{label}"
63
- end
64
- return nil if parts.empty?
65
-
66
- parts.join(" · ")
67
- end
68
-
69
45
  def pgbus_status_badge(healthy_or_status)
70
46
  status = case healthy_or_status
71
47
  when true then :healthy
@@ -76,59 +52,45 @@ module Pgbus
76
52
  case status
77
53
  when :healthy
78
54
  tag.span(I18n.t("pgbus.helpers.status_badge.healthy"),
79
- class: "inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800")
55
+ class: "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium " \
56
+ "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400")
80
57
  when :stalled
81
58
  tag.span(I18n.t("pgbus.helpers.status_badge.stalled"),
82
- class: "inline-flex items-center rounded-full bg-yellow-100 px-2.5 py-0.5 text-xs font-medium text-yellow-800")
59
+ class: "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium " \
60
+ "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400")
83
61
  else
84
62
  tag.span(I18n.t("pgbus.helpers.status_badge.stale"),
85
- class: "inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800")
63
+ class: "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium " \
64
+ "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400")
86
65
  end
87
66
  end
88
67
 
89
68
  def pgbus_queue_badge(name)
90
69
  if name.to_s.end_with?("_dlq")
91
70
  tag.span(I18n.t("pgbus.helpers.queue_badge.dlq"),
92
- class: "inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700")
71
+ class: "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium " \
72
+ "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400")
93
73
  else
94
74
  tag.span(I18n.t("pgbus.helpers.queue_badge.queue"),
95
- class: "inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700")
75
+ class: "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium " \
76
+ "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400")
96
77
  end
97
78
  end
98
79
 
99
80
  def pgbus_duration(seconds)
100
- return "—" unless seconds
101
-
102
- seconds = seconds.to_i
103
- if seconds < 60
104
- "#{seconds}s"
105
- elsif seconds < 3600
106
- "#{seconds / 60}m #{seconds % 60}s"
107
- elsif seconds < 86_400
108
- "#{seconds / 3600}h #{(seconds % 3600) / 60}m"
109
- else
110
- "#{seconds / 86_400}d #{(seconds % 86_400) / 3600}h"
111
- end
81
+ Web::TimeFormat.duration(seconds)
112
82
  end
113
83
 
114
84
  def pgbus_ms_duration(millis)
115
- return "—" unless millis
116
-
117
- millis = millis.to_i
118
- if millis < 1000
119
- "#{millis}ms"
120
- elsif millis < 60_000
121
- "#{(millis / 1000.0).round(1)}s"
122
- else
123
- "#{(millis / 60_000.0).round(1)}m"
124
- end
85
+ Web::TimeFormat.ms_duration(millis)
125
86
  end
126
87
 
127
88
  def pgbus_paused_badge(paused)
128
89
  return unless paused
129
90
 
130
91
  tag.span(I18n.t("pgbus.helpers.paused_badge"),
131
- class: "inline-flex items-center rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800")
92
+ class: "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium " \
93
+ "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400")
132
94
  end
133
95
 
134
96
  BATCH_BADGE_BASE = "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
@@ -143,6 +105,117 @@ module Pgbus
143
105
  tag.span(I18n.t("pgbus.helpers.batch_status.#{status}", default: status), class: css)
144
106
  end
145
107
 
108
+ JOB_STATE_BADGE_CSS = {
109
+ blue: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
110
+ gray: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300",
111
+ indigo: "bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300",
112
+ yellow: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400",
113
+ purple: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400",
114
+ green: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
115
+ }.freeze
116
+
117
+ def pgbus_job_state_badge(state)
118
+ tone = Pgbus::Web::JobState::BADGE_TONES.fetch(state.to_s, :gray)
119
+ tag.span(I18n.t("pgbus.jobs.list.states.#{state}"), class: "#{BATCH_BADGE_BASE} #{JOB_STATE_BADGE_CSS[tone]}")
120
+ end
121
+
122
+ # A JobState::Result's reason in words. :time is a future moment
123
+ # ("in 2h (21:40)"), :ago a past one ("5m ago"), both as <time>. The
124
+ # _html key lets them through; translate escapes every other argument.
125
+ def pgbus_job_reason(result)
126
+ translate("pgbus.jobs.list.reasons.#{result.reason_key}_html", **pgbus_reason_args(result.reason_args))
127
+ end
128
+
129
+ # Reason arguments ready for an _html key: :time (future) and :ago (past)
130
+ # as <time>, everything else as given for translate to escape.
131
+ def pgbus_reason_args(args)
132
+ args.to_h do |key, value|
133
+ case key
134
+ when :time then [key, pgbus_time(value, clock: true)]
135
+ when :ago then [key, pgbus_time(value)]
136
+ else [key, value]
137
+ end
138
+ end
139
+ end
140
+
141
+ # A QueueSummary::Line in words. :ago is a past moment and :time a future
142
+ # one, both as <time>; :age is a wait in seconds. The _html key lets the
143
+ # <time> elements through; translate escapes every other argument.
144
+ def pgbus_queue_summary_line(line)
145
+ args = line.args.to_h do |key, value|
146
+ case key
147
+ when :ago then [key, pgbus_time(value)]
148
+ when :time then [key, pgbus_time(value, clock: true)]
149
+ when :age then [key, pgbus_duration(value)]
150
+ else [key, value]
151
+ end
152
+ end
153
+ translate("pgbus.queues.show.summary.#{line.key}_html", **args)
154
+ end
155
+
156
+ QUEUE_SUMMARY_TONE_CSS = {
157
+ yellow: "bg-yellow-50 text-yellow-800 ring-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-200 dark:ring-yellow-800",
158
+ red: "bg-red-50 text-red-800 ring-red-200 dark:bg-red-900/30 dark:text-red-200 dark:ring-red-800",
159
+ gray: "bg-gray-50 text-gray-700 ring-gray-200 dark:bg-gray-900/40 dark:text-gray-200 dark:ring-gray-700"
160
+ }.freeze
161
+
162
+ def pgbus_queue_summary_classes(tone)
163
+ QUEUE_SUMMARY_TONE_CSS.fetch(tone, QUEUE_SUMMARY_TONE_CSS[:gray])
164
+ end
165
+
166
+ # "2/5" for a job's delivery attempts against max_retries; "—" when the
167
+ # row was never delivered (blocked).
168
+ def pgbus_job_attempts(row, max_retries)
169
+ return "—" if row[:read_ct].nil?
170
+
171
+ "#{row[:read_ct]}/#{max_retries}"
172
+ end
173
+
174
+ # A DeadLetterReason::Result in words (issue #495). With filter_path
175
+ # (->(extra) { url }) the error class becomes a link that filters the list
176
+ # to it. The _html keys let that link through; translate escapes the
177
+ # error message and every other argument.
178
+ def pgbus_dead_letter_reason(result, filter_path: nil)
179
+ args = result.reason_args.dup
180
+ if args[:error_class] && filter_path
181
+ args[:error_class] = pgbus_link_to(
182
+ result.error_class, filter_path.call(error_class: result.error_class, page: nil),
183
+ title: t("pgbus.dead_letter.filters.filter_error_class", error_class: result.error_class),
184
+ data: { turbo_frame: "_top" }
185
+ )
186
+ end
187
+ sentence = translate("pgbus.dead_letter.reasons.#{result.reason_key}_html", **args)
188
+ return sentence unless result.retried_before.to_i.positive?
189
+
190
+ note = t("pgbus.dead_letter.reasons.retried_before", count: result.retried_before)
191
+ safe_join([sentence, tag.span(note, class: "mt-1 block text-xs text-gray-600 dark:text-gray-300")])
192
+ end
193
+
194
+ def pgbus_dead_letter_attempts(result)
195
+ return "—" if result.attempts.nil?
196
+
197
+ "#{result.attempts}/#{result.max_retries}"
198
+ end
199
+
200
+ # The Job column: an ActiveJob's class, or a dead event's routing key.
201
+ # Reads the raw message like the Events page: both are identifiers, and
202
+ # PayloadFilter's default /_key$/ pattern would mask routing_key.
203
+ def pgbus_dead_letter_job(message)
204
+ payload = message.is_a?(String) ? JSON.parse(message) : message
205
+ return "—" unless payload.is_a?(Hash)
206
+
207
+ headers = payload["headers"]
208
+ payload["job_class"].presence || (headers["routing_key"].presence if headers.is_a?(Hash)) ||
209
+ payload["routing_key"].presence || "—"
210
+ rescue JSON::ParserError
211
+ "—"
212
+ end
213
+
214
+ # A tab's count, with "+" when a capped fragment made it a lower bound.
215
+ def pgbus_job_count(counts, state)
216
+ "#{number_with_delimiter(counts[state])}#{"+" if counts.capped?(state)}"
217
+ end
218
+
146
219
  # Persisted Current attributes for a job payload, as
147
220
  # { "Current" => { "tenant" => "gid://…", ... } } or nil (issue #430).
148
221
  # Goes through pgbus_parse_message so PayloadFilter redaction applies.
@@ -175,45 +248,20 @@ module Pgbus
175
248
  Pgbus.configuration.web_refresh_interval
176
249
  end
177
250
 
178
- def pgbus_time_ago_future(time)
179
- return "—" unless time
180
-
181
- time = Time.parse(time) if time.is_a?(String)
182
- seconds = (time - Time.now).to_i
183
-
184
- if seconds <= 0
185
- "now"
186
- elsif seconds < 60
187
- "in #{seconds}s"
188
- elsif seconds < 3600
189
- "in #{seconds / 60}m"
190
- elsif seconds < 86_400
191
- "in #{seconds / 3600}h"
192
- else
193
- "in #{seconds / 86_400}d"
194
- end
195
- end
196
-
197
251
  def pgbus_recurring_health_badge(task)
198
252
  if task[:last_run_at].nil?
199
253
  tag.span(I18n.t("pgbus.helpers.recurring_health.pending"),
200
- class: "inline-flex items-center rounded-full bg-yellow-100 px-2.5 py-0.5 text-xs font-medium text-yellow-800")
254
+ class: "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium " \
255
+ "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400")
201
256
  else
202
257
  tag.span(I18n.t("pgbus.helpers.recurring_health.active"),
203
- class: "inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800")
258
+ class: "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium " \
259
+ "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400")
204
260
  end
205
261
  end
206
262
 
207
263
  def pgbus_time_range_label(minutes)
208
- minutes = [minutes.to_i, 1].max
209
-
210
- if minutes > 1440 && (minutes % 1440).zero?
211
- pgbus_pluralize_unit(minutes / 1440, "day")
212
- elsif minutes >= 60 && (minutes % 60).zero?
213
- pgbus_pluralize_unit(minutes / 60, "hour")
214
- else
215
- pgbus_pluralize_unit(minutes, "minute")
216
- end
264
+ Web::TimeFormat.range_label(minutes)
217
265
  end
218
266
 
219
267
  def pgbus_nav_link(label, path)
@@ -272,11 +320,5 @@ module Pgbus
272
320
  else "\u{1F310}"
273
321
  end
274
322
  end
275
-
276
- private
277
-
278
- def pgbus_pluralize_unit(count, unit)
279
- count == 1 ? "1 #{unit}" : "#{count} #{unit}s"
280
- end
281
323
  end
282
324
  end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ # Every button and action link on the dashboard (issue #496): one class table
5
+ # with light and dark colours, a focus ring, and a 24 px minimum target
6
+ # (WCAG 2.5.8) in every size. Class lists are spelled out in full so Tailwind
7
+ # and the static specs (dark_mode_contrast_spec, compiled_css_coverage_spec)
8
+ # can see every token; never interpolate a colour name.
9
+ module ButtonHelper
10
+ BASE = "inline-flex items-center min-h-6 font-medium focus-visible:outline-2 focus-visible:outline-offset-2 " \
11
+ "focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-400"
12
+
13
+ VARIANTS = {
14
+ # Hover goes darker, never lighter: white on red-500 is 3.8:1, on
15
+ # green-600 3.3:1, both under 4.5:1 for these small labels.
16
+ primary: "bg-indigo-600 text-white hover:bg-indigo-700",
17
+ danger: "bg-red-600 text-white hover:bg-red-700",
18
+ # Permanent and irreversible (Delete queue), set apart from danger.
19
+ destructive: "bg-red-800 text-white hover:bg-red-900",
20
+ success: "bg-green-700 text-white hover:bg-green-800",
21
+ # White on yellow fails; yellow buttons carry dark text in both themes.
22
+ warning: "bg-yellow-400 text-yellow-950 hover:bg-yellow-300",
23
+ secondary: "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600",
24
+ # Bordered, on the card's own surface (the pager).
25
+ outline: "border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 " \
26
+ "hover:bg-gray-50 dark:hover:bg-gray-700",
27
+ # A row in a dropdown menu (the Events reroute picker).
28
+ menu: "w-full justify-start text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700",
29
+ link: nil
30
+ }.freeze
31
+
32
+ # Link colours. Dark text is -300: 12 px row actions sit on the hovered row
33
+ # tint (dark:hover:bg-gray-700/50), where -400 drops below 4.5:1.
34
+ TONES = {
35
+ indigo: "text-indigo-600 dark:text-indigo-300 hover:text-indigo-800 dark:hover:text-indigo-200",
36
+ red: "text-red-600 dark:text-red-300 hover:text-red-800 dark:hover:text-red-200",
37
+ green: "text-green-700 dark:text-green-300 hover:text-green-900 dark:hover:text-green-200",
38
+ yellow: "text-yellow-700 dark:text-yellow-300 hover:text-yellow-900 dark:hover:text-yellow-200"
39
+ }.freeze
40
+
41
+ SIZES = {
42
+ sm: "rounded-md px-3 py-1.5 text-xs",
43
+ md: "rounded-md px-3 py-2 text-sm"
44
+ }.freeze
45
+
46
+ # Links keep their type size; the padding on sm gives the 24 px target.
47
+ LINK_SIZES = {
48
+ sm: "rounded px-1.5 py-1 text-xs",
49
+ md: "text-sm"
50
+ }.freeze
51
+
52
+ def pgbus_button_classes(variant, size = :md, tone: :indigo, extra: nil)
53
+ raise ArgumentError, "unknown button variant: #{variant.inspect}" unless VARIANTS.key?(variant)
54
+ raise ArgumentError, "unknown button size: #{size.inspect}" unless SIZES.key?(size)
55
+
56
+ colours = variant == :link ? TONES.fetch(tone) { raise ArgumentError, "unknown link tone: #{tone.inspect}" } : VARIANTS[variant]
57
+ sizing = variant == :link ? LINK_SIZES[size] : SIZES[size]
58
+ [BASE, sizing, colours, extra].compact.join(" ")
59
+ end
60
+
61
+ def pgbus_button_to(label, path, variant: :primary, size: :md, tone: :indigo, confirm: nil, **options)
62
+ options[:class] = pgbus_button_classes(variant, size, tone: tone, extra: options[:class])
63
+ options[:data] = pgbus_confirm_data(options[:data], confirm)
64
+ button_to(label, path, options)
65
+ end
66
+
67
+ def pgbus_link_to(label, path, variant: :link, size: :md, tone: :indigo, **options)
68
+ options[:class] = pgbus_button_classes(variant, size, tone: tone, extra: options[:class])
69
+ link_to(label, path, options)
70
+ end
71
+
72
+ # A submit button outside its form (form: "bulk-…"), for the bulk actions.
73
+ def pgbus_button_tag(label, variant: :primary, size: :md, tone: :indigo, confirm: nil, **options)
74
+ options[:class] = pgbus_button_classes(variant, size, tone: tone, extra: options[:class])
75
+ options[:data] = pgbus_confirm_data(options[:data], confirm)
76
+ content_tag(:button, label, type: "submit", **options)
77
+ end
78
+
79
+ private
80
+
81
+ # application.js's confirm dialog reads data-turbo-confirm.
82
+ def pgbus_confirm_data(data, confirm)
83
+ data = (data || {}).dup
84
+ data[:turbo_confirm] = confirm if confirm
85
+ data.presence
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ # The home page's words (issue #492): the health verdict strip, the card
5
+ # sentences built from DataSource#dashboard_signals, the queue rows'
6
+ # QueueSummary line and the Queue Health thresholds from HealthVerdict.
7
+ module DashboardHelper
8
+ PART_SEPARATOR = " · "
9
+
10
+ # Every home card is a link to the page that explains it.
11
+ CARD_CSS = "block rounded-lg bg-white dark:bg-gray-800 p-5 shadow ring-1 ring-gray-200 dark:ring-gray-700 " \
12
+ "hover:ring-gray-300 dark:hover:ring-gray-600"
13
+ CARD_NUMBER_CSS = "mt-1 text-3xl font-semibold text-gray-900 dark:text-white"
14
+
15
+ VERDICT_CSS = {
16
+ "OK" => "bg-green-50 text-green-800 ring-green-200 dark:bg-green-900/30 dark:text-green-200 dark:ring-green-800",
17
+ "DEGRADED" => "bg-yellow-50 text-yellow-800 ring-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-200 " \
18
+ "dark:ring-yellow-800",
19
+ "STALLED" => "bg-red-50 text-red-800 ring-red-200 dark:bg-red-900/30 dark:text-red-200 dark:ring-red-800"
20
+ }.freeze
21
+ VERDICT_UNKNOWN_CSS = "bg-gray-50 text-gray-700 ring-gray-200 dark:bg-gray-900/40 dark:text-gray-200 dark:ring-gray-700"
22
+
23
+ # Stat numbers are text-3xl, so the -600 shades meet the 3:1 large-text ratio.
24
+ HEALTH_NUMBER_CSS = {
25
+ ok: "mt-1 text-3xl font-semibold text-green-600 dark:text-green-400",
26
+ warn: "mt-1 text-3xl font-semibold text-amber-600 dark:text-amber-400",
27
+ critical: "mt-1 text-3xl font-semibold text-red-600 dark:text-red-400",
28
+ unknown: "mt-1 text-3xl font-semibold text-gray-900 dark:text-white"
29
+ }.freeze
30
+ HEALTH_WORDS_CSS = {
31
+ ok: "text-xs text-gray-600 dark:text-gray-300",
32
+ warn: "text-xs text-amber-700 dark:text-amber-300",
33
+ critical: "text-xs text-red-700 dark:text-red-300",
34
+ unknown: "text-xs text-gray-600 dark:text-gray-300"
35
+ }.freeze
36
+ QUEUE_STATUS_CSS = {
37
+ red: "text-red-700 dark:text-red-300",
38
+ yellow: "text-yellow-800 dark:text-yellow-200",
39
+ gray: "text-gray-600 dark:text-gray-300"
40
+ }.freeze
41
+
42
+ def pgbus_verdict_css(verdict)
43
+ VERDICT_CSS.fetch(verdict&.dig(:status).to_s, VERDICT_UNKNOWN_CSS)
44
+ end
45
+
46
+ # Where the verdict's reasons are explained, from the signals rather than
47
+ # the (English, unlocalised) reason strings.
48
+ def pgbus_verdict_links(signals)
49
+ processes = signals[:processes]
50
+ links = []
51
+ if (processes[:stalled] + processes[:stale]).positive?
52
+ links << pgbus_link_to(t("pgbus.dashboard.verdict.links.processes"), pgbus.processes_path,
53
+ data: { turbo_frame: "_top" })
54
+ end
55
+ if signals[:dlq][:depth].positive?
56
+ links << pgbus_link_to(t("pgbus.dashboard.verdict.links.dlq"), pgbus.dead_letter_index_path, data: { turbo_frame: "_top" })
57
+ end
58
+ links
59
+ end
60
+
61
+ def pgbus_queue_card_words(queues)
62
+ parts = [t("pgbus.dashboard.stats_cards.queues_detail", paused: queues[:paused], dlq: queues[:dlq])]
63
+ parts << t("pgbus.dashboard.stats_cards.queues_undrained", count: queues[:undrained]) if queues[:undrained].positive?
64
+ safe_join(parts, PART_SEPARATOR)
65
+ end
66
+
67
+ def pgbus_enqueued_card_words(queues)
68
+ parts = [t("pgbus.dashboard.stats_cards.enqueued_detail", claimable: number_with_delimiter(queues[:claimable]),
69
+ waiting: number_with_delimiter(queues[:waiting]))]
70
+ parts << t("pgbus.dashboard.stats_cards.enqueued_paused", count: queues[:paused_depth]) if queues[:paused_depth].positive?
71
+ safe_join(parts, PART_SEPARATOR)
72
+ end
73
+
74
+ def pgbus_process_card_words(processes)
75
+ return t("pgbus.dashboard.stats_cards.processes_none") if processes[:total].zero?
76
+
77
+ words = t("pgbus.dashboard.stats_cards.processes_detail", **processes.slice(:healthy, :stalled, :stale))
78
+ return words unless processes[:stale].positive?
79
+
80
+ safe_join([words, t("pgbus.dashboard.stats_cards.processes_stale")], " — ")
81
+ end
82
+
83
+ def pgbus_retrying_card_words(retrying)
84
+ return t("pgbus.dashboard.stats_cards.retrying_none") if retrying[:count].zero?
85
+
86
+ parts = []
87
+ if retrying[:next_attempt_at]
88
+ parts << t("pgbus.dashboard.stats_cards.retrying_next_html", time: pgbus_time(retrying[:next_attempt_at], clock: true))
89
+ end
90
+ if retrying[:due_now_count].positive?
91
+ # Past the per-queue cap the due count is a lower bound, like the total.
92
+ due = retrying[:capped] ? "#{number_with_delimiter(retrying[:due_now_count])}+" : retrying[:due_now_count]
93
+ parts << t("pgbus.dashboard.stats_cards.retrying_due", count: due)
94
+ end
95
+ parts << t("pgbus.dashboard.stats_cards.retrying_newest", error_class: retrying[:newest_error_class]) if retrying[:newest_error_class]
96
+ safe_join(parts, PART_SEPARATOR)
97
+ end
98
+
99
+ def pgbus_retrying_count(retrying)
100
+ "#{number_with_delimiter(retrying[:count])}#{"+" if retrying[:capped]}"
101
+ end
102
+
103
+ # The newest dead letter's reason, read as the DLQ page reads it (#495).
104
+ def pgbus_dlq_card_words(dlq)
105
+ return t("pgbus.dashboard.stats_cards.dlq_none") if dlq[:depth].zero?
106
+ return t("pgbus.dashboard.stats_cards.dlq_see_why") unless dlq[:newest]
107
+
108
+ reason = Web::DeadLetterReason.present(dlq[:newest])
109
+ args = { job: pgbus_dead_letter_job(dlq[:newest][:message]), ago: pgbus_time(reason.died_at) }
110
+ return t("pgbus.dashboard.stats_cards.dlq_newest_legacy_html", **args) if reason.legacy?
111
+ return t("pgbus.dashboard.stats_cards.dlq_newest_no_error_html", **args) unless reason.error_class
112
+
113
+ t("pgbus.dashboard.stats_cards.dlq_newest_html", error_class: reason.error_class, **args)
114
+ end
115
+
116
+ def pgbus_queue_status(line)
117
+ return Web::TimeFormat::NONE unless line
118
+
119
+ tag.span(pgbus_queue_summary_line(line), class: QUEUE_STATUS_CSS.fetch(line.tone, QUEUE_STATUS_CSS[:gray]))
120
+ end
121
+
122
+ # A HealthVerdict::Entry in words. Bloat thresholds are ratios, the
123
+ # vacuum and transaction ones seconds.
124
+ def pgbus_health_words(entry)
125
+ percent = entry.key.start_with?("bloat.")
126
+ args = entry.args.to_h do |key, value|
127
+ case key
128
+ when :table then [key, value]
129
+ when :count, :limit then [key, number_with_delimiter(value)]
130
+ else [key, percent ? pgbus_percent(value) : pgbus_duration(value)]
131
+ end
132
+ end
133
+ tag.p(t("pgbus.dashboard.queue_health.words.#{entry.key}", **args), class: HEALTH_WORDS_CSS.fetch(entry.level))
134
+ end
135
+
136
+ def pgbus_health_number_css(entry) = HEALTH_NUMBER_CSS.fetch(entry.level)
137
+
138
+ def pgbus_percent(ratio)
139
+ number_to_percentage(ratio.to_f * 100, precision: 1, strip_insignificant_zeros: true)
140
+ end
141
+
142
+ # "4 / 5" for a failed row: the attempt that failed against max_retries.
143
+ def pgbus_failure_attempt(row)
144
+ "#{row["retry_count"].to_i + 1} / #{Pgbus.configuration.max_retries}"
145
+ end
146
+
147
+ # The payload arrives as a JSON string or, when the column is typecast,
148
+ # an already-parsed Hash.
149
+ def pgbus_failure_job(row)
150
+ payload = row["payload"]
151
+ payload = JSON.parse(payload.to_s) unless payload.is_a?(Hash)
152
+ (payload["job_class"] if payload.is_a?(Hash)).presence || row["handler_class"].presence || Web::TimeFormat::NONE
153
+ rescue JSON::ParserError
154
+ row["handler_class"].presence || Web::TimeFormat::NONE
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ # The Events page's words (issue #494): EventState results as badges and
5
+ # sentences, in the jobs list's vocabulary and colours.
6
+ module EventsHelper
7
+ def pgbus_event_state_badge(state)
8
+ tone = Pgbus::Web::JobState::BADGE_TONES.fetch(state.to_s, :gray)
9
+ tag.span(t("pgbus.events.list.states.#{state}"),
10
+ class: "#{ApplicationHelper::BATCH_BADGE_BASE} #{ApplicationHelper::JOB_STATE_BADGE_CSS[tone]}")
11
+ end
12
+
13
+ # An EventState::Result in words. The _html key lets the <time> elements
14
+ # through; translate escapes the handler, pattern and error.
15
+ def pgbus_event_reason(result)
16
+ translate("pgbus.events.list.reasons.#{result.reason_key}_html", **pgbus_reason_args(result.reason_args))
17
+ end
18
+
19
+ def pgbus_processed_event_badge(result)
20
+ tag.span(t("pgbus.events.processed.states.#{result.state}"),
21
+ class: "#{ApplicationHelper::BATCH_BADGE_BASE} #{ApplicationHelper::JOB_STATE_BADGE_CSS[result.badge_tone]}")
22
+ end
23
+
24
+ def pgbus_processed_event_reason(result)
25
+ translate("pgbus.events.processed.reasons.#{result.reason_key}_html", **pgbus_reason_args(result.reason_args))
26
+ end
27
+
28
+ # The routing key from the raw message: PayloadFilter's default /_key$/
29
+ # pattern would mask it in the parsed (filtered) payload.
30
+ def pgbus_event_routing_key(message)
31
+ payload = message.is_a?(String) ? JSON.parse(message) : message
32
+ return "—" unless payload.is_a?(Hash)
33
+
34
+ headers = payload["headers"]
35
+ (headers["routing_key"].presence if headers.is_a?(Hash)) || payload["routing_key"].presence || "—"
36
+ rescue JSON::ParserError, TypeError
37
+ "—"
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ # The Insights charts' words (issue #492): one sentence per chart, used as
5
+ # its figcaption and as the chart's accessible name.
6
+ module InsightsHelper
7
+ def pgbus_job_stat_status(status)
8
+ t("pgbus.insights.show.statuses.#{status}", default: status.to_s.humanize)
9
+ end
10
+
11
+ def pgbus_throughput_caption(chart, minutes)
12
+ t("pgbus.insights.show.charts.captions.throughput_html",
13
+ total: number_with_delimiter(chart.total), range: pgbus_time_range_label(minutes),
14
+ peak: number_with_delimiter(chart.peak), at: pgbus_time(chart.peak_at, clock: true))
15
+ end
16
+
17
+ def pgbus_status_caption(chart)
18
+ t("pgbus.insights.show.charts.captions.status", success: number_with_delimiter(chart[:success]),
19
+ failed: number_with_delimiter(chart[:failed]),
20
+ dead_lettered: number_with_delimiter(chart[:dead_lettered]))
21
+ end
22
+
23
+ def pgbus_latency_caption(summary)
24
+ t("pgbus.insights.show.charts.captions.latency", avg: pgbus_ms_duration(summary[:avg_latency_ms]),
25
+ p95: pgbus_ms_duration(summary[:p95_latency_ms]))
26
+ end
27
+
28
+ # The labels charts.js puts on the series and the donut legend.
29
+ def pgbus_chart_i18n
30
+ {
31
+ seriesName: t("pgbus.insights.show.charts.series_name"),
32
+ latencyAvg: t("pgbus.insights.show.charts.latency_avg"),
33
+ latencyP95: t("pgbus.insights.show.charts.latency_p95"),
34
+ milliseconds: t("pgbus.helpers.time.units.milliseconds", count: "%n"),
35
+ statuses: Web::InsightsCharts::STATUS_ORDER.index_with { |status| pgbus_job_stat_status(status) }
36
+ }
37
+ end
38
+ end
39
+ end