cosmonats 0.3.0 → 0.4.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +208 -156
  3. data/lib/cosmo/active_job/adapter.rb +46 -0
  4. data/lib/cosmo/active_job/executor.rb +16 -0
  5. data/lib/cosmo/active_job/options.rb +50 -0
  6. data/lib/cosmo/active_job.rb +29 -0
  7. data/lib/cosmo/api/busy.rb +2 -2
  8. data/lib/cosmo/api/counter.rb +2 -2
  9. data/lib/cosmo/api/cron/entry.rb +99 -0
  10. data/lib/cosmo/api/cron.rb +118 -0
  11. data/lib/cosmo/api/kv.rb +35 -13
  12. data/lib/cosmo/api/stream.rb +10 -5
  13. data/lib/cosmo/api.rb +1 -0
  14. data/lib/cosmo/cli.rb +27 -10
  15. data/lib/cosmo/client.rb +48 -2
  16. data/lib/cosmo/config.rb +9 -0
  17. data/lib/cosmo/job/data.rb +1 -1
  18. data/lib/cosmo/job/limit.rb +51 -0
  19. data/lib/cosmo/job/processor.rb +49 -5
  20. data/lib/cosmo/job.rb +51 -2
  21. data/lib/cosmo/processor.rb +1 -1
  22. data/lib/cosmo/railtie.rb +21 -0
  23. data/lib/cosmo/stream/processor.rb +2 -2
  24. data/lib/cosmo/stream.rb +2 -1
  25. data/lib/cosmo/utils/hash.rb +13 -0
  26. data/lib/cosmo/utils/overrides.rb +1 -1
  27. data/lib/cosmo/version.rb +1 -1
  28. data/lib/cosmo/web/assets/app.css +42 -0
  29. data/lib/cosmo/web/controllers/crons.rb +41 -0
  30. data/lib/cosmo/web/controllers/jobs.rb +7 -3
  31. data/lib/cosmo/web/controllers/streams.rb +1 -1
  32. data/lib/cosmo/web/helpers/application.rb +4 -0
  33. data/lib/cosmo/web/views/actions/index.erb +1 -1
  34. data/lib/cosmo/web/views/crons/_table.erb +58 -0
  35. data/lib/cosmo/web/views/crons/index.erb +10 -0
  36. data/lib/cosmo/web/views/jobs/_busy.erb +54 -49
  37. data/lib/cosmo/web/views/jobs/_dead.erb +70 -65
  38. data/lib/cosmo/web/views/jobs/_enqueued.erb +82 -56
  39. data/lib/cosmo/web/views/jobs/_scheduled.erb +53 -48
  40. data/lib/cosmo/web/views/jobs/_tabs.erb +6 -0
  41. data/lib/cosmo/web/views/jobs/busy.erb +8 -6
  42. data/lib/cosmo/web/views/jobs/dead.erb +6 -5
  43. data/lib/cosmo/web/views/jobs/enqueued.erb +8 -6
  44. data/lib/cosmo/web/views/jobs/index.erb +1 -1
  45. data/lib/cosmo/web/views/jobs/scheduled.erb +6 -5
  46. data/lib/cosmo/web/views/layout.erb +1 -1
  47. data/lib/cosmo/web.rb +5 -0
  48. data/lib/cosmo.rb +1 -0
  49. data/sig/cosmo/active_job/adapter.rbs +13 -0
  50. data/sig/cosmo/active_job/executor.rbs +9 -0
  51. data/sig/cosmo/active_job/options.rbs +14 -0
  52. data/sig/cosmo/api/cron/entry.rbs +30 -0
  53. data/sig/cosmo/api/cron.rbs +25 -0
  54. data/sig/cosmo/api/kv.rbs +4 -6
  55. data/sig/cosmo/client.rbs +9 -1
  56. data/sig/cosmo/job/data.rbs +1 -1
  57. data/sig/cosmo/job/limit.rbs +18 -0
  58. data/sig/cosmo/job/processor.rbs +3 -1
  59. data/sig/cosmo/job.rbs +9 -4
  60. data/sig/cosmo/railtie.rbs +4 -0
  61. data/sig/cosmo/utils/hash.rbs +4 -0
  62. metadata +20 -1
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module Job
5
+ # Distributed concurrency limiter backed by NATS Key-Value with per-message TTL.
6
+ #
7
+ # Each unit of concurrency is a numbered KV slot:
8
+ # "{concurrency_key}/0", "{concurrency_key}/1", ..., "{concurrency_key}/{limit-1}"
9
+ #
10
+ # Acquiring a slot is a single atomic `set` (CAS with last-revision=0).
11
+ # Only one worker can win a given slot; losers try the next number.
12
+ # When a job finishes the slot is deleted; if the worker crashes NATS
13
+ # expires it automatically via the per-message Nats-TTL header.
14
+ class Limit
15
+ BUCKET = "cosmo_jobs_limits"
16
+
17
+ def self.instance
18
+ @instance ||= new
19
+ end
20
+
21
+ def initialize
22
+ @kv = API::KV.new(BUCKET, allow_msg_ttl: true)
23
+ end
24
+
25
+ # Try to acquire one of the numbered slots for +key+.
26
+ #
27
+ # @param key [String] concurrency key
28
+ # @param jid [String] stored as the slot value for observability
29
+ # @param limit [Integer] number of slots (0 … limit-1)
30
+ # @param duration [Integer] seconds before the slot is auto-expired by NATS
31
+ # @return [String, nil] the acquired slot key, or nil when all slots are taken
32
+ def acquire(key, jid:, limit:, duration:)
33
+ 0.upto(limit - 1) do |i|
34
+ slot = "#{key}/#{i}"
35
+ @kv.set(slot, jid, ttl: duration)
36
+ return slot
37
+ rescue NATS::KeyValue::KeyWrongLastSequenceError
38
+ next # slot is live, try the next one
39
+ end
40
+ nil # all slots occupied
41
+ end
42
+
43
+ # Release a previously acquired slot.
44
+ def release(slot)
45
+ @kv.delete(slot)
46
+ rescue NATS::Error
47
+ # best effort — slot TTL will reclaim it if delete fails
48
+ end
49
+ end
50
+ end
51
+ end
@@ -1,11 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "timeout"
4
+
3
5
  module Cosmo
