cosmonats 0.5.1 → 0.7.0

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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +21 -169
  3. data/README.md +102 -18
  4. data/lib/cosmo/api/counter.rb +14 -11
  5. data/lib/cosmo/api/cron/entry.rb +23 -3
  6. data/lib/cosmo/api/cron.rb +22 -26
  7. data/lib/cosmo/api/job.rb +30 -0
  8. data/lib/cosmo/api/kv.rb +31 -11
  9. data/lib/cosmo/api/registry.rb +68 -0
  10. data/lib/cosmo/api/stats/busy.rb +63 -0
  11. data/lib/cosmo/api/stats/processes.rb +37 -0
  12. data/lib/cosmo/api/stats/totals.rb +35 -0
  13. data/lib/cosmo/api/stats.rb +5 -4
  14. data/lib/cosmo/api/stream.rb +1 -1
  15. data/lib/cosmo/api.rb +3 -2
  16. data/lib/cosmo/batch.rb +9 -2
  17. data/lib/cosmo/cli.rb +27 -7
  18. data/lib/cosmo/client.rb +69 -6
  19. data/lib/cosmo/config.rb +6 -2
  20. data/lib/cosmo/engine.rb +103 -4
  21. data/lib/cosmo/heartbeat.rb +129 -0
  22. data/lib/cosmo/http_server/handler.rb +44 -0
  23. data/lib/cosmo/http_server/health.rb +39 -0
  24. data/lib/cosmo/http_server/ping.rb +23 -0
  25. data/lib/cosmo/http_server.rb +62 -0
  26. data/lib/cosmo/job/failure.rb +52 -0
  27. data/lib/cosmo/job/processor.rb +87 -25
  28. data/lib/cosmo/job/stream_filter.rb +54 -0
  29. data/lib/cosmo/job.rb +2 -0
  30. data/lib/cosmo/logger.rb +21 -3
  31. data/lib/cosmo/processor.rb +46 -16
  32. data/lib/cosmo/sentry/job_processor_middleware.rb +1 -2
  33. data/lib/cosmo/stream/message.rb +2 -1
  34. data/lib/cosmo/stream/processor.rb +5 -0
  35. data/lib/cosmo/utils/json.rb +1 -1
  36. data/lib/cosmo/utils/overrides.rb +199 -8
  37. data/lib/cosmo/utils/thread_pool.rb +5 -0
  38. data/lib/cosmo/version.rb +1 -1
  39. data/lib/cosmo/web/assets/app.css +21 -0
  40. data/lib/cosmo/web/controllers/jobs.rb +28 -4
  41. data/lib/cosmo/web/helpers/application.rb +10 -1
  42. data/lib/cosmo/web/views/batches/index.erb +1 -1
  43. data/lib/cosmo/web/views/jobs/_busy.erb +70 -4
  44. data/lib/cosmo/web/views/jobs/_dead.erb +29 -16
  45. data/lib/cosmo/web/views/jobs/_enqueued.erb +10 -0
  46. data/lib/cosmo/web/views/jobs/_processes.erb +131 -0
  47. data/lib/cosmo/web/views/jobs/_scheduled.erb +1 -1
  48. data/lib/cosmo/web/views/jobs/busy.erb +1 -1
  49. data/lib/cosmo/web/views/jobs/dead.erb +2 -2
  50. data/lib/cosmo/web/views/jobs/enqueued.erb +1 -1
  51. data/lib/cosmo/web/views/jobs/index.erb +7 -1
  52. data/lib/cosmo/web/views/jobs/scheduled.erb +1 -1
  53. data/lib/cosmo/web/views/streams/index.erb +1 -1
  54. data/lib/cosmo/web.rb +2 -0
  55. data/lib/cosmo.rb +13 -0
  56. data/sig/cosmo/api/counter.rbs +2 -4
  57. data/sig/cosmo/api/cron/entry.rbs +2 -0
  58. data/sig/cosmo/api/cron.rbs +4 -2
  59. data/sig/cosmo/api/job.rbs +12 -0
  60. data/sig/cosmo/api/kv.rbs +7 -1
  61. data/sig/cosmo/api/registry.rbs +30 -0
  62. data/sig/cosmo/api/stats/busy.rbs +31 -0
  63. data/sig/cosmo/api/stats/processes.rbs +16 -0
  64. data/sig/cosmo/api/stats/totals.rbs +17 -0
  65. data/sig/cosmo/cli.rbs +4 -0
  66. data/sig/cosmo/client.rbs +17 -2
  67. data/sig/cosmo/engine.rbs +29 -0
  68. data/sig/cosmo/heartbeat.rbs +36 -0
  69. data/sig/cosmo/http_server/handler.rbs +17 -0
  70. data/sig/cosmo/http_server/health.rbs +21 -0
  71. data/sig/cosmo/http_server/ping.rbs +14 -0
  72. data/sig/cosmo/http_server.rbs +22 -0
  73. data/sig/cosmo/job/failure.rbs +28 -0
  74. data/sig/cosmo/job/processor.rbs +17 -1
  75. data/sig/cosmo/job/stream_filter.rbs +25 -0
  76. data/sig/cosmo/logger.rbs +10 -0
  77. data/sig/cosmo/processor.rbs +12 -4
  78. data/sig/cosmo/stream/message.rbs +3 -1
  79. data/sig/cosmo/stream/processor.rbs +2 -0
  80. data/sig/cosmo/utils/thread_pool.rbs +3 -0
  81. data/sig/cosmo.rbs +4 -0
  82. metadata +33 -20
  83. data/lib/cosmo/api/busy.rb +0 -66
  84. data/sig/cosmo/api/busy.rbs +0 -35
