cosmonats 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +127 -66
- data/lib/cosmo/api/kv.rb +1 -1
- data/lib/cosmo/api/stream.rb +17 -4
- data/lib/cosmo/cli.rb +3 -2
- data/lib/cosmo/client.rb +27 -3
- data/lib/cosmo/config.rb +5 -32
- data/lib/cosmo/engine.rb +1 -1
- data/lib/cosmo/job/processor.rb +34 -59
- data/lib/cosmo/logger.rb +4 -1
- data/lib/cosmo/processor.rb +109 -1
- data/lib/cosmo/stream/processor.rb +23 -59
- data/lib/cosmo/stream.rb +2 -2
- data/lib/cosmo/utils/hash.rb +3 -27
- data/lib/cosmo/utils/ttl_cache.rb +44 -0
- data/lib/cosmo/utils.rb +1 -0
- data/lib/cosmo/version.rb +1 -1
- data/lib/cosmo/web/assets/app.css +46 -0
- data/lib/cosmo/web/controllers/streams.rb +36 -10
- data/lib/cosmo/web/helpers/application.rb +13 -2
- data/lib/cosmo/web/views/streams/_info.erb +3 -0
- data/lib/cosmo/web/views/streams/_pause_banner.erb +17 -0
- data/lib/cosmo/web/views/streams/_stream_row.erb +42 -0
- data/lib/cosmo/web/views/streams/_table.erb +4 -21
- data/lib/cosmo/web.rb +2 -0
- data/sig/cosmo/api/stream.rbs +7 -1
- data/sig/cosmo/client.rbs +11 -3
- data/sig/cosmo/config.rbs +3 -15
- data/sig/cosmo/job/processor.rbs +16 -8
- data/sig/cosmo/processor.rbs +26 -0
- data/sig/cosmo/stream/processor.rbs +4 -10
- data/sig/cosmo/utils/hash.rbs +0 -8
- data/sig/cosmo/utils/ttl_cache.rbs +20 -0
- metadata +6 -3
- data/lib/cosmo/defaults.yml +0 -70
data/lib/cosmo/processor.rb
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Cosmo
|
|
4
|
-
class Processor
|
|
4
|
+
class Processor # rubocop:disable Metrics/ClassLength
|
|
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
|
|
@@ -3,48 +3,18 @@
|
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
20
|
def process(messages, processor) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
@@ -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.
|
|
52
|
+
Config.internal[:streams].map { _1.default_options.merge(class: _1) }
|
|
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
|
|
105
|
-
timeout =
|
|
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
|
@@ -28,8 +28,8 @@ module Cosmo
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def register # rubocop:disable Metrics/AbcSize
|
|
31
|
-
Config.
|
|
32
|
-
Config.
|
|
31
|
+
Config.internal[:streams] ||= []
|
|
32
|
+
Config.internal[:streams] << self
|
|
33
33
|
|
|
34
34
|
# settings are inherited, don't try to modify them
|
|
35
35
|
return if default_options != Data::DEFAULTS
|
data/lib/cosmo/utils/hash.rb
CHANGED
|
@@ -23,22 +23,6 @@ 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]
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
true
|
|
40
|
-
end
|
|
41
|
-
|
|
42
26
|
# deep set
|
|
43
27
|
def set(hash, *keys, value)
|
|
44
28
|
last_key = keys.pop
|
|
@@ -49,17 +33,9 @@ module Cosmo
|
|
|
49
33
|
target[last_key] = value
|
|
50
34
|
end
|
|
51
35
|
|
|
52
|
-
# deep
|
|
53
|
-
def
|
|
54
|
-
|
|
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
|
|
36
|
+
# deep dup
|
|
37
|
+
def dup(hash)
|
|
38
|
+
Marshal.load(Marshal.dump(hash))
|
|
63
39
|
end
|
|
64
40
|
end
|
|
65
41
|
end
|
|
@@ -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
data/lib/cosmo/version.rb
CHANGED
|
@@ -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);
|
|
@@ -21,22 +21,48 @@ module Cosmo
|
|
|
21
21
|
ok render("streams/info", { name: name }, layout: true)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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.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
|
-
|
|
39
|
-
ok render("streams/_info",
|
|
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
|
|
@@ -8,6 +8,12 @@ module Cosmo
|
|
|
8
8
|
module Application
|
|
9
9
|
include Renderer
|
|
10
10
|
|
|
11
|
+
def render(template, locals = nil)
|
|
12
|
+
defaults = { request: @request }
|
|
13
|
+
locals = Hash(locals).merge(defaults)
|
|
14
|
+
erb(template, locals)
|
|
15
|
+
end
|
|
16
|
+
|
|
11
17
|
def format_bytes(bytes)
|
|
12
18
|
b = bytes.to_i
|
|
13
19
|
return "0 B" if b.zero?
|
|
@@ -64,11 +70,16 @@ module Cosmo
|
|
|
64
70
|
def current_page?(path)
|
|
65
71
|
request_path = @request.path_info
|
|
66
72
|
request_path = "/" if request_path.empty?
|
|
67
|
-
request_path ==
|
|
73
|
+
request_path == path
|
|
68
74
|
end
|
|
69
75
|
|
|
70
76
|
def referrer?(path)
|
|
71
|
-
URI(@request.referrer)
|
|
77
|
+
referrer_uri = URI(@request.referrer)
|
|
78
|
+
referrer_path = referrer_uri.path
|
|
79
|
+
script_name = @request.script_name
|
|
80
|
+
referrer_path = referrer_path.delete_prefix(script_name) if script_name && !script_name.empty?
|
|
81
|
+
referrer_path = "/" if referrer_path.empty?
|
|
82
|
+
referrer_path == path
|
|
72
83
|
end
|
|
73
84
|
end
|
|
74
85
|
end
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
</article>
|
|
18
18
|
</div>
|
|
19
19
|
|
|
20
|
+
<%= render("streams/_pause_banner", { name: @name, paused: @paused }) %>
|
|
21
|
+
|
|
20
22
|
<div class="nav">
|
|
21
23
|
<a href="<%= url_for('/streams') %>"
|
|
22
24
|
hx-get="<%= url_for('/streams') %>"
|
|
@@ -87,3 +89,4 @@
|
|
|
87
89
|
<div class="subject-tag" style="padding: var(--space) var(--space-2x); font-size: 1rem;"><%= h(sub) %></div>
|
|
88
90
|
<% end -%>
|
|
89
91
|
</div>
|
|
92
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<% if @paused -%>
|
|
2
|
+
<div id="pause-banner-<%= h(@name) %>" class="alert alert-warning" style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:var(--space-2x);">
|
|
3
|
+
<span>⏸ <strong>Stream is paused</strong> — consumers are not fetching new messages.</span>
|
|
4
|
+
<a hx-patch="<%= url_for('/streams/unpause', { name: u(@name), banner: 1 }) %>"
|
|
5
|
+
hx-target="#pause-banner-<%= h(@name) %>"
|
|
6
|
+
hx-swap="outerHTML"
|
|
7
|
+
class="btn btn-success">▶ Resume Stream</a>
|
|
8
|
+
</div>
|
|
9
|
+
<% else -%>
|
|
10
|
+
<div id="pause-banner-<%= h(@name) %>" class="alert alert-success" style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:var(--space-2x);">
|
|
11
|
+
<span>▶ <strong>Stream is active</strong> — consumers are fetching new messages.</span>
|
|
12
|
+
<a hx-patch="<%= url_for('/streams/pause', { name: u(@name), banner: 1 }) %>"
|
|
13
|
+
hx-target="#pause-banner-<%= h(@name) %>"
|
|
14
|
+
hx-swap="outerHTML"
|
|
15
|
+
class="btn btn-warning">⏸ Pause Stream</a>
|
|
16
|
+
</div>
|
|
17
|
+
<% end -%>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<tr id="stream-row-<%= h(@stream[:name]) %>">
|
|
2
|
+
<td>
|
|
3
|
+
<a href="<%= url_for('/streams/info', { name: u(@stream[:name]) }) %>"
|
|
4
|
+
hx-get="<%= url_for('/streams/info', { name: u(@stream[:name]) }) %>"
|
|
5
|
+
hx-target="#content"
|
|
6
|
+
hx-push-url="true"
|
|
7
|
+
class="stream-link"><%= h(@stream[:name]) %></a>
|
|
8
|
+
</td>
|
|
9
|
+
<td><%= format_numbers(@stream[:messages]) %></td>
|
|
10
|
+
<td><%= format_bytes(@stream[:bytes]) %></td>
|
|
11
|
+
<td><%= @stream[:consumer_count] %></td>
|
|
12
|
+
<td>
|
|
13
|
+
<div class="subjects">
|
|
14
|
+
<% Array(@stream[:subjects]).each do |subject| -%>
|
|
15
|
+
<span class="subject-tag"><%= h(subject) %></span>
|
|
16
|
+
<% end -%>
|
|
17
|
+
</div>
|
|
18
|
+
</td>
|
|
19
|
+
<td><code><%= format_numbers(@stream[:first_seq]) %> → <%= format_numbers(@stream[:last_seq]) %></code></td>
|
|
20
|
+
<td>
|
|
21
|
+
<% if @stream[:paused] -%>
|
|
22
|
+
<span class="badge badge-warning">⏸ Paused</span>
|
|
23
|
+
<% elsif @stream[:consumer_count].to_i > 0 -%>
|
|
24
|
+
<span class="badge badge-success">▶ Active</span>
|
|
25
|
+
<% else -%>
|
|
26
|
+
<span class="badge">No consumers</span>
|
|
27
|
+
<% end -%>
|
|
28
|
+
</td>
|
|
29
|
+
<td>
|
|
30
|
+
<% if @stream[:paused] -%>
|
|
31
|
+
<a hx-patch="<%= url_for('/streams/unpause', { name: u(@stream[:name]) }) %>"
|
|
32
|
+
hx-target="#stream-row-<%= h(@stream[:name]) %>"
|
|
33
|
+
hx-swap="outerHTML"
|
|
34
|
+
class="btn btn-success">▶ Resume</a>
|
|
35
|
+
<% else -%>
|
|
36
|
+
<a hx-patch="<%= url_for('/streams/pause', { name: u(@stream[:name]) }) %>"
|
|
37
|
+
hx-target="#stream-row-<%= h(@stream[:name]) %>"
|
|
38
|
+
hx-swap="outerHTML"
|
|
39
|
+
class="btn btn-warning">⏸ Pause</a>
|
|
40
|
+
<% end -%>
|
|
41
|
+
</td>
|
|
42
|
+
</tr>
|
|
@@ -11,30 +11,13 @@
|
|
|
11
11
|
<th>Consumers</th>
|
|
12
12
|
<th>Subjects</th>
|
|
13
13
|
<th>Sequence</th>
|
|
14
|
+
<th>Status</th>
|
|
15
|
+
<th>Actions</th>
|
|
14
16
|
</tr>
|
|
15
17
|
</thead>
|
|
16
18
|
<tbody>
|
|
17
|
-
<% @streams.each do |
|
|
18
|
-
|
|
19
|
-
<td>
|
|
20
|
-
<a href="<%= url_for('/streams/info', { name: u(s[:name]) }) %>"
|
|
21
|
-
hx-get="<%= url_for('/streams/info', { name: u(s[:name]) }) %>"
|
|
22
|
-
hx-target="#content"
|
|
23
|
-
hx-push-url="true"
|
|
24
|
-
class="stream-link"><%= h(s[:name]) %></a>
|
|
25
|
-
</td>
|
|
26
|
-
<td><%= format_numbers(s[:messages]) %></td>
|
|
27
|
-
<td><%= format_bytes(s[:bytes]) %></td>
|
|
28
|
-
<td><%= s[:consumer_count] %></td>
|
|
29
|
-
<td>
|
|
30
|
-
<div class="subjects">
|
|
31
|
-
<% (s[:subjects] || []).each do |sub| -%>
|
|
32
|
-
<span class="subject-tag"><%= h(sub) %></span>
|
|
33
|
-
<% end -%>
|
|
34
|
-
</div>
|
|
35
|
-
</td>
|
|
36
|
-
<td><code><%= format_numbers(s[:first_seq]) %> → <%= format_numbers(s[:last_seq]) %></code></td>
|
|
37
|
-
</tr>
|
|
19
|
+
<% @streams.each do |stream| -%>
|
|
20
|
+
<%= render("streams/_stream_row", { stream: stream }) %>
|
|
38
21
|
<% end -%>
|
|
39
22
|
</tbody>
|
|
40
23
|
</table>
|
data/lib/cosmo/web.rb
CHANGED
|
@@ -41,6 +41,8 @@ module Cosmo
|
|
|
41
41
|
in [:get, "/streams/info"] then [Controllers::Streams, :info]
|
|
42
42
|
in [:get, "/streams/_table"] then [Controllers::Streams, :_table]
|
|
43
43
|
in [:get, "/streams/_info"] then [Controllers::Streams, :_info]
|
|
44
|
+
in [:patch, "/streams/pause"] then [Controllers::Streams, :pause]
|
|
45
|
+
in [:patch, "/streams/unpause"] then [Controllers::Streams, :unpause]
|
|
44
46
|
in [:get, "/actions"] then [Controllers::Actions, :index]
|
|
45
47
|
in [:get, "/assets/htmx.min.js.gz"] then serve("htmx.2.0.8.min.js.gz",
|
|
46
48
|
"application/javascript",
|
data/sig/cosmo/api/stream.rbs
CHANGED
|
@@ -26,7 +26,7 @@ module Cosmo
|
|
|
26
26
|
|
|
27
27
|
def retries: () -> ::Integer
|
|
28
28
|
|
|
29
|
-
def each: (
|
|
29
|
+
def each: () { (Job) -> void } -> void
|
|
30
30
|
|
|
31
31
|
def messages: (?page: ::Integer?, ?limit: ::Integer?) -> Array[Job]
|
|
32
32
|
|
|
@@ -36,6 +36,12 @@ module Cosmo
|
|
|
36
36
|
|
|
37
37
|
def delete: (::Integer seq) -> untyped
|
|
38
38
|
|
|
39
|
+
def pause!: () -> untyped
|
|
40
|
+
|
|
41
|
+
def unpause!: () -> untyped
|
|
42
|
+
|
|
43
|
+
def paused?: () -> bool
|
|
44
|
+
|
|
39
45
|
private
|
|
40
46
|
|
|
41
47
|
def client: () -> Client
|