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.
- checksums.yaml +4 -4
- data/LICENSE.txt +21 -169
- data/README.md +296 -116
- data/lib/cosmo/active_job/adapter.rb +1 -2
- data/lib/cosmo/api/counter.rb +18 -15
- data/lib/cosmo/api/cron/entry.rb +23 -3
- data/lib/cosmo/api/cron.rb +25 -26
- data/lib/cosmo/api/job.rb +30 -0
- data/lib/cosmo/api/kv.rb +31 -11
- data/lib/cosmo/api/registry.rb +68 -0
- data/lib/cosmo/api/stats/busy.rb +63 -0
- data/lib/cosmo/api/stats/metrics.rb +109 -0
- data/lib/cosmo/api/stats/processes.rb +47 -0
- data/lib/cosmo/api/stats/totals.rb +27 -0
- data/lib/cosmo/api/stats.rb +8 -6
- data/lib/cosmo/api/stream.rb +1 -1
- data/lib/cosmo/api.rb +3 -2
- data/lib/cosmo/batch/counters.rb +19 -0
- data/lib/cosmo/batch.rb +15 -8
- data/lib/cosmo/cli.rb +46 -10
- data/lib/cosmo/client.rb +61 -7
- data/lib/cosmo/config/cosmo.yml +58 -0
- data/lib/cosmo/config/settings.rb +20 -0
- data/lib/cosmo/config.rb +144 -15
- data/lib/cosmo/engine.rb +103 -4
- data/lib/cosmo/heartbeat.rb +140 -0
- data/lib/cosmo/http_server/handler.rb +44 -0
- data/lib/cosmo/http_server/health.rb +39 -0
- data/lib/cosmo/http_server/ping.rb +23 -0
- data/lib/cosmo/http_server.rb +62 -0
- data/lib/cosmo/job/data.rb +17 -12
- data/lib/cosmo/job/enqueuer.rb +53 -0
- data/lib/cosmo/job/failure.rb +52 -0
- data/lib/cosmo/job/limit.rb +2 -2
- data/lib/cosmo/job/processor.rb +110 -73
- data/lib/cosmo/job/requeue.rb +20 -0
- data/lib/cosmo/job/stream_filter.rb +54 -0
- data/lib/cosmo/job.rb +10 -22
- data/lib/cosmo/logger.rb +15 -2
- data/lib/cosmo/middleware/busy.rb +15 -0
- data/lib/cosmo/middleware/chain.rb +137 -0
- data/lib/cosmo/middleware/limit.rb +50 -0
- data/lib/cosmo/middleware/metrics.rb +39 -0
- data/lib/cosmo/middleware/sentry.rb +61 -0
- data/lib/cosmo/middleware/totals.rb +29 -0
- data/lib/cosmo/middleware.rb +7 -0
- data/lib/cosmo/processor.rb +49 -8
- data/lib/cosmo/publisher.rb +1 -13
- data/lib/cosmo/railtie.rb +2 -2
- data/lib/cosmo/services.rb +69 -0
- data/lib/cosmo/stream/message.rb +2 -1
- data/lib/cosmo/stream/processor.rb +12 -1
- data/lib/cosmo/utils/hash.rb +7 -8
- data/lib/cosmo/utils/json.rb +1 -1
- data/lib/cosmo/utils/overrides.rb +16 -10
- data/lib/cosmo/utils/thread_pool.rb +5 -0
- data/lib/cosmo/version.rb +1 -1
- data/lib/cosmo/web/assets/app.css +79 -0
- data/lib/cosmo/web/chart.rb +98 -0
- data/lib/cosmo/web/controllers/crons.rb +8 -0
- data/lib/cosmo/web/controllers/jobs.rb +43 -5
- data/lib/cosmo/web/controllers/metrics.rb +48 -0
- data/lib/cosmo/web/helpers/application.rb +20 -1
- data/lib/cosmo/web/views/batches/_table.erb +11 -0
- data/lib/cosmo/web/views/batches/index.erb +1 -1
- data/lib/cosmo/web/views/jobs/_busy.erb +70 -4
- data/lib/cosmo/web/views/jobs/_dead.erb +29 -16
- data/lib/cosmo/web/views/jobs/_enqueued.erb +10 -0
- data/lib/cosmo/web/views/jobs/_processes.erb +145 -0
- data/lib/cosmo/web/views/jobs/_scheduled.erb +1 -1
- data/lib/cosmo/web/views/jobs/_stats.erb +4 -0
- data/lib/cosmo/web/views/jobs/_tabs.erb +6 -0
- data/lib/cosmo/web/views/jobs/busy.erb +1 -1
- data/lib/cosmo/web/views/jobs/dead.erb +2 -2
- data/lib/cosmo/web/views/jobs/enqueued.erb +1 -1
- data/lib/cosmo/web/views/jobs/index.erb +7 -1
- data/lib/cosmo/web/views/jobs/scheduled.erb +1 -1
- data/lib/cosmo/web/views/layout.erb +1 -1
- data/lib/cosmo/web/views/metrics/_chart.erb +36 -0
- data/lib/cosmo/web/views/metrics/_panel.erb +13 -0
- data/lib/cosmo/web/views/metrics/_table.erb +41 -0
- data/lib/cosmo/web/views/metrics/index.erb +17 -0
- data/lib/cosmo/web/views/streams/index.erb +1 -1
- data/lib/cosmo/web.rb +5 -0
- data/lib/cosmo.rb +51 -0
- data/sig/cosmo/api/counter.rbs +3 -6
- data/sig/cosmo/api/cron/entry.rbs +2 -0
- data/sig/cosmo/api/cron.rbs +4 -2
- data/sig/cosmo/api/job.rbs +12 -0
- data/sig/cosmo/api/kv.rbs +7 -1
- data/sig/cosmo/api/registry.rbs +30 -0
- data/sig/cosmo/api/stats/busy.rbs +31 -0
- data/sig/cosmo/api/stats/metrics.rbs +42 -0
- data/sig/cosmo/api/stats/processes.rbs +19 -0
- data/sig/cosmo/api/stats/totals.rbs +18 -0
- data/sig/cosmo/batch/counters.rbs +10 -0
- data/sig/cosmo/batch.rbs +3 -4
- data/sig/cosmo/cli.rbs +6 -0
- data/sig/cosmo/client.rbs +15 -2
- data/sig/cosmo/config/settings.rbs +31 -0
- data/sig/cosmo/config.rbs +59 -5
- data/sig/cosmo/engine.rbs +29 -0
- data/sig/cosmo/heartbeat.rbs +38 -0
- data/sig/cosmo/http_server/handler.rbs +17 -0
- data/sig/cosmo/http_server/health.rbs +21 -0
- data/sig/cosmo/http_server/ping.rbs +14 -0
- data/sig/cosmo/http_server.rbs +22 -0
- data/sig/cosmo/job/data.rbs +5 -3
- data/sig/cosmo/job/enqueuer.rbs +11 -0
- data/sig/cosmo/job/failure.rbs +28 -0
- data/sig/cosmo/job/processor.rbs +18 -4
- data/sig/cosmo/job/requeue.rbs +9 -0
- data/sig/cosmo/job/stream_filter.rbs +25 -0
- data/sig/cosmo/job.rbs +0 -2
- data/sig/cosmo/logger.rbs +6 -0
- data/sig/cosmo/middleware/chain.rbs +51 -0
- data/sig/cosmo/middleware/sentry.rbs +14 -0
- data/sig/cosmo/middleware.rbs +35 -0
- data/sig/cosmo/processor.rbs +14 -1
- data/sig/cosmo/publisher.rbs +1 -9
- data/sig/cosmo/services.rbs +18 -0
- data/sig/cosmo/stream/message.rbs +3 -1
- data/sig/cosmo/stream/processor.rbs +4 -0
- data/sig/cosmo/utils/hash.rbs +2 -3
- data/sig/cosmo/utils/thread_pool.rbs +3 -0
- data/sig/cosmo.rbs +15 -0
- metadata +62 -22
- data/lib/cosmo/api/busy.rb +0 -66
- data/lib/cosmo/sentry/auto.rb +0 -6
- data/lib/cosmo/sentry/job_processor_middleware.rb +0 -66
- 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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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">—</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>
|
|
@@ -1,10 +1,29 @@
|
|
|
1
|
+
<% nav = @polling ? { limit: @limit } : { limit: @limit, poll: 0 } -%>
|
|
1
2
|
<div id="busy-poller"
|
|
2
|
-
|
|
3
|
-
hx-
|
|
4
|
-
hx-
|
|
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="
|
|
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">⏹ 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">▶ 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? ? '—' : 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">← Prev</a>
|
|
92
|
+
<% else -%>
|
|
93
|
+
<span class="btn btn-disabled">← Prev</span>
|
|
94
|
+
<% end -%>
|
|
95
|
+
|
|
96
|
+
<% pages(@page, @total_pages).each do |p| -%>
|
|
97
|
+
<% if p == :gap -%>
|
|
98
|
+
<span class="pagination-gap">…</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 →</a>
|
|
116
|
+
<% else -%>
|
|
117
|
+
<span class="btn btn-disabled">Next →</span>
|
|
118
|
+
<% end -%>
|
|
119
|
+
</div>
|
|
120
|
+
<% end -%>
|
|
55
121
|
</div>
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
<div id="dead-
|
|
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
|
-
|
|
35
|
+
<td style="white-space: nowrap;">
|
|
36
|
+
<% if job.x_stream -%><code><%= h(job.x_stream) %></code><% else -%><span class="text-muted">—</span><% end -%>
|
|
40
37
|
</td>
|
|
41
|
-
<td style="
|
|
42
|
-
<% if job.
|
|
38
|
+
<td style="min-width: 420px;">
|
|
39
|
+
<% if job.error? -%>
|
|
43
40
|
<div class="error-message">
|
|
44
|
-
<strong><%= h(job.
|
|
45
|
-
<p><%= h(job.
|
|
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
|
-
|
|
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">—</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
|
-
|
|
57
|
-
|
|
69
|
+
title="Retry"
|
|
70
|
+
class="btn btn-primary btn-sm">↻</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
|
-
|
|
62
|
-
|
|
74
|
+
title="Delete"
|
|
75
|
+
class="btn btn-danger btn-sm">✕</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">✕</a>
|
|
68
|
+
</td>
|
|
59
69
|
</tr>
|
|
60
70
|
<% end -%>
|
|
61
71
|
</tbody>
|