@@ -1,15 +1,206 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  Cosmo::Utils::Warnings.silence do
4
- members = NATS::JetStream::API::StreamConfig.members + %i[allow_msg_counter allow_msg_schedules]
5
- NATS::JetStream::API::StreamConfig = Struct.new(*members, keyword_init: true) do
6
- def initialize(opts = {})
7
- rem = opts.keys - members
8
- opts.delete_if { |k| rem.include?(k) }
9
- super
4
+ unless NATS::JetStream::API::StreamConfig.members.include?(:allow_msg_schedules)
5
+ members = NATS::JetStream::API::StreamConfig.members + %i[allow_msg_counter allow_msg_schedules]
6
+ NATS::JetStream::API::StreamConfig = Struct.new(*members, keyword_init: true) do
7
+ def initialize(opts = {})
8
+ rem = opts.keys - members
9
+ opts.delete_if { |k| rem.include?(k) }
10
+ super
11
+ end
10
12
  end
11
13
  end
12
14
 
13
- members = NATS::JetStream::PubAck.members + [:val]
14
- NATS::JetStream::PubAck = Struct.new(*members, keyword_init: true)
15
+ unless NATS::JetStream::PubAck.members.include?(:val)
16
+ members = NATS::JetStream::PubAck.members + [:val]
17
+ NATS::JetStream::PubAck = Struct.new(*members, keyword_init: true)
18
+ end
19
+ end
20
+
21
+ # Upstream bug in nats-pure 2.5.0 and 2.6.0 (https://github.com/nats-io/nats-pure.rb):
22
+ # NATS::JetStream::PullSubscription#fetch only assigns its `start_time` local in the
23
+ # `batch > 1` branch, but the final "did we time out" check outside the case statement
24
+ # reads it unconditionally. For `batch == 1` (what Cosmo::Processor always uses),
25
+ # `start_time` is nil there -- normally masked because the `batch == 1` branch has its
26
+ # own timeout check (using a properly-set `t`) that raises first.
27
+ #
28
+ # It only surfaces once two threads call #fetch concurrently on the *same* subscription:
29
+ # @pending_queue/wait_for_msgs_cond are shared per-subscription state, and #dispatch's
30
+ # `wait_for_msgs_cond.signal` wakes exactly one arbitrary waiter -- not necessarily the
31
+ # one whose own request produced that wakeup. A thread woken by someone else's signal
32
+ # finds nothing left in the (already-drained) queue, so it skips its own inner timeout
33
+ # check and falls into the buggy tail check, raising `TypeError: nil can't be coerced
34
+ # into Float` instead of the intended timeout/empty result. Reproduced deterministically
35
+ # with N threads fetching in a loop against the same PullSubscription.
36
+ #
37
+ # Second, separate upstream bug in the same method: the post-wait timeout check below
38
+ # (`raise ... if MonotonicTime.since(t) > timeout`) fires unconditionally, unlike every
39
+ # other timeout check in this method (all guarded with `msgs.empty? &&`). If the unsynchronized
40
+ # pop just above it succeeded in grabbing a real, already-delivered message -- which can happen
41
+ # even after `timeout` has technically elapsed, since #dispatch's signal and this thread actually
42
+ # resuming are two different moments -- that message is already gone from @pending_queue with
43
+ # nowhere else to go, yet gets discarded here and a plain NATS::Timeout raised instead. Callers
44
+ # (including Cosmo::Processor#fetch) treat NATS::Timeout as "no messages", so the message is lost
45
+ # silently: never processed, never acked, and not redelivered until the consumer's ack_wait expires.
46
+ # Reproduces reliably with several threads fetching (batch: 1) concurrently against the same
47
+ # subscription, e.g. Cosmo::Job::Processor's thread pool.
48
+ #
49
+ # Vendored copy of nats-pure's PullSubscription#fetch, kept as close to
50
+ # upstream as possible (two one-line fixes, see comments above), so it's easy to diff against the
51
+ # next nats-pure release and drop once fixed there. Fixed on nats-pure main by the fetch rewrite
52
+ # (#181), recognized by its private #take_pending, in which case this copy is not loaded.
53
+ return if NATS::JetStream.const_get(:PullSubscription).private_method_defined?(:take_pending)
54
+
55
+ # rubocop:disable all
56
+ module NATS
57
+ class JetStream
58
+ module PullSubscription
59
+ def fetch(batch = 1, params = {})
60
+ raise ::NATS::JetStream::Error.new("nats: invalid batch size") if batch < 1
61
+
62
+ t = MonotonicTime.now
63
+ start_time = t # fixes the upstream bug -- see comment above
64
+ timeout = params[:timeout] ||= 5
65
+ expires = (timeout * 1_000_000_000) - 100_000
66
+ next_req = { batch: batch }
67
+
68
+ msgs = []
69
+ case
70
+ when batch == 1
71
+ synchronize do
72
+ unless @pending_queue.empty?
73
+ msg = @pending_queue.pop
74
+ @pending_size -= msg.data.size
75
+ if JS.is_status_msg(msg)
76
+ case msg.header["Status"]
77
+ when JS::Status::NoMsgs then nil
78
+ when JS::Status::RequestTimeout then nil # Skip
79
+ else raise JS.from_msg(msg)
80
+ end
81
+ else
82
+ msgs << msg
83
+ end
84
+ end
85
+ end
86
+
87
+ next_req[:expires] = expires
88
+ if msgs.empty?
89
+ @nc.publish(@jsi.nms, JS.next_req_to_json(next_req), @subject)
90
+ synchronize { wait_for_msgs_cond.wait(timeout) }
91
+
92
+ unless @pending_queue.empty?
93
+ msg = @pending_queue.pop
94
+ @pending_size -= msg.data.size
95
+ msgs << msg
96
+ end
97
+
98
+ raise ::NATS::Timeout.new("nats: fetch timeout") if msgs.empty? && (MonotonicTime.since(t) > timeout)
99
+
100
+ if JS.is_status_msg(msgs.first)
101
+ msg = msgs.first
102
+ case msg.header[JS::Header::Status]
103
+ when JS::Status::RequestTimeout then raise NATS::Timeout.new("nats: fetch request timeout")
104
+ else raise JS.from_msg(msgs.first)
105
+ end
106
+ end
107
+ end
108
+ when batch > 1
109
+ synchronize do
110
+ if batch <= @pending_queue.size
111
+ batch.times do
112
+ msg = @pending_queue.pop
113
+ @pending_size -= msg.data.size
114
+ if JS.is_status_msg(msg)
115
+ case msg.header[JS::Header::Status]
116
+ when JS::Status::NoMsgs, JS::Status::RequestTimeout then next
117
+ else raise JS.from_msg(msg)
118
+ end
119
+ else
120
+ msgs << msg
121
+ end
122
+ end
123
+ return msgs
124
+ end
125
+ end
126
+
127
+ next_req[:no_wait] = true
128
+ @nc.publish(@jsi.nms, JS.next_req_to_json(next_req), @subject)
129
+
130
+ start_time = MonotonicTime.now
131
+ msg = nil
132
+
133
+ synchronize do
134
+ wait_for_msgs_cond.wait(timeout)
135
+ unless @pending_queue.empty?
136
+ msg = @pending_queue.pop
137
+ @pending_size -= msg.data.size
138
+ end
139
+ end
140
+
141
+ if !msg.nil? && JS.is_status_msg(msg)
142
+ case msg.header[JS::Header::Status]
143
+ when JS::Status::NoMsgs
144
+ next_req[:expires] = expires
145
+ next_req.delete(:no_wait)
146
+ @nc.publish(@jsi.nms, JS.next_req_to_json(next_req), @subject)
147
+ when JS::Status::RequestTimeout
148
+ raise NATS::Timeout.new("nats: fetch request timeout")
149
+ else
150
+ raise JS.from_msg(msg)
151
+ end
152
+ else
153
+ msgs << msg unless msg.nil?
154
+ end
155
+
156
+ duration = MonotonicTime.since(start_time)
157
+ raise NATS::Timeout.new("nats: fetch timeout") if msgs.empty? && (duration > timeout)
158
+
159
+ needed = batch - msgs.count
160
+ while (needed > 0) && (MonotonicTime.since(start_time) < timeout)
161
+ duration = MonotonicTime.since(start_time)
162
+
163
+ synchronize do
164
+ if @pending_queue.empty?
165
+ deadline = timeout - duration
166
+ wait_for_msgs_cond.wait(deadline) if deadline > 0
167
+
168
+ duration = MonotonicTime.since(start_time)
169
+ if msgs.empty? && @pending_queue.empty? && (duration > timeout)
170
+ raise NATS::Timeout.new("nats: fetch timeout")
171
+ end
172
+ end
173
+
174
+ unless @pending_queue.empty?
175
+ msg = @pending_queue.pop
176
+ @pending_size -= msg.data.size
177
+
178
+ if JS.is_status_msg(msg)
179
+ case msg.header[JS::Header::Status]
180
+ when JS::Status::NoMsgs, JS::Status::RequestTimeout
181
+ duration = MonotonicTime.since(start_time)
182
+ if duration > timeout
183
+ raise NATS::Timeout.new("nats: fetch timeout") if msgs.empty?
184
+
185
+ return msgs
186
+ end
187
+ else
188
+ raise JS.from_msg(msg)
189
+ end
190
+ else
191
+ msgs << msg
192
+ needed -= 1
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
198
+
199
+ raise ::NATS::Timeout.new("nats: fetch timeout") if msgs.empty? && (MonotonicTime.since(start_time) > timeout)
200
+
201
+ msgs
202
+ end
203
+ end
204
+ end
15
205
  end
