cosmonats 0.3.0 → 0.4.1

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 (66) 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 +43 -8
  13. data/lib/cosmo/api.rb +1 -0
  14. data/lib/cosmo/cli.rb +27 -10
  15. data/lib/cosmo/client.rb +52 -4
  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 +63 -7
  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/sentry/auto.rb +6 -0
  24. data/lib/cosmo/sentry/job_processor_middleware.rb +66 -0
  25. data/lib/cosmo/stream/processor.rb +2 -2
  26. data/lib/cosmo/stream.rb +2 -1
  27. data/lib/cosmo/utils/hash.rb +13 -0
  28. data/lib/cosmo/utils/overrides.rb +1 -1
  29. data/lib/cosmo/version.rb +1 -1
  30. data/lib/cosmo/web/assets/app.css +59 -0
  31. data/lib/cosmo/web/controllers/crons.rb +41 -0
  32. data/lib/cosmo/web/controllers/jobs.rb +7 -3
  33. data/lib/cosmo/web/controllers/streams.rb +1 -1
  34. data/lib/cosmo/web/helpers/application.rb +4 -0
  35. data/lib/cosmo/web/views/actions/index.erb +1 -1
  36. data/lib/cosmo/web/views/crons/_table.erb +58 -0
  37. data/lib/cosmo/web/views/crons/index.erb +10 -0
  38. data/lib/cosmo/web/views/jobs/_busy.erb +54 -49
  39. data/lib/cosmo/web/views/jobs/_dead.erb +70 -65
  40. data/lib/cosmo/web/views/jobs/_enqueued.erb +85 -56
  41. data/lib/cosmo/web/views/jobs/_scheduled.erb +53 -48
  42. data/lib/cosmo/web/views/jobs/_stats.erb +5 -1
  43. data/lib/cosmo/web/views/jobs/_tabs.erb +6 -0
  44. data/lib/cosmo/web/views/jobs/busy.erb +8 -6
  45. data/lib/cosmo/web/views/jobs/dead.erb +6 -5
  46. data/lib/cosmo/web/views/jobs/enqueued.erb +8 -6
  47. data/lib/cosmo/web/views/jobs/index.erb +1 -1
  48. data/lib/cosmo/web/views/jobs/scheduled.erb +6 -5
  49. data/lib/cosmo/web/views/layout.erb +2 -1
  50. data/lib/cosmo/web.rb +5 -0
  51. data/lib/cosmo.rb +1 -0
  52. data/sig/cosmo/active_job/adapter.rbs +13 -0
  53. data/sig/cosmo/active_job/executor.rbs +9 -0
  54. data/sig/cosmo/active_job/options.rbs +14 -0
  55. data/sig/cosmo/api/cron/entry.rbs +30 -0
  56. data/sig/cosmo/api/cron.rbs +25 -0
  57. data/sig/cosmo/api/kv.rbs +4 -6
  58. data/sig/cosmo/api/stream.rbs +6 -0
  59. data/sig/cosmo/client.rbs +9 -1
  60. data/sig/cosmo/job/data.rbs +1 -1
  61. data/sig/cosmo/job/limit.rbs +18 -0
  62. data/sig/cosmo/job/processor.rbs +3 -1
  63. data/sig/cosmo/job.rbs +9 -4
  64. data/sig/cosmo/railtie.rbs +4 -0
  65. data/sig/cosmo/utils/hash.rbs +4 -0
  66. metadata +23 -2
data/lib/cosmo/config.rb CHANGED
@@ -31,11 +31,20 @@ module Cosmo
31
31
  end
32
32
 
33
33
  config[:setup]&.each_key do |type|
34
+ next if type == :cron
35
+
34
36
  config[:setup][type]&.each_key do |name|
35
37
  c = config[:setup][type][name]
36
38
  c[:max_age] = c[:max_age].to_i * NANO if c[:max_age]
37
39
  c[:duplicate_window] = c[:duplicate_window].to_i * NANO if c[:duplicate_window]
