cosmonats 0.7.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/README.md +199 -103
- data/lib/cosmo/active_job/adapter.rb +1 -2
- data/lib/cosmo/api/counter.rb +16 -16
- data/lib/cosmo/api/cron.rb +3 -0
- data/lib/cosmo/api/registry.rb +1 -1
- data/lib/cosmo/api/stats/busy.rb +1 -1
- data/lib/cosmo/api/stats/metrics.rb +109 -0
- data/lib/cosmo/api/stats/processes.rb +14 -4
- data/lib/cosmo/api/stats/totals.rb +4 -12
- data/lib/cosmo/api/stats.rb +3 -2
- data/lib/cosmo/batch/counters.rb +19 -0
- data/lib/cosmo/batch.rb +6 -6
- data/lib/cosmo/cli.rb +21 -5
- data/lib/cosmo/client.rb +28 -2
- data/lib/cosmo/config/cosmo.yml +58 -0
- data/lib/cosmo/config/settings.rb +20 -0
- data/lib/cosmo/config.rb +144 -19
- data/lib/cosmo/heartbeat.rb +11 -0
- data/lib/cosmo/job/data.rb +17 -12
- data/lib/cosmo/job/enqueuer.rb +53 -0
- data/lib/cosmo/job/limit.rb +2 -2
- data/lib/cosmo/job/processor.rb +27 -52
- data/lib/cosmo/job/requeue.rb +20 -0
- data/lib/cosmo/job.rb +8 -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/{sentry/job_processor_middleware.rb → middleware/sentry.rb} +26 -30
- data/lib/cosmo/middleware/totals.rb +29 -0
- data/lib/cosmo/middleware.rb +7 -0
- data/lib/cosmo/processor.rb +11 -5
- 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/processor.rb +7 -1
- data/lib/cosmo/utils/hash.rb +7 -8
- data/lib/cosmo/version.rb +1 -1
- data/lib/cosmo/web/assets/app.css +58 -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 +16 -2
- data/lib/cosmo/web/controllers/metrics.rb +48 -0
- data/lib/cosmo/web/helpers/application.rb +10 -0
- data/lib/cosmo/web/views/batches/_table.erb +11 -0
- data/lib/cosmo/web/views/jobs/_processes.erb +49 -35
- 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/index.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.rb +3 -0
- data/lib/cosmo.rb +38 -0
- data/sig/cosmo/api/counter.rbs +2 -3
- data/sig/cosmo/api/stats/busy.rbs +1 -1
- data/sig/cosmo/api/stats/metrics.rbs +42 -0
- data/sig/cosmo/api/stats/processes.rbs +3 -0
- data/sig/cosmo/api/stats/totals.rbs +3 -2
- data/sig/cosmo/batch/counters.rbs +10 -0
- data/sig/cosmo/batch.rbs +3 -4
- data/sig/cosmo/cli.rbs +2 -0
- data/sig/cosmo/client.rbs +3 -1
- data/sig/cosmo/config/settings.rbs +31 -0
- data/sig/cosmo/config.rbs +59 -5
- data/sig/cosmo/heartbeat.rbs +2 -0
- data/sig/cosmo/job/data.rbs +5 -3
- data/sig/cosmo/job/enqueuer.rbs +11 -0
- data/sig/cosmo/job/processor.rbs +3 -5
- data/sig/cosmo/job/requeue.rbs +9 -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 +2 -0
- data/sig/cosmo/publisher.rbs +1 -9
- data/sig/cosmo/services.rbs +18 -0
- data/sig/cosmo/stream/processor.rbs +2 -0
- data/sig/cosmo/utils/hash.rbs +2 -3
- data/sig/cosmo.rbs +11 -0
- metadata +30 -3
- data/lib/cosmo/sentry/auto.rb +0 -6
data/lib/cosmo/processor.rb
CHANGED
|
@@ -86,9 +86,10 @@ module Cosmo
|
|
|
86
86
|
end
|
|
87
87
|
all_paused = false
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
key = backoff_key(config)
|
|
90
|
+
_, skip_t = consumer_state[key]
|
|
90
91
|
if skip_t && Time.now < skip_t
|
|
91
|
-
Logger.trace "
|
|
92
|
+
Logger.trace "#{key} is empty, backing off"
|
|
92
93
|
next
|
|
93
94
|
end
|
|
94
95
|
all_empty = false
|
|
@@ -96,7 +97,7 @@ module Cosmo
|
|
|
96
97
|
begin
|
|
97
98
|
@pool.post do
|
|
98
99
|
# Re-check, after possibly being blocked on post to thread pool
|
|
99
|
-
_, skip_t = consumer_state[
|
|
100
|
+
_, skip_t = consumer_state[key]
|
|
100
101
|
next if skip_t && Time.now < skip_t
|
|
101
102
|
|
|
102
103
|
timeout = fetch_timeout(config)
|
|
@@ -105,11 +106,11 @@ module Cosmo
|
|
|
105
106
|
messages = fetch(subscription, batch_size: config[:batch_size], timeout:)
|
|
106
107
|
Logger.trace "fetched (#{messages&.size.to_i}) messages"
|
|
107
108
|
if messages&.any?
|
|
108
|
-
consumer_state.delete(
|
|
109
|
+
consumer_state.delete(key)
|
|
109
110
|
track_busy { process(messages, processor) }
|
|
110
111
|
else
|
|
111
112
|
max_backoff = Utils::Duration.parse(ENV.fetch("COSMO_STREAM_EMPTY_BACKOFF_MAX", STREAM_EMPTY_BACKOFF_MAX))
|
|
112
|
-
consumer_state.compute(
|
|
113
|
+
consumer_state.compute(key) do |(count, wake_at)|
|
|
113
114
|
next [count, wake_at] if wake_at && wake_at > started_at
|
|
114
115
|
|
|
115
116
|
count = count.to_i + 1
|
|
@@ -179,6 +180,7 @@ module Cosmo
|
|
|
179
180
|
rescue StandardError => e
|
|
180
181
|
Logger.error "Snap! Error just happened"
|
|
181
182
|
Logger.error "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}"
|
|
183
|
+
Cosmo.handle_error(e, { source: :fetch })
|
|
182
184
|
|
|
183
185
|
backoff = ENV.fetch("COSMO_STREAMS_FETCH_BACKOFF", 5).to_f
|
|
184
186
|
sleep([timeout, backoff].max) # backoff before retry
|
|
@@ -196,5 +198,9 @@ module Cosmo
|
|
|
196
198
|
def consumer_state
|
|
197
199
|
@consumer_state ||= Concurrent::Map.new
|
|
198
200
|
end
|
|
201
|
+
|
|
202
|
+
def backoff_key(config)
|
|
203
|
+
config[:stream].to_s
|
|
204
|
+
end
|
|
199
205
|
end
|
|
200
206
|
end
|
data/lib/cosmo/publisher.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Cosmo
|
|
|
7
7
|
class << self
|
|
8
8
|
extend Forwardable
|
|
9
9
|
|
|
10
|
-
delegate %i[publish
|
|
10
|
+
delegate %i[publish] => :instance
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def self.instance
|
|
@@ -22,17 +22,5 @@ module Cosmo
|
|
|
22
22
|
payload = (serializer || Stream::Serializer).serialize(data)
|
|
23
23
|
@client.publish(subject, payload, **options)
|
|
24
24
|
end
|
|
25
|
-
|
|
26
|
-
def publish_job(data)
|
|
27
|
-
subject, payload, params = data.to_args
|
|
28
|
-
@client.publish(subject, payload, **params)
|
|
29
|
-
data.jid
|
|
30
|
-
rescue NATS::JetStream::Error::NoStreamResponse
|
|
31
|
-
raise StreamNotFoundError, params[:stream].to_s
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def publish_batch(subject, batch, **options)
|
|
35
|
-
batch.each { publish(subject, _1, **options) }
|
|
36
|
-
end
|
|
37
25
|
end
|
|
38
26
|
end
|
data/lib/cosmo/railtie.rb
CHANGED
|
@@ -12,10 +12,10 @@ module Cosmo
|
|
|
12
12
|
require "cosmo/active_job"
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
#
|
|
15
|
+
# Loads config/cosmo.yml, or the built-in defaults without one, unless a config has been loaded already.
|
|
16
16
|
initializer "cosmo.load_config", after: "cosmo.active_job_adapter" do |app|
|
|
17
17
|
config_path = app.root.join("config", "cosmo.yml")
|
|
18
|
-
Config.load(config_path.
|
|
18
|
+
Config.load(config_path.exist? ? config_path.to_s : nil) if Config.instance.none?
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cosmo
|
|
4
|
+
# The streams Cosmo itself depends on. They are built from the settings in Cosmo.configure, never from cosmo.yml,
|
|
5
|
+
# and `cosmo --setup` creates them next to the user's streams.
|
|
6
|
+
module Services
|
|
7
|
+
SCHEDULED = Job::StreamFilter::SCHEDULED.to_s
|
|
8
|
+
DEAD = "dead"
|
|
9
|
+
METADATA = { "_cosmo.type" => "jobs" }.freeze
|
|
10
|
+
RETENTION_CHANGE = 10_052
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Holds delayed jobs and every cron template. It alone enables NATS message scheduling, since a schedule can only
|
|
15
|
+
# target a subject its own stream covers, and NATS refuses +discard: new+ wherever scheduling is enabled.
|
|
16
|
+
#
|
|
17
|
+
# @return [Hash, nil] nil when scheduling is turned off
|
|
18
|
+
def scheduled_stream
|
|
19
|
+
return unless Config.scheduled.enabled
|
|
20
|
+
|
|
21
|
+
{ storage: "file", retention: "workqueue", discard: "old", allow_direct: true, allow_msg_schedules: true,
|
|
22
|
+
duplicate_window: Config.to_ns(120), subjects: ["jobs.#{SCHEDULED}.>", "#{API::Cron::Entry::SUBJECT_PREFIX}.>"],
|
|
23
|
+
num_replicas: Config.replicas, description: "Scheduled jobs and cron schedules", metadata: METADATA }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# +max_deliver+ stays above 1: the scheduler naks a message that is not yet due, and one whose dispatch failed,
|
|
27
|
+
# and both need another delivery.
|
|
28
|
+
#
|
|
29
|
+
# @return [Hash, nil] nil when scheduling is turned off
|
|
30
|
+
def scheduled_consumer
|
|
31
|
+
return unless Config.scheduled.enabled
|
|
32
|
+
|
|
33
|
+
{ ack_policy: "explicit", max_deliver: 5, max_ack_pending: 100, ack_wait: 10, subject: "jobs.#{SCHEDULED}.>" }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Hash, nil] nil when dead-lettering is turned off
|
|
37
|
+
def dead_stream
|
|
38
|
+
dead = Config.dead
|
|
39
|
+
return unless dead.enabled
|
|
40
|
+
|
|
41
|
+
{ storage: "file", retention: "workqueue", discard: "old", allow_direct: true, duplicate_window: Config.to_ns(120),
|
|
42
|
+
max_age: Config.to_ns(Utils::Duration.parse(dead.max_age)), max_msgs: dead.max_msgs, max_bytes: dead.max_bytes,
|
|
43
|
+
subjects: ["jobs.#{DEAD}.>"], num_replicas: Config.replicas, description: "Dead jobs", metadata: METADATA }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Creates or updates every enabled service stream, plus the internal totals and batch counters and, while enabled,
|
|
47
|
+
# the job metrics.
|
|
48
|
+
#
|
|
49
|
+
# @return [Array<String>] the names of the service streams set up, without the internal ones
|
|
50
|
+
def setup!
|
|
51
|
+
streams = { SCHEDULED => scheduled_stream, DEAD => dead_stream }.compact
|
|
52
|
+
streams.each { |name, config| setup_stream(name, config) }
|
|
53
|
+
API::Stats::Totals.setup!
|
|
54
|
+
Batch::Counters.setup!
|
|
55
|
+
API::Stats::Metrics.setup! if Config.metrics.enabled
|
|
56
|
+
streams.keys
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @raise [Error] when an existing stream has another retention policy, which NATS can't change in place
|
|
60
|
+
def setup_stream(name, config)
|
|
61
|
+
Client.instance.setup_stream(name, config)
|
|
62
|
+
rescue NATS::JetStream::Error::APIError => e
|
|
63
|
+
raise unless e.err_code == RETENTION_CHANGE
|
|
64
|
+
|
|
65
|
+
raise Error, "The existing `#{name}` stream has another retention policy, which NATS can't change in place: " \
|
|
66
|
+
"delete it (`nats stream rm #{name}`, dropping its messages) and rerun `cosmo --setup`"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -38,8 +38,10 @@ module Cosmo
|
|
|
38
38
|
processor.process(messages)
|
|
39
39
|
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "done" }
|
|
40
40
|
rescue StandardError => e
|
|
41
|
-
Logger.
|
|
41
|
+
Logger.error e
|
|
42
42
|
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail" }
|
|
43
|
+
Cosmo.handle_error(e, { source: :stream, stream: metadata&.stream, processor: processor.class.name,
|
|
44
|
+
size: messages&.size })
|
|
43
45
|
rescue Exception # rubocop:disable Lint/RescueException
|
|
44
46
|
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail" }
|
|
45
47
|
raise
|
|
@@ -70,6 +72,10 @@ module Cosmo
|
|
|
70
72
|
config.dig(:consumer, :subjects)
|
|
71
73
|
end
|
|
72
74
|
|
|
75
|
+
def backoff_key(config)
|
|
76
|
+
config[:consumer_name] || config[:class].name
|
|
77
|
+
end
|
|
78
|
+
|
|
73
79
|
def fetch_timeout(config)
|
|
74
80
|
timeout = config[:fetch_timeout].to_f
|
|
75
81
|
if timeout <= 0
|
data/lib/cosmo/utils/hash.rb
CHANGED
|
@@ -36,14 +36,13 @@ module Cosmo
|
|
|
36
36
|
end
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
target[last_key] = value
|
|
39
|
+
# Nested hashes merge recursively, +other+ winning; any other value in +other+ replaces the one in +base+.
|
|
40
|
+
#
|
|
41
|
+
# @param base [::Hash]
|
|
42
|
+
# @param other [::Hash]
|
|
43
|
+
# @return [::Hash] a new hash
|
|
44
|
+
def deep_merge(base, other)
|
|
45
|
+
base.merge(other) { |_, old, new| old.is_a?(::Hash) && new.is_a?(::Hash) ? deep_merge(old, new) : new }
|
|
47
46
|
end
|
|
48
47
|
|
|
49
48
|
# deep dup
|
data/lib/cosmo/version.rb
CHANGED
|
@@ -439,6 +439,15 @@ td {
|
|
|
439
439
|
tr:last-child td { border-bottom: none; }
|
|
440
440
|
tr:hover { background: var(--color-table-bg-alt); }
|
|
441
441
|
|
|
442
|
+
tr.group-row td,
|
|
443
|
+
tr.group-row:hover { background: var(--color-table-bg-alt); }
|
|
444
|
+
tr.group-row td {
|
|
445
|
+
color: var(--color-text-light);
|
|
446
|
+
font-size: var(--font-size-small);
|
|
447
|
+
font-weight: 600;
|
|
448
|
+
padding: var(--space) var(--space-2x);
|
|
449
|
+
}
|
|
450
|
+
|
|
442
451
|
/* ── Subjects ──────────────────────────────────────────────────────────── */
|
|
443
452
|
.subjects { display: flex; flex-wrap: wrap; gap: var(--space-1-2); }
|
|
444
453
|
|
|
@@ -539,6 +548,15 @@ details summary {
|
|
|
539
548
|
details summary code { display: inline; user-select: text; }
|
|
540
549
|
details[open] summary { margin-bottom: var(--space); }
|
|
541
550
|
details code { display: block; max-width: 400px; white-space: pre-wrap; }
|
|
551
|
+
details summary:has(> code.truncate) { white-space: nowrap; }
|
|
552
|
+
details summary code.truncate {
|
|
553
|
+
display: inline-block;
|
|
554
|
+
max-width: 32ch;
|
|
555
|
+
overflow: hidden;
|
|
556
|
+
text-overflow: ellipsis;
|
|
557
|
+
vertical-align: middle;
|
|
558
|
+
white-space: nowrap;
|
|
559
|
+
}
|
|
542
560
|
|
|
543
561
|
/* ── HTMX ──────────────────────────────────────────────────────────────── */
|
|
544
562
|
.htmx-indicator { opacity: 0; transition: opacity 200ms; }
|
|
@@ -563,6 +581,46 @@ details code { display: block; max-width: 400px; white-space: pre-wrap; }
|
|
|
563
581
|
}
|
|
564
582
|
|
|
565
583
|
/* ── Responsive ────────────────────────────────────────────────────────── */
|
|
584
|
+
|
|
585
|
+
.metrics-chart {
|
|
586
|
+
background: var(--color-elevated);
|
|
587
|
+
border-radius: var(--space-1-2);
|
|
588
|
+
box-shadow: 0 1px 3px var(--color-shadow);
|
|
589
|
+
padding: var(--space-2x) var(--space-3x);
|
|
590
|
+
margin-bottom: var(--space-3x);
|
|
591
|
+
}
|
|
592
|
+
.metrics-chart svg { width: 100%; height: auto; display: block; }
|
|
593
|
+
.metrics-chart-header {
|
|
594
|
+
display: flex;
|
|
595
|
+
justify-content: space-between;
|
|
596
|
+
flex-wrap: wrap;
|
|
597
|
+
gap: var(--space);
|
|
598
|
+
margin-bottom: var(--space);
|
|
599
|
+
}
|
|
600
|
+
.metrics-legend { display: flex; gap: var(--space-2x); font-size: var(--font-size-small); color: var(--color-text-light); }
|
|
601
|
+
.metrics-legend span::before {
|
|
602
|
+
content: "";
|
|
603
|
+
display: inline-block;
|
|
604
|
+
width: 10px;
|
|
605
|
+
height: 10px;
|
|
606
|
+
margin-right: 6px;
|
|
607
|
+
border-radius: 2px;
|
|
608
|
+
}
|
|
609
|
+
.metrics-legend-runs::before { background: var(--color-primary); opacity: 0.45; }
|
|
610
|
+
.metrics-legend-exec::before { background: var(--color-warning); }
|
|
611
|
+
.chart-grid { stroke: var(--color-border); stroke-width: 1; }
|
|
612
|
+
.chart-axis { fill: var(--color-text-light); font-size: 11px; font-family: var(--font-sans); }
|
|
613
|
+
.chart-bar { fill: var(--color-primary); opacity: 0.45; }
|
|
614
|
+
.chart-bar:hover { opacity: 0.7; }
|
|
615
|
+
.chart-line { fill: none; stroke: var(--color-warning); stroke-width: 2; }
|
|
616
|
+
.chart-point { fill: var(--color-warning); stroke: var(--color-elevated); stroke-width: 2; }
|
|
617
|
+
.metrics-hint { margin: 0 0 var(--space); font-size: var(--font-size-small); color: var(--color-text-light); }
|
|
618
|
+
.metrics-table tr.selectable { cursor: pointer; }
|
|
619
|
+
.metrics-table tr.selectable:hover { background: var(--color-selected); }
|
|
620
|
+
.metrics-table tr.selected { background: var(--color-selected); }
|
|
621
|
+
.metrics-table tr.selected td:first-child { box-shadow: inset 3px 0 0 var(--color-primary); }
|
|
622
|
+
.metrics-table tr.selected code { color: var(--color-primary); }
|
|
623
|
+
|
|
566
624
|
@media (max-width: 768px) {
|
|
567
625
|
.nav { flex-wrap: wrap; }
|
|
568
626
|
.nav-list { order: 3; width: 100%; margin-top: var(--space); }
|
|
@@ -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,6 +55,8 @@ 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)
|
|
@@ -62,12 +71,16 @@ module Cosmo
|
|
|
62
71
|
end
|
|
63
72
|
|
|
64
73
|
def _scheduled
|
|
74
|
+
return not_found unless Config.scheduled.enabled
|
|
75
|
+
|
|
65
76
|
stream = API::Stream.new("scheduled")
|
|
66
77
|
jobs = stream.messages(page: params["page"], limit: params["limit"])
|
|
67
78
|
ok render("jobs/_scheduled", { jobs: jobs, total: stream.total })
|
|
68
79
|
end
|
|
69
80
|
|
|
70
81
|
def _dead
|
|
82
|
+
return not_found unless Config.dead.enabled
|
|
83
|
+
|
|
71
84
|
stream = API::Stream.new("dead")
|
|
72
85
|
jobs = stream.messages(page: params["page"], limit: params["limit"])
|
|
73
86
|
ok render("jobs/_dead", { jobs: jobs, total: stream.total })
|
|
@@ -86,7 +99,8 @@ module Cosmo
|
|
|
86
99
|
total = processes.size
|
|
87
100
|
page, limit, total_pages = paginate(total, API::Stats::Processes::LIMIT)
|
|
88
101
|
polling = params["poll"].to_s != "0"
|
|
89
|
-
|
|
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: })
|
|
90
104
|
end
|
|
91
105
|
|
|
92
106
|
def _enqueued # rubocop:disable Metrics/AbcSize
|
|
@@ -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
|
|
@@ -42,6 +42,16 @@ module Cosmo
|
|
|
42
42
|
value.to_s
|
|
43
43
|
end
|
|
44
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
|
+
|
|
45
55
|
def elapsed(value)
|
|
46
56
|
elapsed = Time.now.to_i - value.to_i
|
|
47
57
|
|
|
@@ -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>
|