206
+ # rubocop:enable all
@@ -16,11 +16,16 @@ module Cosmo
16
16
 
17
17
  def initialize(concurrency)
18
18
  @mutex = Thread::Mutex.new
19
+ @concurrency = concurrency
19
20
  @available = concurrency
20
21
  @cond = ConditionVariable.new
21
22
  @pool = Concurrent::FixedThreadPool.new(concurrency)
22
23
  end
23
24
 
25
+ def wait_idle
26
+ @mutex.synchronize { @cond.wait(@mutex) until @available == @concurrency }
27
+ end
28
+
24
29
  def post
25
30
  @mutex.synchronize do
26
31
  @cond.wait(@mutex) while @available <= 0
data/lib/cosmo/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cosmo
4
- VERSION = "0.5.1"
4
+ VERSION = "0.7.0"
5
5
  end
@@ -163,6 +163,14 @@ button:hover, .btn:hover {
163
163
  );
164
164
  }
165
165
 
166
+ .btn-sm {
167
+ box-shadow: none;
168
+ font-size: var(--font-size-small);
169
+ font-weight: 600;
170
+ line-height: var(--space-2x);
171
+ padding: var(--space-1-2) var(--space);
172
+ }
173
+
166
174
  /* ── Badges ────────────────────────────────────────────────────────────── */
