cosmonats 0.6.0 → 0.8.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +21 -169
  3. data/README.md +296 -116
  4. data/lib/cosmo/active_job/adapter.rb +1 -2
  5. data/lib/cosmo/api/counter.rb +18 -15
  6. data/lib/cosmo/api/cron/entry.rb +23 -3
  7. data/lib/cosmo/api/cron.rb +25 -26
  8. data/lib/cosmo/api/job.rb +30 -0
  9. data/lib/cosmo/api/kv.rb +31 -11
  10. data/lib/cosmo/api/registry.rb +68 -0
  11. data/lib/cosmo/api/stats/busy.rb +63 -0
  12. data/lib/cosmo/api/stats/metrics.rb +109 -0
  13. data/lib/cosmo/api/stats/processes.rb +47 -0
  14. data/lib/cosmo/api/stats/totals.rb +27 -0
  15. data/lib/cosmo/api/stats.rb +8 -6
  16. data/lib/cosmo/api/stream.rb +1 -1
  17. data/lib/cosmo/api.rb +3 -2
  18. data/lib/cosmo/batch/counters.rb +19 -0
  19. data/lib/cosmo/batch.rb +15 -8
  20. data/lib/cosmo/cli.rb +46 -10
  21. data/lib/cosmo/client.rb +61 -7
  22. data/lib/cosmo/config/cosmo.yml +58 -0
  23. data/lib/cosmo/config/settings.rb +20 -0
  24. data/lib/cosmo/config.rb +144 -15
  25. data/lib/cosmo/engine.rb +103 -4
  26. data/lib/cosmo/heartbeat.rb +140 -0
  27. data/lib/cosmo/http_server/handler.rb +44 -0
  28. data/lib/cosmo/http_server/health.rb +39 -0
  29. data/lib/cosmo/http_server/ping.rb +23 -0
  30. data/lib/cosmo/http_server.rb +62 -0
  31. data/lib/cosmo/job/data.rb +17 -12
  32. data/lib/cosmo/job/enqueuer.rb +53 -0
  33. data/lib/cosmo/job/failure.rb +52 -0
  34. data/lib/cosmo/job/limit.rb +2 -2
  35. data/lib/cosmo/job/processor.rb +110 -73
  36. data/lib/cosmo/job/requeue.rb +20 -0
  37. data/lib/cosmo/job/stream_filter.rb +54 -0
  38. data/lib/cosmo/job.rb +10 -22
  39. data/lib/cosmo/logger.rb +15 -2
  40. data/lib/cosmo/middleware/busy.rb +15 -0
  41. data/lib/cosmo/middleware/chain.rb +137 -0
  42. data/lib/cosmo/middleware/limit.rb +50 -0
  43. data/lib/cosmo/middleware/metrics.rb +39 -0
  44. data/lib/cosmo/middleware/sentry.rb +61 -0
  45. data/lib/cosmo/middleware/totals.rb +29 -0
  46. data/lib/cosmo/middleware.rb +7 -0
  47. data/lib/cosmo/processor.rb +49 -8
  48. data/lib/cosmo/publisher.rb +1 -13
  49. data/lib/cosmo/railtie.rb +2 -2
  50. data/lib/cosmo/services.rb +69 -0
  51. data/lib/cosmo/stream/message.rb +2 -1
  52. data/lib/cosmo/stream/processor.rb +12 -1
  53. data/lib/cosmo/utils/hash.rb +7 -8
  54. data/lib/cosmo/utils/json.rb +1 -1
  55. data/lib/cosmo/utils/overrides.rb +16 -10
  56. data/lib/cosmo/utils/thread_pool.rb +5 -0
  57. data/lib/cosmo/version.rb +1 -1
  58. data/lib/cosmo/web/assets/app.css +79 -0
  59. data/lib/cosmo/web/chart.rb +98 -0
  60. data/lib/cosmo/web/controllers/crons.rb +8 -0
  61. data/lib/cosmo/web/controllers/jobs.rb +43 -5
  62. data/lib/cosmo/web/controllers/metrics.rb +48 -0
  63. data/lib/cosmo/web/helpers/application.rb +20 -1
  64. data/lib/cosmo/web/views/batches/_table.erb +11 -0
  65. data/lib/cosmo/web/views/batches/index.erb +1 -1
  66. data/lib/cosmo/web/views/jobs/_busy.erb +70 -4
  67. data/lib/cosmo/web/views/jobs/_dead.erb +29 -16
  68. data/lib/cosmo/web/views/jobs/_enqueued.erb +10 -0
  69. data/lib/cosmo/web/views/jobs/_processes.erb +145 -0
  70. data/lib/cosmo/web/views/jobs/_scheduled.erb +1 -1
  71. data/lib/cosmo/web/views/jobs/_stats.erb +4 -0
  72. data/lib/cosmo/web/views/jobs/_tabs.erb +6 -0
  73. data/lib/cosmo/web/views/jobs/busy.erb +1 -1
  74. data/lib/cosmo/web/views/jobs/dead.erb +2 -2
  75. data/lib/cosmo/web/views/jobs/enqueued.erb +1 -1
  76. data/lib/cosmo/web/views/jobs/index.erb +7 -1
  77. data/lib/cosmo/web/views/jobs/scheduled.erb +1 -1
  78. data/lib/cosmo/web/views/layout.erb +1 -1
  79. data/lib/cosmo/web/views/metrics/_chart.erb +36 -0
  80. data/lib/cosmo/web/views/metrics/_panel.erb +13 -0
  81. data/lib/cosmo/web/views/metrics/_table.erb +41 -0
  82. data/lib/cosmo/web/views/metrics/index.erb +17 -0
  83. data/lib/cosmo/web/views/streams/index.erb +1 -1
  84. data/lib/cosmo/web.rb +5 -0
  85. data/lib/cosmo.rb +51 -0
  86. data/sig/cosmo/api/counter.rbs +3 -6
  87. data/sig/cosmo/api/cron/entry.rbs +2 -0
  88. data/sig/cosmo/api/cron.rbs +4 -2
  89. data/sig/cosmo/api/job.rbs +12 -0
  90. data/sig/cosmo/api/kv.rbs +7 -1
  91. data/sig/cosmo/api/registry.rbs +30 -0
  92. data/sig/cosmo/api/stats/busy.rbs +31 -0
  93. data/sig/cosmo/api/stats/metrics.rbs +42 -0
  94. data/sig/cosmo/api/stats/processes.rbs +19 -0
  95. data/sig/cosmo/api/stats/totals.rbs +18 -0
  96. data/sig/cosmo/batch/counters.rbs +10 -0
  97. data/sig/cosmo/batch.rbs +3 -4
  98. data/sig/cosmo/cli.rbs +6 -0
  99. data/sig/cosmo/client.rbs +15 -2
  100. data/sig/cosmo/config/settings.rbs +31 -0
  101. data/sig/cosmo/config.rbs +59 -5
  102. data/sig/cosmo/engine.rbs +29 -0
  103. data/sig/cosmo/heartbeat.rbs +38 -0
  104. data/sig/cosmo/http_server/handler.rbs +17 -0
  105. data/sig/cosmo/http_server/health.rbs +21 -0
  106. data/sig/cosmo/http_server/ping.rbs +14 -0
  107. data/sig/cosmo/http_server.rbs +22 -0
  108. data/sig/cosmo/job/data.rbs +5 -3
  109. data/sig/cosmo/job/enqueuer.rbs +11 -0
  110. data/sig/cosmo/job/failure.rbs +28 -0
  111. data/sig/cosmo/job/processor.rbs +18 -4
  112. data/sig/cosmo/job/requeue.rbs +9 -0
  113. data/sig/cosmo/job/stream_filter.rbs +25 -0
  114. data/sig/cosmo/job.rbs +0 -2
  115. data/sig/cosmo/logger.rbs +6 -0
  116. data/sig/cosmo/middleware/chain.rbs +51 -0
  117. data/sig/cosmo/middleware/sentry.rbs +14 -0
  118. data/sig/cosmo/middleware.rbs +35 -0
  119. data/sig/cosmo/processor.rbs +14 -1
  120. data/sig/cosmo/publisher.rbs +1 -9
  121. data/sig/cosmo/services.rbs +18 -0
  122. data/sig/cosmo/stream/message.rbs +3 -1
  123. data/sig/cosmo/stream/processor.rbs +4 -0
  124. data/sig/cosmo/utils/hash.rbs +2 -3
  125. data/sig/cosmo/utils/thread_pool.rbs +3 -0
  126. data/sig/cosmo.rbs +15 -0
  127. metadata +62 -22
  128. data/lib/cosmo/api/busy.rb +0 -66
  129. data/lib/cosmo/sentry/auto.rb +0 -6
  130. data/lib/cosmo/sentry/job_processor_middleware.rb +0 -66
  131. data/sig/cosmo/api/busy.rbs +0 -35
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ class Web
5
+ # Geometry for the Metrics tab's SVG chart: a bar per day for the number of runs on the left axis, and a line for
6
+ # the average execution time on the right one.
7
+ class Chart
8
+ WIDTH = 760
9
+ HEIGHT = 240
10
+ TOP = 16
11
+ BOTTOM = 212
12
+ LEFT = 52
13
+ RIGHT = 700
14
+ TICKS = 4
15
+ LABELS = 10
16
+
17
+ # @param series [Array<Hash>] +{ date:, count:, exec_ms: }+ per day, oldest first
18
+ def initialize(series)
19
+ @series = series
20
+ @count_top = top(series.map { _1[:count] }.max.to_i)
21
+ @exec_top = top(series.filter_map { _1[:exec_ms] }.max.to_f)
22
+ end
23
+
24
+ # @return [Boolean]
25
+ def empty?
26
+ @series.all? { _1[:count].zero? }
27
+ end
28
+
29
+ # @return [Array<Hash>] +{ x:, y:, width:, height:, title: }+
30
+ def bars # rubocop:disable Metrics/AbcSize
31
+ @series.each_with_index.map do |day, index|
32
+ height = scale(day[:count], @count_top)
33
+ { x: (slot_x(index) + (slot * 0.15)).round(1), y: (BOTTOM - height).round(1), width: (slot * 0.7).round(1),
34
+ height: height.round(1), title: "#{label(day[:date])}: #{day[:count]} run(s)" }
35
+ end
36
+ end
37
+
38
+ # @return [Array<Hash>] +{ x:, y:, title: }+ for the days with an average
39
+ def points
40
+ @series.each_with_index.filter_map do |day, index|
41
+ next unless day[:exec_ms]
42
+
43
+ { x: (slot_x(index) + (slot / 2)).round(1), y: (BOTTOM - scale(day[:exec_ms], @exec_top)).round(1),
44
+ title: "#{label(day[:date])}: #{day[:exec_ms]} ms on average" }
45
+ end
46
+ end
47
+
48
+ # @return [String] the +points+ attribute of the execution time polyline
49
+ def line
50
+ points.map { "#{_1[:x]},#{_1[:y]}" }.join(" ")
51
+ end
52
+
53
+ # @return [Array<Hash>] +{ y:, count:, exec_ms: }+ per horizontal grid line, bottom up
54
+ def ticks
55
+ (0..TICKS).map do |step|
56
+ { y: (BOTTOM - ((BOTTOM - TOP) * step / TICKS.to_f)).round(1),
57
+ count: (@count_top * step / TICKS.to_f).round, exec_ms: (@exec_top * step / TICKS.to_f).round }
58
+ end
59
+ end
60
+
61
+ # @return [Array<Hash>] +{ x:, text: }+, thinned out to at most LABELS
62
+ def labels
63
+ every = (@series.size / LABELS.to_f).ceil.clamp(1, nil)
64
+ @series.each_with_index.filter_map do |day, index|
65
+ { x: (slot_x(index) + (slot / 2)).round(1), text: label(day[:date]) } if (index % every).zero?
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def slot
72
+ (RIGHT - LEFT) / [@series.size, 1].max.to_f
73
+ end
74
+
75
+ def slot_x(index)
76
+ LEFT + (slot * index)
77
+ end
78
+
79
+ def scale(value, top)
80
+ top.zero? ? 0 : (BOTTOM - TOP) * value / top.to_f
81
+ end
82
+
83
+ # The smallest "nice" axis maximum (1, 2 or 5 times a power of ten per tick) at or above +max+.
84
+ def top(max)
85
+ return TICKS if max.zero?
86
+
87
+ raw = max / TICKS.to_f
88
+ magnitude = 10**Math.log10(raw).floor
89
+ step = [1, 2, 5, 10].map { _1 * magnitude }.find { _1 >= raw }
90
+ step * TICKS
91
+ end
92
+
93
+ def label(date)
94
+ "#{date[4, 2]}/#{date[6, 2]}"
95
+ end
96
+ end
97
+ end
98
+ end
@@ -7,17 +7,23 @@ module Cosmo
7
7
  module Controllers