38
40
  c[:subjects] = c[:subjects].map { |s| format(s, name: name) } if c[:subjects]
41
+
42
+ next unless type == :jobs # Every jobs stream supports NATS 2.14 message scheduling.
43
+
44
+ c[:allow_msg_schedules] = true
45
+ cron_subject = "#{API::Cron::Entry::SUBJECT_PREFIX}.#{name}.>"
46
+ c[:subjects] = Array(c[:subjects])
47
+ c[:subjects] << cron_subject unless c[:subjects].include?(cron_subject)
39
48
  end
40
49
  end
41
50
  end
@@ -5,7 +5,7 @@ require "json"
5
5
  module Cosmo
6
6
  module Job
7
7
  class Data
8
- DEFAULTS = { stream: :default, retry: 3, dead: true }.freeze
8
+ DEFAULTS = { stream: :default, retry: 3, dead: true, limit: nil }.freeze
9
9
 
10
10
  attr_reader :jid
11
11
 
@@ -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,64 @@ 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
- instance = worker_class.new
69
- instance.jid = data[:jid]
70
- instance.perform(*data[:args])
82
+
83
+ instance = worker_class.new.tap { |w| w.jid = data[:jid] }
84
+ perform_job(instance, data: data, message: message, duration: duration)
85
+
71
86
  message.ack
72
87
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "done" }
73
88
  true
89
+ rescue Timeout::Error
90
+ Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[timeout]" }
91
+ dropped = handle_failure(message, data)
92
+ false if dropped
74
93
  rescue StandardError => e
75
94
  Logger.debug e
76
- Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail" }
95
+ Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[error]" }
77
96
  dropped = handle_failure(message, data)
78
97
  false if dropped
79
98
  rescue Exception # rubocop:disable Lint/RescueException
80
- Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail" }
99
+ Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[exception]" }
81
100
  raise
82
101
  end
83
102
  ensure
103
+ Limit.instance.release(slot) if slot
84
104
  Logger.without(:jid)
85
105
  Logger.debug "processed message #{message.inspect}"
86
106
  end
87
107
 
108
+ # Tries to acquire a concurrency slot for the job.
109
+ # Returns the slot key (String) on success, or false if all slots are
110
+ # taken (message is NAK'd with a delay equal to +duration+ before returning).
111
+ def acquire_concurrency_slot(worker_class, message, data)
112
+ options = worker_class.concurrency_options
113
+ key = worker_class.concurrency_key(data[:args])
114
+
115
+ slot = Limit.instance.acquire(key, jid: data[:jid], limit: options[:limit], duration: options[:duration])
116
+ return slot if slot
117
+
118
+ message.nak(delay: options[:duration] * Config::NANO)
119
+ Logger.debug "concurrency limit reached for #{data[:class]}, re-queueing back #{data[:jid]}"
120
+ false
121
+ rescue NATS::Error => e
122
+ # Unexpected KV failure (e.g. transient NATS error). NAK immediately so
123
+ # the message is retried rather than stuck in-flight until ack_wait expires.
124
+ Logger.error e
125
+ message.nak
126
+ false
127
+ end
128
+
88
129
  def handle_failure(message, data) # rubocop:disable Naming/PredicateMethod
89
130
  current_attempt = message.metadata.num_delivered
90
131
  max_retries = data[:retry].to_i + 1
@@ -93,7 +134,7 @@ module Cosmo
93
134
  # NATS will auto-retry with delay (exponential backoff based on current attempt).
94
135
  # When max_deliver is reached, NATS stops redelivering the message and marks it as "max deliveries exceeded".
95
136
  # 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
137
+ delay_ns = ((current_attempt**4) + 15) * Config::NANO
97
138
  message.nak(delay: delay_ns)
98
139
  return false
99
140
  end
@@ -146,6 +187,21 @@ module Cosmo
146
187
  API::Counter.instance.with(&block)
147
188
  end
148
189
  end