4
6
  module Job
5
7
  class Processor < ::Cosmo::Processor
6
8
  private
7
9
 
8
10
  def setup
11
+ # Initialize singletons before starting to process messages
12
+ API::Busy.instance
13
+ API::Counter.instance
14
+ Limit.instance
15
+
9
16
  jobs_config = Config.dig(:consumers, :jobs)
10
17
  jobs_config&.each do |stream_name, config|
11
18
  next if stream_name == :scheduled # scheduled jobs are handled in schedule_loop
@@ -44,7 +51,7 @@ module Cosmo
44
51
  end
45
52
  end
46
53
 
47
- def process(messages, _) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
54
+ def process(messages, _) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
48
55
  message = messages.first
49
56
  Logger.debug "received messages #{messages.inspect}"
50
57
  data = Utils::Json.parse(message.data)
@@ -61,30 +68,67 @@ module Cosmo
61
68
  return
62
69
  end
63
70
 
71
+ if worker_class.limits_concurrency?
72
+ slot = acquire_concurrency_slot(worker_class, message, data)
73
+ return if slot == false
74
+ end
75
+
76
+ duration = worker_class.default_options[:limit]&.dig(:duration)&.to_i
77
+
64
78
  with_stats(message) do
65
79
  sw = stopwatch
66
80
  Logger.with(jid: data[:jid])
67
81
  Logger.info "start"
68
82
  instance = worker_class.new
69
83
  instance.jid = data[:jid]
70
- instance.perform(*data[:args])
84
+ if duration
85
+ Timeout.timeout(duration) { instance.perform(*data[:args]) }
86
+ else
87
+ instance.perform(*data[:args])
88
+ end
71
89
  message.ack
72
90
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "done" }
73
91
  true
92
+ rescue Timeout::Error
93
+ Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[timeout]" }
94
+ dropped = handle_failure(message, data)
95
+ false if dropped
74
96
  rescue StandardError => e
75
97
  Logger.debug e
76
- Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail" }
98
+ Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[error]" }
77
99
  dropped = handle_failure(message, data)
78
100
  false if dropped
79
101
  rescue Exception # rubocop:disable Lint/RescueException
80
- Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail" }
102
+ Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[exception]" }
81
103
  raise
82
104
  end
83
105
  ensure
106
+ Limit.instance.release(slot) if slot
84
107
  Logger.without(:jid)