8
8
  class Crons < Application
9
9
  def index
10
+ return not_found unless Config.scheduled.enabled
11
+
10
12
  content_for :title, "Crons"
11
13
  ok render("crons/index", layout: true)
12
14
  end
13
15
 
14
16
  def _table
17
+ return not_found unless Config.scheduled.enabled
18
+
15
19
  ok render("crons/_table", { schedules: cron.all })
16
20
  end
17
21
 
18
22
  # Dispatch the job immediately, bypassing the schedule timer.
19
23
  # Expects params["subject"] = the schedule subject stored in NATS.
20
24
  def run_now
25
+ return not_found unless Config.scheduled.enabled
26
+
21
27
  subject = Rack::Utils.unescape(params["subject"].to_s)
22
28
  cron.run_now!(subject)
23
29
  ok
@@ -25,6 +31,8 @@ module Cosmo
25
31
 
26
32
  # Purge the schedule from NATS so it stops firing.
27
33
  def delete
34
+ return not_found unless Config.scheduled.enabled
35
+
28
36
  subject = Rack::Utils.unescape(params["subject"].to_s)
29
37
  cron.delete!(subject)
30
38
  _table
@@ -8,7 +8,8 @@ module Cosmo
8
8
  class Jobs < Application
9
9
  def index
10
10
  content_for :title, "Jobs"
