cosmonats 0.2.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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +300 -187
  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 +36 -14
  12. data/lib/cosmo/api/stream.rb +27 -9
  13. data/lib/cosmo/api.rb +1 -0
  14. data/lib/cosmo/cli.rb +27 -9
  15. data/lib/cosmo/client.rb +75 -5
  16. data/lib/cosmo/config.rb +14 -32
  17. data/lib/cosmo/engine.rb +1 -1
  18. data/lib/cosmo/job/data.rb +1 -1
  19. data/lib/cosmo/job/limit.rb +51 -0
  20. data/lib/cosmo/job/processor.rb +82 -63
  21. data/lib/cosmo/job.rb +51 -2
  22. data/lib/cosmo/logger.rb +4 -1
  23. data/lib/cosmo/processor.rb +108 -0
  24. data/lib/cosmo/railtie.rb +21 -0
  25. data/lib/cosmo/stream/processor.rb +24 -60
  26. data/lib/cosmo/stream.rb +4 -3
  27. data/lib/cosmo/utils/hash.rb +13 -24
  28. data/lib/cosmo/utils/overrides.rb +1 -1
  29. data/lib/cosmo/utils/ttl_cache.rb +44 -0
  30. data/lib/cosmo/utils.rb +1 -0
  31. data/lib/cosmo/version.rb +1 -1
  32. data/lib/cosmo/web/assets/app.css +88 -0
  33. data/lib/cosmo/web/controllers/crons.rb +41 -0
  34. data/lib/cosmo/web/controllers/jobs.rb +7 -3
  35. data/lib/cosmo/web/controllers/streams.rb +36 -10
  36. data/lib/cosmo/web/helpers/application.rb +17 -2
  37. data/lib/cosmo/web/views/actions/index.erb +1 -1
  38. data/lib/cosmo/web/views/crons/_table.erb +58 -0
  39. data/lib/cosmo/web/views/crons/index.erb +10 -0
  40. data/lib/cosmo/web/views/jobs/_busy.erb +54 -49
  41. data/lib/cosmo/web/views/jobs/_dead.erb +70 -65
  42. data/lib/cosmo/web/views/jobs/_enqueued.erb +82 -56
  43. data/lib/cosmo/web/views/jobs/_scheduled.erb +53 -48
  44. data/lib/cosmo/web/views/jobs/_tabs.erb +6 -0
  45. data/lib/cosmo/web/views/jobs/busy.erb +8 -6
  46. data/lib/cosmo/web/views/jobs/dead.erb +6 -5
  47. data/lib/cosmo/web/views/jobs/enqueued.erb +8 -6
  48. data/lib/cosmo/web/views/jobs/index.erb +1 -1
  49. data/lib/cosmo/web/views/jobs/scheduled.erb +6 -5
  50. data/lib/cosmo/web/views/layout.erb +1 -1
  51. data/lib/cosmo/web/views/streams/_info.erb +3 -0
  52. data/lib/cosmo/web/views/streams/_pause_banner.erb +17 -0
  53. data/lib/cosmo/web/views/streams/_stream_row.erb +42 -0
  54. data/lib/cosmo/web/views/streams/_table.erb +4 -21
  55. data/lib/cosmo/web.rb +7 -0
  56. data/lib/cosmo.rb +1 -0
  57. data/sig/cosmo/active_job/adapter.rbs +13 -0
  58. data/sig/cosmo/active_job/executor.rbs +9 -0
  59. data/sig/cosmo/active_job/options.rbs +14 -0
  60. data/sig/cosmo/api/cron/entry.rbs +30 -0
  61. data/sig/cosmo/api/cron.rbs +25 -0
  62. data/sig/cosmo/api/kv.rbs +4 -6
  63. data/sig/cosmo/api/stream.rbs +7 -1
  64. data/sig/cosmo/client.rbs +20 -4
  65. data/sig/cosmo/config.rbs +3 -15
  66. data/sig/cosmo/job/data.rbs +1 -1
  67. data/sig/cosmo/job/limit.rbs +18 -0
  68. data/sig/cosmo/job/processor.rbs +19 -9
  69. data/sig/cosmo/job.rbs +9 -4
  70. data/sig/cosmo/processor.rbs +26 -0
  71. data/sig/cosmo/railtie.rbs +4 -0
  72. data/sig/cosmo/stream/processor.rbs +4 -10
  73. data/sig/cosmo/utils/hash.rbs +4 -8
  74. data/sig/cosmo/utils/ttl_cache.rbs +20 -0
  75. metadata +25 -3
  76. data/lib/cosmo/defaults.yml +0 -70
