cosmonats 0.6.0 → 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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +21 -169
  3. data/README.md +101 -17
  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 +33 -5
  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/processor.rb +39 -4
  31. data/lib/cosmo/sentry/job_processor_middleware.rb +1 -2
  32. data/lib/cosmo/stream/message.rb +2 -1
  33. data/lib/cosmo/stream/processor.rb +5 -0
  34. data/lib/cosmo/utils/json.rb +1 -1
  35. data/lib/cosmo/utils/overrides.rb +16 -10
  36. data/lib/cosmo/utils/thread_pool.rb +5 -0
  37. data/lib/cosmo/version.rb +1 -1
  38. data/lib/cosmo/web/assets/app.css +21 -0
  39. data/lib/cosmo/web/controllers/jobs.rb +28 -4
  40. data/lib/cosmo/web/helpers/application.rb +10 -1
  41. data/lib/cosmo/web/views/batches/index.erb +1 -1
  42. data/lib/cosmo/web/views/jobs/_busy.erb +70 -4
  43. data/lib/cosmo/web/views/jobs/_dead.erb +29 -16
  44. data/lib/cosmo/web/views/jobs/_enqueued.erb +10 -0
  45. data/lib/cosmo/web/views/jobs/_processes.erb +131 -0
  46. data/lib/cosmo/web/views/jobs/_scheduled.erb +1 -1
  47. data/lib/cosmo/web/views/jobs/busy.erb +1 -1
  48. data/lib/cosmo/web/views/jobs/dead.erb +2 -2
  49. data/lib/cosmo/web/views/jobs/enqueued.erb +1 -1
  50. data/lib/cosmo/web/views/jobs/index.erb +7 -1
  51. data/lib/cosmo/web/views/jobs/scheduled.erb +1 -1
  52. data/lib/cosmo/web/views/streams/index.erb +1 -1
  53. data/lib/cosmo/web.rb +2 -0
  54. data/lib/cosmo.rb +13 -0
  55. data/sig/cosmo/api/counter.rbs +2 -4
  56. data/sig/cosmo/api/cron/entry.rbs +2 -0
  57. data/sig/cosmo/api/cron.rbs +4 -2
  58. data/sig/cosmo/api/job.rbs +12 -0
  59. data/sig/cosmo/api/kv.rbs +7 -1
  60. data/sig/cosmo/api/registry.rbs +30 -0
  61. data/sig/cosmo/api/stats/busy.rbs +31 -0
  62. data/sig/cosmo/api/stats/processes.rbs +16 -0
  63. data/sig/cosmo/api/stats/totals.rbs +17 -0
  64. data/sig/cosmo/cli.rbs +4 -0
  65. data/sig/cosmo/client.rbs +12 -1
  66. data/sig/cosmo/engine.rbs +29 -0
  67. data/sig/cosmo/heartbeat.rbs +36 -0
  68. data/sig/cosmo/http_server/handler.rbs +17 -0
  69. data/sig/cosmo/http_server/health.rbs +21 -0
  70. data/sig/cosmo/http_server/ping.rbs +14 -0
  71. data/sig/cosmo/http_server.rbs +22 -0
  72. data/sig/cosmo/job/failure.rbs +28 -0
  73. data/sig/cosmo/job/processor.rbs +17 -1
  74. data/sig/cosmo/job/stream_filter.rbs +25 -0
  75. data/sig/cosmo/processor.rbs +12 -1
  76. data/sig/cosmo/stream/message.rbs +3 -1
  77. data/sig/cosmo/stream/processor.rbs +2 -0
  78. data/sig/cosmo/utils/thread_pool.rbs +3 -0
  79. data/sig/cosmo.rbs +4 -0
  80. metadata +33 -20
  81. data/lib/cosmo/api/busy.rb +0 -66
  82. data/sig/cosmo/api/busy.rbs +0 -35
@@ -5,20 +5,29 @@ module Cosmo
5
5
  STREAM_PAUSED_RECHECK_TTL = "5s" # How long a stream's paused state is cached before re-checking (override via COSMO_STREAM_PAUSED_RECHECK_TTL)
6
6
  STREAMS_PAUSED_IDLE_SLEEP = "1s" # How long to sleep when every stream is paused, preventing a tight CPU spin (override via COSMO_STREAMS_PAUSED_IDLE_SLEEP)
7
7
  STREAM_EMPTY_BACKOFF_MAX = "5s" # Max sleep between empty fetches (override via COSMO_STREAM_EMPTY_BACKOFF_MAX)
8
+ QUIET_SLEEP = "5s" # How long to sleep between checks while in quiet mode (override via COSMO_QUIET_IDLE_SLEEP)
8
9
 
9
10
  def self.run(...)
10
11
  new(...).tap(&:run)
11
12
  end
12
13
 
14
+ # Checks the command-line options this processor understands, before anything is booted or connected.
15
+ #
16
+ # @param options [Hash]
17
+ # @return [void]
18
+ def self.validate_options!(_options); end
19
+
13
20
  attr_reader :consumers
14
21
 
15
- def initialize(pool, running, options)
22
+ def initialize(pool, running, options, quiet:)
16
23
  @pool = pool
17
24
  @running = running
25
+ @quiet = quiet
18
26
  @options = options
19
27
  @threads = []
20
28
  @consumers = []
21
29
  @cache = Utils::TTLCache.new