85
108
  Logger.debug "processed message #{message.inspect}"
86
109
  end
87
110
 
111
+ # Tries to acquire a concurrency slot for the job.
112
+ # Returns the slot key (String) on success, or false if all slots are
113
+ # taken (message is NAK'd with a delay equal to +duration+ before returning).
114
+ def acquire_concurrency_slot(worker_class, message, data)
115
+ options = worker_class.concurrency_options
116
+ key = worker_class.concurrency_key(data[:args])
117
+
118
+ slot = Limit.instance.acquire(key, jid: data[:jid], limit: options[:limit], duration: options[:duration])
119
+ return slot if slot
120
+
121
+ message.nak(delay: options[:duration] * Config::NANO)
122
+ Logger.debug "concurrency limit reached for #{data[:class]}, re-queueing back #{data[:jid]}"
123
+ false
124
+ rescue NATS::Error => e
125
+ # Unexpected KV failure (e.g. transient NATS error). NAK immediately so
126
+ # the message is retried rather than stuck in-flight until ack_wait expires.
127
+ Logger.error e
128
+ message.nak
129
+ false
130
+ end
131
+
88
132
  def handle_failure(message, data) # rubocop:disable Naming/PredicateMethod
89
133
  current_attempt = message.metadata.num_delivered
90
134
  max_retries = data[:retry].to_i + 1
@@ -93,7 +137,7 @@ module Cosmo
93
137
  # NATS will auto-retry with delay (exponential backoff based on current attempt).
94
138
  # When max_deliver is reached, NATS stops redelivering the message and marks it as "max deliveries exceeded".
95
139
  # The message is effectively abandoned by NATS — it stays in the stream (consuming a slot) but will never be delivered again to that consumer.
96
- delay_ns = ((current_attempt**4) + 15) * 1_000_000_000
140
+ delay_ns = ((current_attempt**4) + 15) * Config::NANO
97
141
  message.nak(delay: delay_ns)
98
142
  return false
99
143
  end
data/lib/cosmo/job.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "cosmo/job/data"
4
+ require "cosmo/job/limit"
4
5
  require "cosmo/job/processor"
5
6
 
6
7
  module Cosmo
@@ -10,8 +11,56 @@ module Cosmo
10
11
  end
11
12
 
12
13
  module ClassMethods
13
- def options(stream: nil, retry: nil, dead: nil)
14
- default_options.merge!({ stream:, retry:, dead: }.compact)
14
+ # @option config [Symbol] :stream NATS stream to publish to (default: :default)
15
+ # @option config [Integer] :retry max delivery attempts before giving up (default: 3)
16
+ # @option config [Boolean] :dead move to dead-letter stream after retries exhausted (default: true)
17
+ # @option config [Hash] :limit execution limits:
18
+ #
19
+ # limit: { duration: 30 }
20
+ # limit: { duration: 30, concurrency: 3 }
21
+ # limit: { duration: 30, concurrency: { to: 3, key: ->(id) { id } } }
22
+ #
23
+ # @option config [Integer] :"limit[:duration]" hard execution timeout in seconds. The job thread is
24
+ # killed after this many seconds and counts as a failed attempt (retried with exponential backoff,
25
+ # moved to DLQ after retries exhausted).
26
+ # @option config [Integer, Hash] :"limit[:concurrency]" caps how many instances run at once across all
27
+ # workers. Jobs that cannot acquire a slot are NAK'd with a delay equal to +duration+ so they are not
28
+ # re-delivered until the slot is guaranteed free. Requires +duration+.
29
+ # Pass an Integer for a class-wide cap, or <tt>{ to: N, key: ->(args) {} }</tt> to scope per key.
30
+ def options(**config)
31
+ if config[:limit] && config.dig(:limit, :concurrency) && !config.dig(:limit, :duration).to_i.positive?
32
+ raise ArgumentError, "limit: duration is required when concurrency is set"
33
+ end
34
+
35
+ default_options.merge!(config)
36
+ end
37
+ alias cosmo_options options
38
+
39
+ def limits_concurrency?
40
+ !!concurrency_options
41
+ end
42
+
43
+ # Returns a normalized concurrency config hash, or +nil+ when not configured.
44
+ # Always contains +:limit+, +:key+, and +:duration+.
45
+ def concurrency_options
46
+ value = default_options.dig(:limit, :concurrency)
47
+ duration = default_options.dig(:limit, :duration).to_i
48
+ return unless value
49
+
50
+ case value
51
+ when Integer then { limit: value, key: nil, duration: duration }
52
+ when Hash then { limit: value.fetch(:to), key: value[:key], duration: duration }
53
+ end
54
+ end
55
+
56
+ # Derive the fully-scoped concurrency key for a given args array.
57
+ def concurrency_key(args)
58
+ config = concurrency_options
59
+ return unless config
60
+
61
+ base = Utils::String.underscore(name)
62
+ suffix = config[:key]&.call(*args)
63
+ suffix ? "#{base}/#{suffix}" : base
15
64
  end