@@ -2,15 +2,23 @@
2
2
 
3
3
  module Cosmo
4
4
  class Processor
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
+ 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
+ STREAM_EMPTY_BACKOFF_MAX = 5.0 # Max seconds to sleep between empty fetches (override via COSMO_STREAM_EMPTY_BACKOFF_MAX)
8
+
5
9
  def self.run(...)
6
10
  new(...).tap(&:run)
7
11
  end
8
12
 
13
+ attr_reader :consumers
14
+
9
15
  def initialize(pool, running, options)
10
16
  @pool = pool
11
17
  @running = running
12
18
  @options = options
19
+ @threads = []
13
20
  @consumers = []
21
+ @cache = Utils::TTLCache.new
14
22
  end
15
23
 
16
24
  def run
@@ -21,9 +29,95 @@ module Cosmo
21
29
  run_loop
22
30
  end
23
31
 
32
+ def stop(timeout = Config[:timeout])
33
+ @running.make_false
34
+ @pool.shutdown
35
+ @consumers.each { |(s, _)| s.unsubscribe rescue nil }
36
+ @pool.wait_for_termination(timeout)
37
+ @threads.compact.each { _1.join(timeout) || _1.kill }
38
+ @consumers.clear
39
+ @threads.clear
40
+ end
41
+
24
42
  private
25
43
 
26
44
  def run_loop
45
+ @threads << Thread.new { work_loop }
46
+ @threads << Thread.new { schedule_loop } if scheduler?
47
+ end
48
+
49
+ def work_loop # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
50
+ shutdown = false
51
+
52
+ while running?
53
+ break if shutdown
54
+
55
+ all_empty = true # every stream is empty
56
+ all_paused = true # every stream is paused
57
+ consumers.each do |(subscription, config, processor)| # rubocop:disable Metrics/BlockLength
58
+ break unless running?
59
+
60
+ stream_name = config[:stream].to_s
61
+ ttl = ENV.fetch("COSMO_STREAM_PAUSED_RECHECK_TTL", STREAM_PAUSED_RECHECK_TTL).to_f
62
+ if @cache.fetch(stream_name, ttl:) { API::Stream.new(stream_name).paused? }
63
+ Logger.debug "stream #{stream_name} is paused, skipping fetch"
64
+ next
65
+ end
66
+ all_paused = false
67
+
68
+ _, skip_t = consumer_state[stream_name]
69
+ if skip_t && Time.now < skip_t
70
+ Logger.debug "stream #{stream_name} is empty, backing off"
71
+ next
72
+ end
73
+ all_empty = false
74
+
75
+ begin
76
+ @pool.post do
77
+ # Re-check, after possibly being blocked on post to thread pool
78
+ _, skip_t = consumer_state[stream_name]
79
+ next if skip_t && Time.now < skip_t
80
+
81
+ timeout = fetch_timeout(config)
82
+ Logger.debug "fetching #{fetch_subjects(config).inspect}, timeout=#{timeout}"
83
+ messages = lock(stream_name) { fetch(subscription, batch_size: config[:batch_size], timeout:) }
84
+ Logger.debug "fetched (#{messages&.size.to_i}) messages"
85
+ if messages&.any?
86
+ consumer_state.delete(stream_name)
87
+ process(messages, processor)
88
+ else
89
+ max_backoff = ENV.fetch("COSMO_STREAM_EMPTY_BACKOFF_MAX", STREAM_EMPTY_BACKOFF_MAX).to_f
90
+ consumer_state.compute(stream_name) do |current|
91
+ count = (current&.first || 0) + 1
92
+ backoff = [timeout * (2**(count - 1)), max_backoff].min
93
+ [count, Time.now + backoff]
94
+ end
95
+ end
96
+ end
97
+ rescue Concurrent::RejectedExecutionError
98
+ shutdown = true
99
+ break # pool doesn't accept new jobs, we are shutting down
100
+ end
101
+ end
102
+
103
+ break unless running?
104
+
105
+ if all_paused
106
+ period = ENV.fetch("COSMO_STREAMS_PAUSED_IDLE_SLEEP", STREAMS_PAUSED_IDLE_SLEEP).to_f
107
+ Logger.debug "all streams paused, sleep=#{period}"
108
+ sleep(period)
109
+ elsif all_empty
110
+ next_wake = consumer_state.values.filter_map { |_, t| t }.min
111
+ next unless next_wake # entry was deleted concurrently (messages arrived), re-loop immediately
112
+
113
+ remaining = [next_wake - Time.now, 0.01].max
114
+ Logger.debug "all streams empty, sleep=#{remaining}"
115
+ sleep(remaining)
116
+ end
117
+ end
118
+ end
119
+
120
+ def schedule_loop
27
121
  raise NotImplementedError