11
- ok render("jobs/index", layout: true)
11
+ processes_query = params.slice("page", "limit").transform_values(&:to_i).select { _2.positive? }
12
+ ok render("jobs/index", { processes_query: }, layout: true)
12
13
  end
13
14
 
14
15
  def busy
@@ -27,6 +28,8 @@ module Cosmo
27
28
  end
28
29
 
29
30
  def scheduled
31
+ return not_found unless Config.scheduled.enabled
32
+
30
33
  return _scheduled if hx_request?
31
34
 
32
35
  content_for :title, "Scheduled Jobs"
@@ -34,6 +37,8 @@ module Cosmo
34
37
  end
35
38
 
36
39
  def dead
40
+ return not_found unless Config.dead.enabled
41
+
37
42
  return _dead if hx_request?
38
43
 
39
44
  content_for :title, "Dead Jobs"
@@ -41,6 +46,8 @@ module Cosmo
41
46
  end
42
47
 
43
48
  def retry
49
+ return not_found unless Config.dead.enabled
50
+
44
51
  seq = path.split("/").last.to_i
45
52
  stream = API::Stream.new("dead")
46
53
  stream.retry(seq)
@@ -48,28 +55,52 @@ module Cosmo
48
55
  end
49
56
 
50
57
  def delete
58
+ return not_found unless Config.dead.enabled
59
+
51
60
  seq = path.split("/").last.to_i