16
65
 
17
66
  def perform(*args, async: true, **options)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cosmo
4
- class Processor # rubocop:disable Metrics/ClassLength
4
+ class Processor
5
5
  STREAM_PAUSED_RECHECK_TTL = 5.0 # Seconds a stream's paused state is cached before re-checking (override via COSMO_STREAM_PAUSED_RECHECK_TTL)
6
6
  STREAMS_PAUSED_IDLE_SLEEP = 1.0 # Seconds to sleep when every stream is paused, preventing a tight CPU spin (override via COSMO_STREAMS_PAUSED_IDLE_SLEEP)
7
7
  STREAM_EMPTY_BACKOFF_MAX = 5.0 # Max seconds to sleep between empty fetches (override via COSMO_STREAM_EMPTY_BACKOFF_MAX)
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ # Rails Railtie — loaded automatically when cosmonats is required inside a
5
+ # Rails application. Ensures the ActiveJob adapter constant is registered
6
+ # before Rails tries to resolve it and autoloads +config/cosmo.yml+ when
7
+ # the file is present.
8
+ class Railtie < ::Rails::Railtie
9
+ # Make Cosmo::ActiveJobAdapter::Adapter available under the conventional
10
+ # ActiveJob namespace so :cosmonats resolves without any extra requires.
11
+ initializer "cosmo.active_job_adapter", before: :run_prepare_callbacks do
12
+ require "cosmo/active_job"
13
+ end
14
+
15
+ # Autoload config/cosmo.yml when it exists and no config has been loaded yet.
16
+ initializer "cosmo.load_config", after: "cosmo.active_job_adapter" do |app|
17
+ config_path = app.root.join("config", "cosmo.yml")
18
+ Config.load(config_path.to_s) if config_path.exist? && Config.instance.none?
19
+ end
20
+ end
21
+ end
@@ -17,7 +17,7 @@ module Cosmo
17
17
  @configs.each { @consumers << subscribe(nil, _1) }
18
18
  end
19
19
 
20
- def process(messages, processor) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
20
+ def process(messages, processor) # rubocop:disable Metrics/AbcSize
21
21
  metadata = messages.last.metadata
22
22
  serializer = processor.class.default_options.dig(:publisher, :serializer)
23
23
  messages = messages.map { Message.new(_1, serializer:) }
@@ -49,7 +49,7 @@ module Cosmo
49
49
  end
50
50
 
51
51
  def dynamic_config
52
- Config.internal[:streams].map { _1.default_options.merge(class: _1) }
52
+ Config.internal[:streams]&.map { _1.default_options.merge(class: _1) }.to_a
53
53
  end
54
54
 
55
55
  def subscribe(_stream_name, config)
data/lib/cosmo/stream.rb CHANGED
@@ -12,10 +12,11 @@ module Cosmo
12
12
  end
13
13
 
14
14
  module ClassMethods
15
- def options(stream: nil, consumer_name: nil, batch_size: nil, fetch_timeout: nil, start_position: nil, consumer: nil, publisher: nil) # rubocop:disable Metrics/ParameterLists
15
+ def options(stream: nil, consumer_name: nil, batch_size: nil, fetch_timeout: nil, start_position: nil, consumer: nil, publisher: nil)
16
16
  register
17
17
  default_options.merge!({ stream:, consumer_name:, batch_size:, fetch_timeout:, start_position:, consumer:, publisher: }.compact)
18
18
  end
19
+ alias cosmo_options options
19
20
 
20
21
  def publish(data, subject: nil, **options)
21
22
  stream = default_options[:stream]
@@ -23,6 +23,19 @@ module Cosmo
23
23
  end