190
+
191
+ # @param job_instance [Cosmo::Job]
192
+ # @param data [Hash]
193
+ # @param message [NATS::Msg]
194
+ # @param duration [Float, nil]
195
+ #
196
+ # rubocop:disable Lint/UnusedMethodArgument
197
+ def perform_job(job_instance, data:, message:, duration: nil)
198
+ if duration
199
+ Timeout.timeout(duration) { job_instance.perform(*data[:args]) }
200
+ else
201
+ job_instance.perform(*data[:args])
202
+ end
203
+ end
204
+ # rubocop:enable Lint/UnusedMethodArgument
149
205
  end
150
206
  end
151
207
  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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cosmo/job/processor"
4
+ require "cosmo/sentry/job_processor_middleware"
5
+
6
+ Cosmo::Job::Processor.prepend Cosmo::Sentry::JobProcessorMiddleware
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
4
+ module Cosmo
5
+ module Sentry
6
+ module JobProcessorMiddleware
7
+ NAME_PREFIX = "Cosmonats"
8
+ OP_NAME = "queue.cosmonats"
9
+ SPAN_ORIGIN = "auto.queue.cosmonats"
10
+ STATUS_OK = 200
11
+ STATUS_FAIL = 500
12
+
13
+ # @param job_instance [Cosmo::Job]
14
+ # @param data [Hash]
15
+ # @param message [NATS::Msg]
16
+ # @param duration [Float, nil]
17
+ def perform_job(job_instance, data:, message:, duration: nil)
18
+ unless ::Sentry.initialized?
19
+ super
20
+ return
21
+ end
22
+
23
+ scope = ::Sentry.get_current_scope
24
+ transaction_name = "#{NAME_PREFIX}/#{job_instance.class.name}"
25
+ scope.set_transaction_name(transaction_name, source: :task)
26
+
27
+ transaction = ::Sentry.start_transaction(
28
+ name: scope.transaction_name,
29
+ source: scope.transaction_source,
30
+ op: OP_NAME,
31
+ origin: SPAN_ORIGIN
32
+ )
33
+ transaction&.set_data("messaging.message.id", data[:jid])
34
+ transaction&.set_data("messaging.destination.name", "#{message.metadata.stream}:#{message.subject}")
35
+ transaction&.set_data("messaging.message.retry.count", data[:retry] || 0)
36
+
37
+ begin
38
+ super
39
+
40
+ transaction&.set_http_status(STATUS_OK)
41
+ transaction&.finish
42
+ rescue StandardError => e
43
+ ::Sentry.capture_exception(
44
+ e,
45
+ contexts: {
46
+ cosmonats: data.merge(
47
+ nats_stream: message.metadata.stream,
48
+ nats_subject: message.subject,
49
+ timeout_duration: duration
50
+ )
51
+ },
52
+ hint: {
53
+ background: true,
54
+ integration: "cosmonats"
55
+ }
56
+ )
57
+ transaction&.set_http_status(STATUS_FAIL)
58
+ transaction&.finish
59
+
60
+ raise e
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
@@ -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.1"
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
 
@@ -467,6 +509,23 @@ details code { display: block; max-width: 400px; white-space: pre-wrap; }
467
509
  .htmx-request .htmx-indicator,
468
510
  .htmx-request.htmx-indicator { opacity: 1; }
469
511
 
512
+ #global-spinner {
513
+ position: fixed;
514
+ top: var(--space-2x);
515
+ right: var(--space-2x);
516
+ width: 24px;
517
+ height: 24px;
518
+ border: 3px solid var(--color-border);
519
+ border-top-color: var(--color-primary);
520
+ border-radius: 50%;
521
+ animation: spin 0.6s linear infinite;
522
+ z-index: 100;
523
+ }
524
+
525
+ @keyframes spin {
526
+ to { transform: rotate(360deg); }
527
+ }
528
+
470
529
  /* ── Responsive ────────────────────────────────────────────────────────── */
471
530
  @media (max-width: 768px) {
472
531
  .nav { flex-wrap: wrap; }
@@ -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>