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.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +199 -103
  3. data/lib/cosmo/active_job/adapter.rb +1 -2
  4. data/lib/cosmo/api/counter.rb +16 -16
  5. data/lib/cosmo/api/cron.rb +3 -0
  6. data/lib/cosmo/api/registry.rb +1 -1
  7. data/lib/cosmo/api/stats/busy.rb +1 -1
  8. data/lib/cosmo/api/stats/metrics.rb +109 -0
  9. data/lib/cosmo/api/stats/processes.rb +14 -4
  10. data/lib/cosmo/api/stats/totals.rb +4 -12
  11. data/lib/cosmo/api/stats.rb +3 -2
  12. data/lib/cosmo/batch/counters.rb +19 -0
  13. data/lib/cosmo/batch.rb +6 -6
  14. data/lib/cosmo/cli.rb +21 -5
  15. data/lib/cosmo/client.rb +28 -2
  16. data/lib/cosmo/config/cosmo.yml +58 -0
  17. data/lib/cosmo/config/settings.rb +20 -0
  18. data/lib/cosmo/config.rb +144 -19
  19. data/lib/cosmo/heartbeat.rb +11 -0
  20. data/lib/cosmo/job/data.rb +17 -12
  21. data/lib/cosmo/job/enqueuer.rb +53 -0
  22. data/lib/cosmo/job/limit.rb +2 -2
  23. data/lib/cosmo/job/processor.rb +27 -52
  24. data/lib/cosmo/job/requeue.rb +20 -0
  25. data/lib/cosmo/job.rb +8 -22
  26. data/lib/cosmo/logger.rb +15 -2
  27. data/lib/cosmo/middleware/busy.rb +15 -0
  28. data/lib/cosmo/middleware/chain.rb +137 -0
  29. data/lib/cosmo/middleware/limit.rb +50 -0
  30. data/lib/cosmo/middleware/metrics.rb +39 -0
  31. data/lib/cosmo/{sentry/job_processor_middleware.rb → middleware/sentry.rb} +26 -30
  32. data/lib/cosmo/middleware/totals.rb +29 -0
  33. data/lib/cosmo/middleware.rb +7 -0
  34. data/lib/cosmo/processor.rb +11 -5
  35. data/lib/cosmo/publisher.rb +1 -13
  36. data/lib/cosmo/railtie.rb +2 -2
  37. data/lib/cosmo/services.rb +69 -0
  38. data/lib/cosmo/stream/processor.rb +7 -1
  39. data/lib/cosmo/utils/hash.rb +7 -8
  40. data/lib/cosmo/version.rb +1 -1
  41. data/lib/cosmo/web/assets/app.css +58 -0
  42. data/lib/cosmo/web/chart.rb +98 -0
  43. data/lib/cosmo/web/controllers/crons.rb +8 -0
  44. data/lib/cosmo/web/controllers/jobs.rb +16 -2
  45. data/lib/cosmo/web/controllers/metrics.rb +48 -0
  46. data/lib/cosmo/web/helpers/application.rb +10 -0
  47. data/lib/cosmo/web/views/batches/_table.erb +11 -0
  48. data/lib/cosmo/web/views/jobs/_processes.erb +49 -35
  49. data/lib/cosmo/web/views/jobs/_stats.erb +4 -0
  50. data/lib/cosmo/web/views/jobs/_tabs.erb +6 -0
  51. data/lib/cosmo/web/views/jobs/index.erb +1 -1
  52. data/lib/cosmo/web/views/layout.erb +1 -1
  53. data/lib/cosmo/web/views/metrics/_chart.erb +36 -0
  54. data/lib/cosmo/web/views/metrics/_panel.erb +13 -0
  55. data/lib/cosmo/web/views/metrics/_table.erb +41 -0
  56. data/lib/cosmo/web/views/metrics/index.erb +17 -0
  57. data/lib/cosmo/web.rb +3 -0
  58. data/lib/cosmo.rb +38 -0
  59. data/sig/cosmo/api/counter.rbs +2 -3
  60. data/sig/cosmo/api/stats/busy.rbs +1 -1
  61. data/sig/cosmo/api/stats/metrics.rbs +42 -0
  62. data/sig/cosmo/api/stats/processes.rbs +3 -0
  63. data/sig/cosmo/api/stats/totals.rbs +3 -2
  64. data/sig/cosmo/batch/counters.rbs +10 -0
  65. data/sig/cosmo/batch.rbs +3 -4
  66. data/sig/cosmo/cli.rbs +2 -0
  67. data/sig/cosmo/client.rbs +3 -1
  68. data/sig/cosmo/config/settings.rbs +31 -0
  69. data/sig/cosmo/config.rbs +59 -5
  70. data/sig/cosmo/heartbeat.rbs +2 -0
  71. data/sig/cosmo/job/data.rbs +5 -3
  72. data/sig/cosmo/job/enqueuer.rbs +11 -0
  73. data/sig/cosmo/job/processor.rbs +3 -5
  74. data/sig/cosmo/job/requeue.rbs +9 -0
  75. data/sig/cosmo/job.rbs +0 -2
  76. data/sig/cosmo/logger.rbs +6 -0
  77. data/sig/cosmo/middleware/chain.rbs +51 -0
  78. data/sig/cosmo/middleware/sentry.rbs +14 -0
  79. data/sig/cosmo/middleware.rbs +35 -0
  80. data/sig/cosmo/processor.rbs +2 -0
  81. data/sig/cosmo/publisher.rbs +1 -9
  82. data/sig/cosmo/services.rbs +18 -0
  83. data/sig/cosmo/stream/processor.rbs +2 -0
  84. data/sig/cosmo/utils/hash.rbs +2 -3
  85. data/sig/cosmo.rbs +11 -0
  86. metadata +30 -3
  87. data/lib/cosmo/sentry/auto.rb +0 -6
@@ -86,9 +86,10 @@ module Cosmo
86
86
  end
87
87
  all_paused = false
88
88
 
89
- _, skip_t = consumer_state[stream_name]
89
+ key = backoff_key(config)
90
+ _, skip_t = consumer_state[key]
90
91
  if skip_t && Time.now < skip_t
91
- Logger.trace "stream #{stream_name} is empty, backing off"
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[stream_name]
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(stream_name)
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(stream_name) do |(count, wake_at)|
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
@@ -7,7 +7,7 @@ module Cosmo
7
7
  class << self
8
8
  extend Forwardable
9
9
 
10
- delegate %i[publish publish_job publish_batch] => :instance
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
- # Autoload config/cosmo.yml when it exists and no config has been loaded yet.
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.to_s) if config_path.exist? && Config.instance.none?
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.debug e
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
@@ -36,14 +36,13 @@ module Cosmo
36
36
  end
37
37
  end
38
38
 
39
- # deep set
40
- def set(hash, *keys, value)
41
- last_key = keys.pop
42
- target = keys.reduce(hash) do |base, key|
43
- base[key] ||= {}
44
- base[key]
45
- end
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cosmo
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -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
- 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,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
- ok render("jobs/_processes", { processes: processes.list(page:, limit:), total:, page:, limit:, total_pages:, polling: })
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">&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>