167
175
  .badge {
168
176
  border-radius: var(--space-1-2);
@@ -343,6 +351,12 @@ section .pending {
343
351
  align-items: center;
344
352
  }
345
353
 
354
+ section .pending .poll-status {
355
+ align-items: center;
356
+ display: inline-flex;
357
+ gap: var(--space);
358
+ }
359
+
346
360
  /* ── Stat cards ────────────────────────────────────────────────────────── */
347
361
  .cards-container {
348
362
  display: grid;
@@ -479,6 +493,13 @@ time {
479
493
  font-size: var(--font-size-small);
480
494
  word-wrap: break-word;
481
495
  }
496
+ .error-message details code {
497
+ max-width: 600px;
498
+ max-height: 320px;
499
+ overflow: auto;
500
+ white-space: pre;
501
+ font-size: var(--font-size-small);
502
+ }
482
503
 
483
504
  /* ── Links ─────────────────────────────────────────────────────────────── */
484
505
  .stream-link { color: var(--color-primary); font-weight: 600; }
@@ -54,6 +54,13 @@ module Cosmo
54
54
  ok
55
55
  end
56
56
 
57
+ def delete_enqueued
58
+ seq = path.split("/").last.to_i
59
+ stream_name, = streams
60
+ API::Stream.new(stream_name).delete(seq)
61
+ _enqueued
62
+ end
63
+
57
64
  def _scheduled
58
65
  stream = API::Stream.new("scheduled")
59
66
  jobs = stream.messages(page: params["page"], limit: params["limit"])
@@ -67,9 +74,19 @@ module Cosmo
67
74
  end
68
75
 
69
76
  def _busy
70
- limit = (limit || 25).to_i
71
- jobs = API::Busy.instance.list(limit:)
72
- ok render("jobs/_busy", { jobs: jobs, total: API::Busy.instance.size })
77
+ busy = API::Stats::Busy.instance
78
+ total = busy.size
79
+ page, limit, total_pages = paginate(total, API::Stats::Busy::LIMIT)
80
+ polling = params["poll"].to_s != "0"
81
+ ok render("jobs/_busy", { jobs: busy.list(page:, limit:), total:, page:, limit:, total_pages:, polling: })
82
+ end
83
+
84
+ def _processes
85
+ processes = API::Stats::Processes.instance
86
+ total = processes.size
87
+ page, limit, total_pages = paginate(total, API::Stats::Processes::LIMIT)
88
+ polling = params["poll"].to_s != "0"
89
+ ok render("jobs/_processes", { processes: processes.list(page:, limit:), total:, page:, limit:, total_pages:, polling: })
73
90
  end
74
91
 
75
92
  def _enqueued # rubocop:disable Metrics/AbcSize
@@ -80,8 +97,9 @@ module Cosmo
80
97
  unless stream_name.to_s.empty?
81
98
  stream = API::Stream.new(stream_name)
82
99
  total = stream.total
83
- jobs = stream.messages(page:, limit:)
84
100
  total_pages = (total.to_f / limit).ceil
101
+ page = page.clamp(1, [total_pages, 1].max)
102
+ jobs = stream.messages(page:, limit:)
85
103
  end
86
104
 
87
105
  ok render("jobs/_enqueued", { jobs:, total:, stream_name:, stream_names:, page:, limit:, total_pages: })
@@ -93,6 +111,12 @@ module Cosmo
93
111
 
94
112
  private
95
113
 
114
+ def paginate(total, default_limit)
115
+ limit = (params["limit"] || default_limit).to_i
116
+ total_pages = (total.to_f / limit).ceil
117
+ [params["page"].to_i.clamp(1, [total_pages, 1].max), limit, total_pages]
118
+ end
119
+
96
120
  def streams
97
121
  stream_names = API::Stream.jobs.map(&:name)
98
122
  stream_name = params.fetch("stream_name", stream_names.first)
@@ -8,6 +8,13 @@ module Cosmo
8
8
  module Application
9
9
  include Renderer
10
10
 
11
+ # Seconds between htmx auto-refreshes. Override with COSMO_WEB_POLL_INTERVAL.
12
+ POLL_INTERVAL = 5
13
+
14
+ def poll_interval
15
+ @poll_interval ||= ENV.fetch("COSMO_WEB_POLL_INTERVAL", POLL_INTERVAL).to_i
16
+ end
17
+
11
18
  def render(template, locals = nil)
12
19
  defaults = { request: @request }
13
20
  locals = Hash(locals).merge(defaults)
@@ -42,8 +49,10 @@ module Cosmo
42
49
  "#{elapsed}s"
43
50
  elsif elapsed < 3600
44
51
  "#{elapsed / 60}m #{elapsed % 60}s"
45
- else
52
+ elsif elapsed < 86_400
46
53
  "#{elapsed / 3600}h #{(elapsed % 3600) / 60}m"
54
+ else
55
+ "#{elapsed / 86_400}d #{(elapsed % 86_400) / 3600}h"
47
56
  end
48
57
  end
49
58
 
@@ -3,7 +3,7 @@
3
3
 
4
4
  <div id="batches-table"
5
5
  hx-get="<%= url_for('/batches/_table') %>"
6
- hx-trigger="load, every 5s"
6
+ hx-trigger="load, every <%= poll_interval %>s"
7
7
  hx-swap="innerHTML">
8
8
  <div class="alert alert-info">Loading batches&hellip;</div>
9
9
  </div>
@@ -1,10 +1,29 @@
1
+ <% nav = @polling ? { limit: @limit } : { limit: @limit, poll: 0 } -%>
1
2
  <div id="busy-poller"
2
- hx-get="<%= url_for('/jobs/_busy') %>"
3
- hx-trigger="every 5s"
4
- hx-swap="outerHTML">
3
+ <% if @polling -%>
4
+ hx-get="<%= url_for('/jobs/_busy', page: @page, limit: @limit) %>"
5
+ hx-trigger="every <%= poll_interval %>s"
6
+ hx-swap="outerHTML"
7
+ <% end -%>
8
+ >
5
9
  <div class="pending">
6
10
  <span></span>
7
- <span class="text-muted"><%= @total %> job(s) running</span>
11
+ <span class="poll-status">
12
+ <span class="text-muted"><%= @total %> job(s) running</span>
13
+ <% if @polling -%>
14
+ <a class="btn btn-sm"
15
+ hx-get="<%= url_for('/jobs/_busy', nav.merge(page: @page, poll: 0)) %>"
16
+ hx-target="#busy-poller"
17
+ hx-swap="outerHTML"
18
+ hx-indicator="#global-spinner">&#9209; Stop Polling</a>
19
+ <% else -%>
20
+ <a class="btn btn-sm btn-primary"
21
+ hx-get="<%= url_for('/jobs/_busy', page: @page, limit: @limit) %>"
22
+ hx-target="#busy-poller"
23
+ hx-swap="outerHTML"
24
+ hx-indicator="#global-spinner">&#x25B6; Live Poll</a>
25
+ <% end -%>
26
+ </span>
8
27
  </div>
9
28
 
10
29
  <% if @jobs.empty? -%>
@@ -18,6 +37,7 @@
18
37
  <th>JID[Args]</th>
19
38
  <th>Stream</th>
20
39
  <th>Worker</th>
40
+ <th>Attempt</th>
21
41
  <th>Running for</th>
22
42
  </tr>
23
43
  </thead>
@@ -43,6 +63,14 @@
43
63
  <code><%= h(job[:stream].to_s) %></code>
44
64
  </td>
45
65
  <td><code style="font-size: var(--font-size-small);"><%= h(job[:worker].to_s) %></code></td>
66
+ <td>
67
+ <% delivery = job[:delivery].to_i -%>
68
+ <% if delivery > 1 -%>
69
+ <span class="badge <%= delivery >= 5 ? 'badge-danger' : 'badge-warning' %>"><%= delivery %></span>
70
+ <% else -%>
71
+ <span class="text-muted"><%= delivery.zero? ? '&mdash;' : delivery %></span>
72
+ <% end -%>
73
+ </td>
46
74
  <td>
47
75
  <%= elapsed(job[:started_at]) %>
48
76
  </td>
@@ -52,4 +80,42 @@
52
80
  </table>
53
81
  </div>
54
82
  <% end -%>
83
+
84
+ <% if @total_pages.to_i > 1 -%>
85
+ <div class="pagination">
86
+ <% if @page > 1 -%>
87
+ <a hx-get="<%= url_for('/jobs/_busy', nav.merge(page: @page - 1)) %>"
88
+ hx-target="#busy-poller"
89
+ hx-swap="outerHTML"
90
+ hx-indicator="#global-spinner"
91
+ class="btn">&#x2190; Prev</a>
92
+ <% else -%>
93
+ <span class="btn btn-disabled">&#x2190; Prev</span>
94
+ <% end -%>
95
+
96
+ <% pages(@page, @total_pages).each do |p| -%>
97
+ <% if p == :gap -%>
98
+ <span class="pagination-gap">&hellip;</span>
99
+ <% elsif p == @page -%>
100
+ <span class="btn btn-primary btn-disabled"><%= p %></span>
101
+ <% else -%>
102
+ <a hx-get="<%= url_for('/jobs/_busy', nav.merge(page: p)) %>"
103
+ hx-target="#busy-poller"
104
+ hx-swap="outerHTML"
105
+ hx-indicator="#global-spinner"
106
+ class="btn"><%= p %></a>
107
+ <% end -%>
108
+ <% end -%>
109
+
110
+ <% if @page < @total_pages -%>
111
+ <a hx-get="<%= url_for('/jobs/_busy', nav.merge(page: @page + 1)) %>"
112
+ hx-target="#busy-poller"
113
+ hx-swap="outerHTML"
114
+ hx-indicator="#global-spinner"
115
+ class="btn">Next &#x2192;</a>
116
+ <% else -%>
117
+ <span class="btn btn-disabled">Next &#x2192;</span>
118
+ <% end -%>
119
+ </div>
120
+ <% end -%>
55
121
  </div>
@@ -1,7 +1,4 @@
1
- <div id="dead-poller"
2
- hx-get="<%= url_for('/jobs/_dead') %>"
3
- hx-trigger="every 5s"
4
- hx-swap="outerHTML">
1
+ <div id="dead-jobs">
5
2
  <% if @jobs.empty? -%>
6
3
  <div class="alert alert-success">No dead jobs found</div>
7
4
  <% else -%>
@@ -35,31 +32,47 @@
35
32
  </details>
36
33
  </div>
37
34
  </td>
38
- <td>
39
- <code><%= h(job.stream) %></code>
35
+ <td style="white-space: nowrap;">
36
+ <% if job.x_stream -%><code><%= h(job.x_stream) %></code><% else -%><span class="text-muted">&mdash;</span><% end -%>
40
37
  </td>
41
- <td style="max-width: 400px;">
42
- <% if job.data[:error] -%>
38
+ <td style="min-width: 420px;">
39
+ <% if job.error? -%>
43
40
  <div class="error-message">
44
- <strong><%= h(job.data[:error].to_s.split(":").first || "Error") %></strong>
45
- <p><%= h(job.data[:error]) %></p>
41
+ <strong><%= h(job.error_class.to_s.empty? ? "Error" : job.error_class) %></strong>
42
+ <p><%= h(job.error_message) %></p>
43
+ <% if job.error_backtrace -%>
44
+ <details>
45
+ <summary>Backtrace</summary>
46
+ <code><%= h(job.error_backtrace_lines.join("\n")) %></code>
47
+ </details>
48
+ <% end -%>
49
+ </div>
50
+ <% else -%>
51
+ <div class="error-message">
52
+ <strong>Unknown</strong>
53
+ <p>No error recorded for this job</p>
46
54
  </div>
47
55
  <% end -%>
48
56
  </td>
49
57
  <td style="white-space: nowrap;">
50
- <time><%= h(job.timestamp) %></time>
58
+ <% if (failed_at = job.stored_at) -%>
59
+ <time datetime="<%= h(job.timestamp) %>" title="<%= h(job.timestamp) %>"><%= h(format_timestamp(failed_at)) %></time>
60
+ <div class="text-muted" style="font-size: var(--font-size-small);"><%= elapsed(failed_at) %> ago</div>
61
+ <% else -%>
62
+ <span class="text-muted">&mdash;</span>
63
+ <% end -%>
51
64
  </td>
52
- <td>
65
+ <td style="white-space: nowrap;">
53
66
  <a hx-patch="<%= url_for("/jobs/retry/#{job.seq}") %>"
54
67
  hx-target="#dead-row-<%= job.seq %>"
55
68
  hx-swap="innerHTML"
56
- class="btn btn-primary"
57
- style="width: 100%; text-align: center; margin-bottom: 3px">&#x21BB; Retry</a>
69
+ title="Retry"
70
+ class="btn btn-primary btn-sm">&#x21BB;</a>
58
71
  <a hx-delete="<%= url_for("/jobs/delete/#{job.seq}") %>"
59
72
  hx-target="#dead-row-<%= job.seq %>"
60
73
  hx-swap="innerHTML"
61
- class="btn btn-danger"
62
- style="width: 100%; text-align: center; margin-bottom: 3px">&#x2715; Delete</a>
74
+ title="Delete"
75
+ class="btn btn-danger btn-sm">&#x2715;</a>
63
76
  </td>
64
77
  </tr>
65
78
  <% end -%>
@@ -30,6 +30,7 @@
30
30
  <th>JID[Args]</th>
31
31
  <th>Stream</th>
32
32
  <th>Seq</th>
33
+ <th>Actions</th>
33
34
  </tr>
34
35
  </thead>
35
36
  <tbody>
@@ -56,6 +57,15 @@
56
57
  <td>
57
58
  <code><%= job.seq %></code>
58
59
  </td>
60
+ <td>
61
+ <a hx-delete="<%= url_for("/jobs/enqueued/#{job.seq}", { stream_name: @stream_name, page: @page, limit: @limit }) %>"
62
+ hx-target="#enqueued-poller"
63
+ hx-swap="outerHTML"
64
+ hx-indicator="#global-spinner"
65
+ hx-confirm="Remove message #<%= job.seq %> from <%= h(@stream_name) %>?"
66
+ title="Remove message"
67
+ class="btn btn-danger btn-sm">&#x2715;</a>
68
+ </td>
59
69
  </tr>
60
70
  <% end -%>
61
71
  </tbody>