52
61
  stream = API::Stream.new("dead")
53
62
  stream.delete(seq)
54
63
  ok
55
64
  end
56
65
 
66
+ def delete_enqueued
67
+ seq = path.split("/").last.to_i
68
+ stream_name, = streams
69
+ API::Stream.new(stream_name).delete(seq)
70
+ _enqueued
71
+ end
72
+
57
73
  def _scheduled
74
+ return not_found unless Config.scheduled.enabled
75
+
58
76
  stream = API::Stream.new("scheduled")
59
77
  jobs = stream.messages(page: params["page"], limit: params["limit"])
60
78
  ok render("jobs/_scheduled", { jobs: jobs, total: stream.total })
61
79
  end
62
80
 
63
81
  def _dead
82
+ return not_found unless Config.dead.enabled
83
+
64
84
  stream = API::Stream.new("dead")
65
85
  jobs = stream.messages(page: params["page"], limit: params["limit"])
66
86
  ok render("jobs/_dead", { jobs: jobs, total: stream.total })
67
87
  end
68
88
 
69
89
  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 })
90
+ busy = API::Stats::Busy.instance
91
+ total = busy.size
92
+ page, limit, total_pages = paginate(total, API::Stats::Busy::LIMIT)
93
+ polling = params["poll"].to_s != "0"
94
+ ok render("jobs/_busy", { jobs: busy.list(page:, limit:), total:, page:, limit:, total_pages:, polling: })
95
+ end
96
+
97
+ def _processes
98
+ processes = API::Stats::Processes.instance
99
+ total = processes.size
100
+ page, limit, total_pages = paginate(total, API::Stats::Processes::LIMIT)
101
+ polling = params["poll"].to_s != "0"
102
+ groups = processes.list(page:, limit:).chunk { API::Stats::Processes.kind(_1) }.to_a
103
+ ok render("jobs/_processes", { groups:, total:, page:, limit:, total_pages:, polling: })
73
104
  end