28
122
  end
29
123
 
@@ -39,6 +133,10 @@ module Cosmo
39
133
  @running.true?
40
134
  end
41
135
 
136
+ def scheduler?
137
+ false
138
+ end
139
+
42
140
  def fetch(subscription, batch_size:, timeout:)
43
141
  subscription.fetch(batch_size, timeout:)
44
142
  rescue NATS::Timeout
@@ -49,6 +147,7 @@ module Cosmo
49
147
 
50
148
  backoff = ENV.fetch("COSMO_STREAMS_FETCH_BACKOFF", 5).to_f
51
149
  sleep([timeout, backoff].max) # backoff before retry
150
+ nil
52
151
  end
53
152
 
54
153
  def client
@@ -58,5 +157,14 @@ module Cosmo
58
157
  def stopwatch
59
158
  Utils::Stopwatch.new
60
159
  end
160
+
161
+ def lock(stream_name, &)
162
+ @locks ||= Hash.new { |h, k| h[k] = Mutex.new }
163
+ @locks[stream_name].synchronize(&)
164
+ end
165
+
166
+ def consumer_state
167
+ @consumer_state ||= Concurrent::Map.new
168
+ end
61
169
  end
62
170
  end
@@ -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
@@ -3,51 +3,21 @@
3
3
  module Cosmo
4
4
  module Stream
5
5
  class Processor < ::Cosmo::Processor
6
- def initialize(pool, running, options)
7
- super
8
- @configs = []
9
- end
10
-
11
6
  private
12
7
 
13
- def run_loop
14
- Thread.new { work_loop }
15
- end
16
-
17
8
  def setup
18
- setup_configs
19
- setup_consumers
20
- end
21
-
22
- def work_loop # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
23
- shutdown = false
24
-
25
- while running?
26
- break if shutdown
27
-
28
- @consumers.each do |(subscription, config, processor)|
29
- break unless running?
30
-
31
- begin
32
- @pool.post do
33
- timeout = convert_timeout(config[:fetch_timeout])
34
- Logger.debug "fetching #{config.dig(:consumer, :subjects).inspect}, timeout=#{timeout}"
35
- messages = fetch(subscription, batch_size: config[:batch_size], timeout:)
36
- Logger.debug "fetched (#{messages&.size.to_i}) messages"
37
- process(messages, processor) if messages&.any?
38
- Logger.debug "processed (#{messages&.size.to_i}) messages"
39
- end
40
- rescue Concurrent::RejectedExecutionError
41
- shutdown = true
42
- break # pool doesn't accept new jobs, we are shutting down
43
- end
9
+ @configs ||= []
10
+ @configs = static_config + dynamic_config
44
11
 