30
+ @busy = Concurrent::AtomicFixnum.new
22
31
  end
23
32
 
24
33
  def run
@@ -29,6 +38,13 @@ module Cosmo
29
38
  run_loop
30
39
  end
31
40
 
41
+ # Threads processing fetched messages right now; threads waiting on a fetch don't count.
42
+ #
43
+ # @return [Integer]
44
+ def busy
45
+ @busy.value
46
+ end
47
+
32
48
  def stop(timeout = Config[:timeout])
33
49
  @running.make_false
34
50
  @pool.shutdown
@@ -52,6 +68,11 @@ module Cosmo
52
68
  while running?
53
69
  break if shutdown
54
70
 
71
+ if quiet?
72
+ sleep(Utils::Duration.parse(ENV.fetch("COSMO_QUIET_IDLE_SLEEP", QUIET_SLEEP)))
73
+ next
74
+ end
75
+
55
76
  all_empty = true # every stream is empty
56
77
  all_paused = true # every stream is paused
57
78
  consumers.each do |(subscription, config, processor)| # rubocop:disable Metrics/BlockLength
@@ -80,15 +101,18 @@ module Cosmo
80
101
 
81
102
  timeout = fetch_timeout(config)
82
103
  Logger.trace "fetching #{fetch_subjects(config).inspect}, timeout=#{timeout}"
104
+ started_at = Time.now
83
105
  messages = fetch(subscription, batch_size: config[:batch_size], timeout:)
84
106
  Logger.trace "fetched (#{messages&.size.to_i}) messages"
85
107
  if messages&.any?
86
108
  consumer_state.delete(stream_name)
87
- process(messages, processor)
109
+ track_busy { process(messages, processor) }
88
110
  else
89
111
  max_backoff = Utils::Duration.parse(ENV.fetch("COSMO_STREAM_EMPTY_BACKOFF_MAX", STREAM_EMPTY_BACKOFF_MAX))
90
- consumer_state.compute(stream_name) do |current|
91
- count = (current&.first || 0) + 1
112
+ consumer_state.compute(stream_name) do |(count, wake_at)|
113
+ next [count, wake_at] if wake_at && wake_at > started_at
114
+
115
+ count = count.to_i + 1
92
116
  backoff = [timeout * (2**(count - 1)), max_backoff].min
93
117
  [count, Time.now + backoff]
94
118
  end
@@ -129,10 +153,21 @@ module Cosmo
129
153
  raise NotImplementedError
130
154
  end
131
155
 
156
+ def track_busy
157
+ @busy.increment
158
+ yield
159
+ ensure
160
+ @busy.decrement
161
+ end
162
+
132
163
  def running?
133
164
  @running.true?
134
165
  end
135
166
 
167
+ def quiet?
168
+ @quiet.true?
169
+ end
170
+
136
171
  def scheduler?
137
172
  false
138
173
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
3
+ # rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize
4
4
  module Cosmo
5
5
  module Sentry
6
6
  module JobProcessorMiddleware
@@ -63,4 +63,3 @@ module Cosmo
63
63
  end
64
64
  end
65
65
  end
66
- # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
@@ -7,8 +7,9 @@ module Cosmo
7
7
  class Message
8
8
  extend Forwardable
9
9
 
10
- delegate %i[subject reply header metadata ack nack term in_progress] => :@msg
10
+ delegate %i[subject reply header metadata ack nak term in_progress] => :@msg
11
11
  delegate %i[timestamp num_delivered num_pending] => :metadata
12
+ alias nack nak
12
13
 
13
14
  def initialize(msg, serializer: nil)
14
15
  @msg = msg
@@ -3,6 +3,11 @@
3
3
  module Cosmo
4
4
  module Stream
5
5
  class Processor < ::Cosmo::Processor
6
+ # @return [Array<String>] names of the stream classes being processed
7
+ def subscriptions
8
+ @consumers.map { |(_, config, _)| config[:class].name }
9
+ end
10
+
6
11
  private
7
12
 
8
13
  def setup
@@ -8,7 +8,7 @@ module Cosmo
8
8
  module_function
9
9
 
10
10
  def parse(value, default: nil, symbolize_names: true, **options)
11
- JSON.parse(value, options.merge(symbolize_names:))
11
+ JSON.parse(value, **options, symbolize_names:)
12
12
  rescue TypeError, JSON::ParserError
13
13
  default
14
14
  end
@@ -1,20 +1,24 @@
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
15
19
  end
16
20
 
17
- # Upstream bug in nats-pure 2.5.0 (https://github.com/nats-io/nats.rb):
21
+ # Upstream bug in nats-pure 2.5.0 and 2.6.0 (https://github.com/nats-io/nats-pure.rb):
18
22
  # NATS::JetStream::PullSubscription#fetch only assigns its `start_time` local in the
19
23
  # `batch > 1` branch, but the final "did we time out" check outside the case statement
20
24
  # reads it unconditionally. For `batch == 1` (what Cosmo::Processor always uses),
@@ -44,7 +48,9 @@ end
44
48
  #
45
49
  # Vendored copy of nats-pure's PullSubscription#fetch, kept as close to
46
50
  # upstream as possible (two one-line fixes, see comments above), so it's easy to diff against the
47
- # next nats-pure release and drop once fixed there.
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)
48
54
 
49
55
  # rubocop:disable all
50
56
  module NATS
@@ -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.6.0"
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>