24
24
  end
25
25
 
26
+ def stringify_keys(obj)
27
+ case obj
28
+ when ::Hash
29
+ obj.each_with_object({}) do |(key, value), result|
30
+ result[key.to_s] = stringify_keys(value)
31
+ end
32
+ when ::Array
33
+ obj.map { |v| stringify_keys(v) }
34
+ else
35
+ obj
36
+ end
37
+ end
38
+
26
39
  # deep set
27
40
  def set(hash, *keys, value)
28
41
  last_key = keys.pop
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  Cosmo::Utils::Warnings.silence do
4
- members = NATS::JetStream::API::StreamConfig.members + [:allow_msg_counter]
4
+ members = NATS::JetStream::API::StreamConfig.members + %i[allow_msg_counter allow_msg_schedules]
5
5
  NATS::JetStream::API::StreamConfig = Struct.new(*members, keyword_init: true) do
6
6
  def initialize(opts = {})
7
7
  rem = opts.keys - members
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.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -292,6 +292,34 @@ section > header {
292
292
  z-index: auto;
293
293
  }
294
294
 
295
+ /* ── Section tabs ───────────────────────────────────────────────────────── */
296
+ .tabs {
297
+ display: flex;
298
+ gap: 0;
299
+ border-bottom: 2px solid var(--color-border);
300
+ margin-bottom: var(--space-3x);
301
+ }
302
+
303
+ .tabs a {
304
+ padding: var(--space) var(--space-3x);
305
+ text-decoration: none;
306
+ color: var(--color-text-light);
307
+ border-bottom: 2px solid transparent;
308
+ margin-bottom: -2px;
309
+ font-weight: 500;
310
+ transition: color 0.15s, border-color 0.15s;
311
+ }
312
+
313
+ .tabs a:hover {
314
+ color: var(--color-text);
315
+ border-bottom-color: var(--color-border);
316
+ }
317
+
318
+ .tabs a.active {
319
+ color: var(--color-primary);
320
+ border-bottom-color: var(--color-primary);
321
+ }
322
+
295
323
  section .nav {
296
324
  display: flex;
297
325
  gap: var(--space);
@@ -448,6 +476,20 @@ time {
448
476
  .stream-link { color: var(--color-primary); font-weight: 600; }
449
477
  .stream-link:hover { text-decoration: underline; }
450
478
 
479
+ /* ── Pagination ─────────────────────────────────────────────────────────── */
480
+ .pagination {
481
+ display: flex;
482
+ align-items: center;
483
+ justify-content: center;
484
+ gap: var(--space);
485
+ padding: var(--space-2x) 0 var(--space);
486
+ }
487
+ .btn-disabled {
488
+ opacity: 0.4;
489
+ cursor: default;
490
+ pointer-events: none;
491
+ }
492
+
451
493
  /* ── Actions ───────────────────────────────────────────────────────────── */
452
494
  .actions-form { display: flex; flex-direction: column; gap: var(--space-1-2); }
453
495
 
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cosmo/web/controllers/application"
4
+
5
+ module Cosmo
6
+ class Web
7
+ module Controllers
8
+ class Crons < Application
9
+ def index
10
+ content_for :title, "Crons"
11
+ ok render("crons/index", layout: true)
12
+ end
13
+
14
+ def _table
15
+ ok render("crons/_table", { schedules: cron.all })
16
+ end
17
+
18
+ # Dispatch the job immediately, bypassing the schedule timer.
19
+ # Expects params["subject"] = the schedule subject stored in NATS.
20
+ def run_now
21
+ subject = Rack::Utils.unescape(params["subject"].to_s)
22
+ cron.run_now!(subject)
23
+ ok
24
+ end
25
+
26
+ # Purge the schedule from NATS so it stops firing.
27
+ def delete
28
+ subject = Rack::Utils.unescape(params["subject"].to_s)
29
+ cron.delete!(subject)
30
+ _table
31
+ end
32
+
33
+ private
34
+
35
+ def cron
36
+ @cron ||= API::Cron.instance
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -72,12 +72,16 @@ module Cosmo
72
72
  ok render("jobs/_busy", { jobs: jobs, total: API::Busy.instance.size })
73
73
  end
74
74
 
75
- def _enqueued
75
+ def _enqueued # rubocop:disable Metrics/AbcSize
76
76
  stream_name, stream_names = streams
77
+ limit = (params["limit"] || API::Stream::LIMIT).to_i
78
+ page = [params["page"].to_i, 1].max
77
79
  stream = API::Stream.new(stream_name)
78
- jobs = stream.messages(page: params["page"], limit: params["limit"])
80
+ total = stream.total
81
+ jobs = stream.messages(page:, limit:)
82
+ total_pages = (total.to_f / limit).ceil
79
83
 
80
- ok render("jobs/_enqueued", { jobs:, total: stream.total, stream_name:, stream_names: })
84
+ ok render("jobs/_enqueued", { jobs:, total:, stream_name:, stream_names:, page:, limit:, total_pages: })
81
85
  end
82
86
 
83
87
  def _stats
@@ -40,7 +40,7 @@ module Cosmo
40
40
  end
41
41
 
42
42
  def _table
43
- streams = API::Stream.all.map { row_locals(_1) }
43
+ streams = API::Stream.all.reject { |s| s.name.start_with?("KV_") || s.name.start_with?("_cosmo") }.map { row_locals(_1) }
44
44
  ok render("streams/_table", { streams: streams })
45
45
  end
46
46
 
@@ -73,6 +73,10 @@ module Cosmo
73
73
  request_path == path
74
74
  end
75
75
 
76
+ def path_prefix?(*values)
77
+ values.any? { |v| @request.path_info.start_with?(v) }
78
+ end
79
+
76
80
  def referrer?(path)
77
81
  referrer_uri = URI(@request.referrer)
78
82
  referrer_path = referrer_uri.path
@@ -2,6 +2,6 @@
2
2
  <header></header>
3
3
 
4
4
  <div>
5
- <div class="alert alert-info">None</div>
5
+ <div class="alert alert-info">Coming soon, stay tuned</div>
6
6
  </div>
7
7
  </section>
@@ -0,0 +1,58 @@
1
+ <% if @schedules.empty? -%>
2
+ <div class="alert alert-success">
3
+ No cron schedules deployed in NATS.<br>
4
+ Add a <code>cron:</code> section to <code>cosmo.yml</code> and run
5
+ <code>cosmo --setup</code> to publish them.
6
+ </div>
7
+ <% else -%>
8
+ <div class="table-container">
9
+ <table>
10
+ <thead>
11
+ <tr>
12
+ <th>Job</th>
13
+ <th>Expression</th>
14
+ <th>Timezone</th>
15
+ <th>Args</th>
16
+ <th>Stream</th>
17
+ <th>Actions</th>
18
+ </tr>
19
+ </thead>
20
+ <tbody>
21
+ <% @schedules.each do |s| -%>
22
+ <tr id="cron-row-<%= h(s[:registry_key].to_s.gsub("/", "-")) %>">
23
+ <td>
24
+ <div class="job-class"><%= h(s[:class].to_s) %></div>
25
+ <% if s[:name] -%>
26
+ <code class="text-muted" style="font-size:0.8em;"><%= h(s[:name].to_s) %></code>
27
+ <% end -%>
28
+ <div style="font-size:0.75em; color:#888; margin-top:2px;">
29
+ <code><%= h(s[:schedule_subject].to_s) %></code>
30
+ </div>
31
+ </td>
32
+ <td><code class="subject-tag"><%= h(s[:schedule].to_s) %></code></td>
33
+ <td><%= h(s[:timezone] || "UTC") %></td>
34
+ <td>
35
+ <% if s[:args]&.any? -%>
36
+ <code><%= h(s[:args].inspect) %></code>
37
+ <% else -%>
38
+ <span class="text-muted">&mdash;</span>
39
+ <% end -%>
40
+ </td>
41
+ <td><code><%= h(s[:stream].to_s) %></code></td>
42
+ <td style="white-space:nowrap;">
43
+ <a hx-post="<%= url_for('/crons/run', { subject: u(s[:schedule_subject].to_s) }) %>"
44
+ class="btn btn-success btn-sm"
45
+ title="Dispatch this job now, bypassing the schedule timer">&#x25BA; Run Now</a>
46
+ <a hx-delete="<%= url_for('/crons/delete', { subject: u(s[:schedule_subject].to_s) }) %>"
47
+ hx-target="#crons-table"
48
+ hx-swap="innerHTML"
49
+ hx-confirm="Delete '<%= h(s[:schedule_subject].to_s) %>'? NATS will stop firing it."
50
+ class="btn btn-danger btn-sm"
51
+ title="Purge schedule from NATS">&#x1F5D1; Delete</a>
52
+ </td>
53
+ </tr>
54
+ <% end -%>
55
+ </tbody>
56
+ </table>
57
+ </div>
58
+ <% end -%>
@@ -0,0 +1,10 @@
1
+ <section>
2
+ <header><%= render('jobs/_tabs') %></header>
3
+
4
+ <div id="crons-table"
5
+ hx-get="<%= url_for('/crons/_table') %>"
6
+ hx-trigger="load, every 10s"
7
+ hx-swap="innerHTML">
8
+ <div class="alert alert-info">Loading cron schedules&hellip;</div>
9
+ </div>
10
+ </section>
@@ -1,50 +1,55 @@
1
- <div class="pending">
2
- <span></span>
3
- <span class="text-muted"><%= @total %> job(s) running</span>
4
- </div>
5
-
6
- <% if @jobs.empty? -%>
7
- <div class="alert alert-success">No jobs are currently running</div>
8
- <% else -%>
9
- <div class="table-container">
10
- <table>
11
- <thead>
12
- <tr>
13
- <th>Job</th>
14
- <th>JID[Args]</th>
15
- <th>Stream</th>
16
- <th>Worker</th>
17
- <th>Running for</th>
18
- </tr>
19
- </thead>
20
- <tbody>
21
- <% @jobs.each do |job| -%>
22
- <tr>
23
- <td>
24
- <div class="job-class">
25
- <%= h(job.dig(:data, :class).to_s) %>
26
- </div>
27
- </td>
28
- <td>
29
- <div class="job-id">
30
- <details>
31
- <summary>
32
- <code><%= h(job.dig(:data, :jid)) %></code>
33
- </summary>
34
- <code><%= h(JSON.pretty_generate(job.dig(:data, :args))) %></code>
35
- </details>
36
- </div>
37
- </td>
38
- <td>
39
- <code><%= h(job[:stream].to_s) %></code>
40
- </td>
41
- <td><code style="font-size: var(--font-size-small);"><%= h(job[:worker].to_s) %></code></td>
42
- <td>
43
- <%= elapsed(job[:started_at]) %>
44
- </td>
45
- </tr>
46
- <% end -%>
47
- </tbody>
48
- </table>
1
+ <div id="busy-poller"
2
+ hx-get="<%= url_for('/jobs/_busy') %>"
3
+ hx-trigger="every 5s"
4
+ hx-swap="outerHTML">
5
+ <div class="pending">
6
+ <span></span>
7
+ <span class="text-muted"><%= @total %> job(s) running</span>
49
8
  </div>
50
- <% end -%>
9
+
10
+ <% if @jobs.empty? -%>
11
+ <div class="alert alert-success">No jobs are currently running</div>
12
+ <% else -%>
13
+ <div class="table-container">
14
+ <table>
15
+ <thead>
16
+ <tr>
17
+ <th>Job</th>
18
+ <th>JID[Args]</th>
19
+ <th>Stream</th>
20
+ <th>Worker</th>
21
+ <th>Running for</th>
22
+ </tr>
23
+ </thead>
24
+ <tbody>
25
+ <% @jobs.each do |job| -%>
26
+ <tr>
27
+ <td>
28
+ <div class="job-class">
29
+ <%= h(job.dig(:data, :class).to_s) %>
30
+ </div>
31
+ </td>
32
+ <td>
33
+ <div class="job-id">
34
+ <details>
35
+ <summary>
36
+ <code><%= h(job.dig(:data, :jid)) %></code>
37
+ </summary>
38
+ <code><%= h(JSON.pretty_generate(job.dig(:data, :args))) %></code>
39
+ </details>
40
+ </div>
41
+ </td>
42
+ <td>
43
+ <code><%= h(job[:stream].to_s) %></code>
44
+ </td>
45
+ <td><code style="font-size: var(--font-size-small);"><%= h(job[:worker].to_s) %></code></td>
46
+ <td>
47
+ <%= elapsed(job[:started_at]) %>
48
+ </td>
49
+ </tr>
50
+ <% end -%>
51
+ </tbody>
52
+ </table>
53
+ </div>
54
+ <% end -%>
55
+ </div>