45
- break unless running?
46
- end
12
+ if @options[:processors]
13
+ pattern = Regexp.new(@options[:processors].map { "\\b#{_1}\\b" }.join("|"))
14
+ @configs.select! { _1[:class].name.match?(pattern) }
47
15
  end
16
+
17
+ @configs.each { @consumers << subscribe(nil, _1) }
48
18
  end
49
19
 
50
- def process(messages, processor) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
20
+ def process(messages, processor) # rubocop:disable Metrics/AbcSize
51
21
  metadata = messages.last.metadata
52
22
  serializer = processor.class.default_options.dig(:publisher, :serializer)
53
23
  messages = messages.map { Message.new(_1, serializer:) }
@@ -70,25 +40,6 @@ module Cosmo
70
40
  raise
71
41
  end
72
42
 
73
- def setup_configs
74
- @configs = static_config + dynamic_config
75
- return unless @options[:processors]
76
-
77
- pattern = Regexp.new(@options[:processors].map { "\\b#{_1}\\b" }.join("|"))
78
- @configs.select! { _1[:class].name.match?(pattern) }
79
- end
80
-
81
- def setup_consumers
82
- @configs.each do |config|
83
- processor = config[:class].new
84
- subjects = config.dig(:consumer, :subjects)
85
- deliver_policy = Config.deliver_policy(config[:start_position])
86
- consumer_config, consumer_name = config.values_at(:consumer, :consumer_name)
87
- subscription = client.subscribe(subjects, consumer_name, consumer_config.merge(deliver_policy))
88
- @consumers << [subscription, config, processor]
89
- end
90
- end
91
-
92
43
  def static_config
93
44
  Config.dig(:consumers, :streams)&.filter_map do |config|
94
45
  next unless (klass = Utils::String.safe_constantize(config[:class]))
@@ -98,11 +49,24 @@ module Cosmo
98
49
  end
99
50
 
100
51
  def dynamic_config
101
- Config.system[:streams].map { _1.default_options.merge(class: _1) }
52
+ Config.internal[:streams]&.map { _1.default_options.merge(class: _1) }.to_a
53
+ end
54
+
55
+ def subscribe(_stream_name, config)
56
+ processor = config[:class].new
57
+ subjects = config.dig(:consumer, :subjects)
58
+ deliver_policy = Config.deliver_policy(config[:start_position])
59
+ consumer_config, consumer_name = config.values_at(:consumer, :consumer_name)
60
+ subscription = client.subscribe(subjects, consumer_name, consumer_config.merge(deliver_policy))
61
+ [subscription, config, processor]
62
+ end
63
+
64
+ def fetch_subjects(config)
65
+ config.dig(:consumer, :subjects)
102
66
  end
103
67
 
104
- def convert_timeout(value)
105
- timeout = value.to_f
68
+ def fetch_timeout(config)
69
+ timeout = config[:fetch_timeout].to_f
106
70
  if timeout <= 0
107
71
  Logger.warn "Ignoring `fetch_timeout: #{timeout}` (causes high CPU usage) with #{Data::DEFAULTS[:fetch_timeout]}s instead"
108
72
  timeout = Data::DEFAULTS[:fetch_timeout].to_f
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]
@@ -28,8 +29,8 @@ module Cosmo
28
29
  end
29
30
 
30
31
  def register # rubocop:disable Metrics/AbcSize
31
- Config.system[:streams] ||= []
32
- Config.system[:streams] << self
32
+ Config.internal[:streams] ||= []
33
+ Config.internal[:streams] << self
33
34
 