74
105
 
75
106
  def _enqueued # rubocop:disable Metrics/AbcSize
@@ -80,8 +111,9 @@ module Cosmo
80
111
  unless stream_name.to_s.empty?
81
112
  stream = API::Stream.new(stream_name)
82
113
  total = stream.total
83
- jobs = stream.messages(page:, limit:)
84
114
  total_pages = (total.to_f / limit).ceil
115
+ page = page.clamp(1, [total_pages, 1].max)
116
+ jobs = stream.messages(page:, limit:)
85
117
  end
86
118
 
87
119
  ok render("jobs/_enqueued", { jobs:, total:, stream_name:, stream_names:, page:, limit:, total_pages: })
@@ -93,6 +125,12 @@ module Cosmo
93
125
 
94
126
  private
95
127
 
128
+ def paginate(total, default_limit)
129
+ limit = (params["limit"] || default_limit).to_i
130
+ total_pages = (total.to_f / limit).ceil
131
+ [params["page"].to_i.clamp(1, [total_pages, 1].max), limit, total_pages]
132
+ end
133
+
96
134
  def streams
97
135
  stream_names = API::Stream.jobs.map(&:name)
98
136
  stream_name = params.fetch("stream_name", stream_names.first)
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cosmo/web/controllers/application"
4
+ require "cosmo/web/chart"
5
+
6
+ module Cosmo
7
+ class Web
8
+ module Controllers
9
+ class Metrics < Application
10
+ RANGES = { 1 => "Today", 7 => "7 days", 30 => "30 days" }.freeze
11
+
12
+ def index
13
+ return not_found unless Config.metrics.enabled
14
+
15
+ content_for :title, "Metrics"
16
+ ok render("metrics/index", { days:, ranges: RANGES }, layout: true)
17
+ end
18
+
19
+ # The chart and the table together, so the charted job stays highlighted across refreshes.
20
+ def _panel
21
+ return not_found unless Config.metrics.enabled
22
+
23
+ rows = metrics.summary(days:)
24
+ job = charted(rows)
25
+ chart = Chart.new(metrics.daily(job, days:)) if job && days > 1
26
+ ok render("metrics/_panel", { rows:, job:, days:, chart: })
27
+ end
28
+
29
+ private
30
+
31
+ # The requested job while it has rows in the period, the busiest one otherwise.
32
+ def charted(rows)
33
+ job = params["job"].to_s
34
+ rows.any? { _1[:job] == job } ? job : rows.first&.dig(:job)
35
+ end
36
+
37
+ def days
38
+ value = params["days"].to_i
39
+ RANGES.key?(value) ? value : 7
40
+ end
41
+
42
+ def metrics
43
+ API::Stats::Metrics.instance
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -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)
@@ -35,6 +42,16 @@ module Cosmo
35
42
  value.to_s