34
35
  # settings are inherited, don't try to modify them
35
36
  return if default_options != Data::DEFAULTS
@@ -23,20 +23,17 @@ module Cosmo
23
23
  end
24
24
  end
25
25
 
26
- # deep dup
27
- def dup(hash)
28
- Marshal.load(Marshal.dump(hash))
29
- end
30
-
31
- # dig keys
32
- def keys?(hash, *keys)
33
- keys.reduce(hash) do |base, key|
34
- return false if !base.is_a?(::Hash) || !base.key?(key)
35
-
36
- base[key]
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
37
36
  end
38
-
39
- true
40
37
  end
41
38
 
42
39
  # deep set
@@ -49,17 +46,9 @@ module Cosmo
49
46
  target[last_key] = value
50
47
  end
51
48
 
52
- # deep merge
53
- def merge(hash1, hash2)
54
- return hash1 unless hash2
55
-
56
- hash1.merge(hash2) do |_key, old_val, new_val|
57
- if old_val.is_a?(::Hash) && new_val.is_a?(::Hash)
58
- merge(old_val, new_val)
59
- else
60
- new_val
61
- end
62
- end
49
+ # deep dup
50
+ def dup(hash)
51
+ Marshal.load(Marshal.dump(hash))
63
52
  end
64
53
  end
65
54
  end
@@ -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
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module Utils
5
+ class TTLCache
6
+ def initialize
7
+ @store = {}
8
+ end
9
+
10
+ def set(key, value, ttl: nil)
11
+ @store[key] = [value, ttl ? Time.now + ttl : nil]
12
+ value
13
+ end
14
+
15
+ def get(key)
16
+ return unless key?(key)
17
+
18
+ @store[key].first
19
+ end
20
+
21
+ def fetch(key, ttl: nil)
22
+ return get(key) if key?(key)
23
+
24
+ result = yield
25
+ set(key, result, ttl: ttl)
26
+ result
27
+ end
28
+
29
+ private
30
+
31
+ def key?(key)
32
+ exists = @store.key?(key)
33
+ return false unless exists
34
+
35
+ _, ttl = @store[key]
36
+ return true unless ttl
37
+ return true if Time.now < ttl
38
+
39
+ @store.delete(key)
40
+ false
41
+ end
42
+ end
43
+ end
44
+ end
data/lib/cosmo/utils.rb CHANGED
@@ -7,6 +7,7 @@ require "cosmo/utils/signal"
7
7
  require "cosmo/utils/warnings"
8
8
  require "cosmo/utils/stopwatch"
9
9
  require "cosmo/utils/thread_pool"
10
+ require "cosmo/utils/ttl_cache"
10
11
 
11
12
  module Cosmo
12
13
  module Utils
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.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -135,6 +135,52 @@ button:hover, .btn:hover {
135
135
  );
136
136
  }
137
137
 
138
+ .btn-warning {
139
+ background: linear-gradient(
140
+ oklch(from var(--color-warning) calc(l + 0.06) c h),
141
+ var(--color-warning)
142
+ );
143
+ color: oklch(27% 0.005 256);
144
+ }
145
+ .btn-warning:hover {
146
+ background: linear-gradient(
147
+ oklch(from var(--color-warning) calc(l + 0.16) c h),
148
+ oklch(from var(--color-warning) calc(l + 0.1) c h)
149
+ );
150
+ color: oklch(27% 0.005 256);
151
+ }
152
+
153
+ .btn-success {
154
+ background: linear-gradient(
155
+ oklch(from var(--color-success) calc(l + 0.06) c h),
156
+ var(--color-success)
157
+ );
158
+ }
159
+ .btn-success:hover {
160
+ background: linear-gradient(
161
+ oklch(from var(--color-success) calc(l + 0.16) c h),
162
+ oklch(from var(--color-success) calc(l + 0.1) c h)
163
+ );
164
+ }
165
+
166
+ /* ── Badges ────────────────────────────────────────────────────────────── */
167
+ .badge {
168
+ border-radius: var(--space-1-2);
169
+ display: inline-block;
170
+ font-size: var(--font-size-small);
171
+ font-weight: 700;
172
+ padding: 2px var(--space);
173
+ white-space: nowrap;
174
+ }
175
+ .badge-success {
176
+ background: oklch(from var(--color-success) l c h / 15%);
177
+ color: var(--color-success);
178
+ }
179
+ .badge-warning {
180
+ background: oklch(from var(--color-warning) l c h / 20%);
181
+ color: oklch(from var(--color-warning) calc(l - 0.15) c h);
182
+ }
183
+
138
184
  /* ── Alerts ────────────────────────────────────────────────────────────── */
139
185
  .alert {
140
186
  padding: var(--space-2x);
@@ -246,6 +292,34 @@ section > header {
246
292
  z-index: auto;
247
293
  }
248
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
+
249
323
  section .nav {
250
324
  display: flex;
251
325
  gap: var(--space);
@@ -402,6 +476,20 @@ time {
402
476
  .stream-link { color: var(--color-primary); font-weight: 600; }
403
477
  .stream-link:hover { text-decoration: underline; }
404
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
+
405
493
  /* ── Actions ───────────────────────────────────────────────────────────── */
406
494
  .actions-form { display: flex; flex-direction: column; gap: var(--space-1-2); }
407
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
@@ -21,22 +21,48 @@ module Cosmo
21
21
  ok render("streams/info", { name: name }, layout: true)
22
22
  end
23
23
 
24
- def _table
25
- streams = API::Stream.all.map do |stream|
26
- state, config = stream.info.values
27
- { name: stream.name, messages: state.messages, bytes: state.bytes,
28
- first_seq: state.first_seq, last_seq: state.last_seq,
29
- consumer_count: state.consumer_count,
30
- subjects: config.subjects }
31
- end
24
+ def pause
25
+ name = Rack::Utils.unescape(@request.params["name"])
26
+ stream = API::Stream.new(name)
27
+ stream.pause!
28
+ return ok render("streams/_pause_banner", banner_locals(stream)) if @request.params["banner"]
29
+
30
+ ok render("streams/_stream_row", { stream: row_locals(stream) })
31
+ end
32
+
33
+ def unpause
34
+ name = Rack::Utils.unescape(@request.params["name"])
35
+ stream = API::Stream.new(name)
36
+ stream.unpause!
37
+ return ok render("streams/_pause_banner", banner_locals(stream)) if @request.params["banner"]
38
+
39
+ ok render("streams/_stream_row", { stream: row_locals(stream) })
40
+ end
32
41
 
42
+ def _table
43
+ streams = API::Stream.all.reject { |s| s.name.start_with?("KV_") || s.name.start_with?("_cosmo") }.map { row_locals(_1) }
33
44
  ok render("streams/_table", { streams: streams })
34
45
  end
35
46
 
36
47
  def _info
37
48
  name = Rack::Utils.unescape(@request.params["name"])
38
- state = API::Stream.new(name).info.merge(name:)
39
- ok render("streams/_info", state)
49
+ stream = API::Stream.new(name)
50
+ ok render("streams/_info", stream.info.merge(name:, paused: stream.paused?))
51
+ end
52
+
53
+ private
54
+
55
+ def row_locals(stream)
56
+ state, config = stream.info.values
57
+ { name: stream.name, messages: state.messages, bytes: state.bytes,
58
+ first_seq: state.first_seq, last_seq: state.last_seq,
59
+ consumer_count: state.consumer_count,
60
+ subjects: config.subjects,
61
+ paused: stream.paused? }
62
+ end
63
+
64
+ def banner_locals(stream)
65
+ { name: stream.name, paused: stream.paused? }
40
66
  end
41
67
  end
42
68
  end