36
43
  end
37
44
 
45
+ # @param millis [Numeric, nil]
46
+ # @return [String] e.g. "340 ms", "1.25 s", "2m 3s", or "—" without a value
47
+ def duration(millis)
48
+ return "—" unless millis
49
+ return "#{millis.round} ms" if millis < 1_000
50
+ return "#{(millis / 1_000.0).round(2)} s" if millis < 60_000
51
+
52
+ "#{(millis / 60_000).floor}m #{((millis % 60_000) / 1_000).round}s"
53
+ end
54
+
38
55
  def elapsed(value)
39
56
  elapsed = Time.now.to_i - value.to_i
40
57
 
@@ -42,8 +59,10 @@ module Cosmo
42
59
  "#{elapsed}s"
43
60
  elsif elapsed < 3600
44
61
  "#{elapsed / 60}m #{elapsed % 60}s"
45
- else
62
+ elsif elapsed < 86_400
46
63
  "#{elapsed / 3600}h #{(elapsed % 3600) / 60}m"
64
+ else
65
+ "#{elapsed / 86_400}d #{(elapsed % 86_400) / 3600}h"
47
66
  end
48
67
  end
49
68
 
@@ -8,7 +8,9 @@
8
8
  <th>Batch</th>
9
9
  <th>Parent</th>
10
10
  <th>Status</th>
11
+ <th>Progress</th>
11
12
  <th>Total</th>
13
+ <th>Pending</th>
12
14
  <th>Succeeded</th>
13
15
  <th>Failed</th>
14
16
  <th>Created</th>
@@ -17,6 +19,7 @@
17
19
  <tbody>
18
20
  <% @batches.each do |b| -%>
19
21
  <% stats = b.stats -%>
22
+ <% total = stats[:total].to_i -%>
20
23
  <tr>
21
24
  <td><code><%= h(b.bid) %></code></td>
22
25
  <td>
@@ -35,7 +38,15 @@
35
38
  <span class="badge badge-success">Success</span>
36
39
  <% end -%>
37
40
  </td>
41
+ <td>
42
+ <% if total.positive? -%>
43
+ <%= ((total - stats[:pending].to_i) * 100 / total).clamp(0, 100) %>%
44
+ <% else -%>
45
+ <span class="text-muted">&mdash;</span>
46
+ <% end -%>
47
+ </td>
38
48
  <td><%= format_numbers(stats[:total]) %></td>
49
+ <td><%= format_numbers(stats[:pending]) %></td>
39
50
  <td><%= format_numbers(stats[:succeeded]) %></td>
40
51
  <td><%= format_numbers(stats[:failed]) %></td>
41
52
  <td><%= format_timestamp(b.created_at) %></td>
@@ -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>