sidekiq 8.0.10 → 8.1.3

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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/Changes.md +58 -0
  3. data/README.md +16 -1
  4. data/bin/kiq +17 -0
  5. data/bin/lint-herb +13 -0
  6. data/lib/active_job/queue_adapters/sidekiq_adapter.rb +11 -8
  7. data/lib/generators/sidekiq/job_generator.rb +15 -3
  8. data/lib/sidekiq/api.rb +130 -76
  9. data/lib/sidekiq/capsule.rb +0 -1
  10. data/lib/sidekiq/cli.rb +2 -1
  11. data/lib/sidekiq/client.rb +3 -1
  12. data/lib/sidekiq/component.rb +3 -0
  13. data/lib/sidekiq/config.rb +4 -5
  14. data/lib/sidekiq/job.rb +2 -0
  15. data/lib/sidekiq/job_retry.rb +7 -3
  16. data/lib/sidekiq/launcher.rb +5 -5
  17. data/lib/sidekiq/manager.rb +1 -1
  18. data/lib/sidekiq/paginator.rb +6 -1
  19. data/lib/sidekiq/profiler.rb +1 -1
  20. data/lib/sidekiq/scheduled.rb +6 -7
  21. data/lib/sidekiq/test_api.rb +331 -0
  22. data/lib/sidekiq/testing/inline.rb +2 -30
  23. data/lib/sidekiq/testing.rb +2 -334
  24. data/lib/sidekiq/tui/controls.rb +53 -0
  25. data/lib/sidekiq/tui/filtering.rb +53 -0
  26. data/lib/sidekiq/tui/tabs/base_tab.rb +187 -0
  27. data/lib/sidekiq/tui/tabs/busy.rb +118 -0
  28. data/lib/sidekiq/tui/tabs/dead.rb +19 -0
  29. data/lib/sidekiq/tui/tabs/home.rb +144 -0
  30. data/lib/sidekiq/tui/tabs/metrics.rb +131 -0
  31. data/lib/sidekiq/tui/tabs/queues.rb +95 -0
  32. data/lib/sidekiq/tui/tabs/retries.rb +19 -0
  33. data/lib/sidekiq/tui/tabs/scheduled.rb +19 -0
  34. data/lib/sidekiq/tui/tabs/set_tab.rb +96 -0
  35. data/lib/sidekiq/tui/tabs.rb +15 -0
  36. data/lib/sidekiq/tui.rb +380 -0
  37. data/lib/sidekiq/version.rb +1 -1
  38. data/lib/sidekiq/web/action.rb +1 -1
  39. data/lib/sidekiq/web/application.rb +2 -2
  40. data/lib/sidekiq/web/config.rb +3 -6
  41. data/lib/sidekiq/web/helpers.rb +43 -3
  42. data/lib/sidekiq/web.rb +23 -4
  43. data/lib/sidekiq.rb +7 -0
  44. data/sidekiq.gemspec +6 -6
  45. data/web/assets/javascripts/application.js +1 -1
  46. data/web/assets/stylesheets/style.css +2 -2
  47. data/web/locales/ar.yml +1 -1
  48. data/web/locales/fa.yml +1 -1
  49. data/web/locales/gd.yml +1 -1
  50. data/web/locales/he.yml +1 -1
  51. data/web/locales/pt-BR.yml +1 -1
  52. data/web/locales/ur.yml +1 -1
  53. data/web/locales/zh-TW.yml +1 -1
  54. data/web/views/{_paging.erb → _paging.html.erb} +1 -1
  55. data/web/views/{busy.erb → busy.html.erb} +1 -1
  56. data/web/views/{metrics.erb → metrics.html.erb} +3 -2
  57. metadata +51 -35
  58. data/lib/sidekiq/web/csrf_protection.rb +0 -183
  59. /data/web/views/{_footer.erb → _footer.html.erb} +0 -0
  60. /data/web/views/{_job_info.erb → _job_info.html.erb} +0 -0
  61. /data/web/views/{_metrics_period_select.erb → _metrics_period_select.html.erb} +0 -0
  62. /data/web/views/{_nav.erb → _nav.html.erb} +0 -0
  63. /data/web/views/{_poll_link.erb → _poll_link.html.erb} +0 -0
  64. /data/web/views/{_summary.erb → _summary.html.erb} +0 -0
  65. /data/web/views/{dashboard.erb → dashboard.html.erb} +0 -0
  66. /data/web/views/{dead.erb → dead.html.erb} +0 -0
  67. /data/web/views/{filtering.erb → filtering.html.erb} +0 -0
  68. /data/web/views/{layout.erb → layout.html.erb} +0 -0
  69. /data/web/views/{metrics_for_job.erb → metrics_for_job.html.erb} +0 -0
  70. /data/web/views/{morgue.erb → morgue.html.erb} +0 -0
  71. /data/web/views/{profiles.erb → profiles.html.erb} +0 -0
  72. /data/web/views/{queue.erb → queue.html.erb} +0 -0
  73. /data/web/views/{queues.erb → queues.html.erb} +0 -0
  74. /data/web/views/{retries.erb → retries.html.erb} +0 -0
  75. /data/web/views/{retry.erb → retry.html.erb} +0 -0
  76. /data/web/views/{scheduled.erb → scheduled.html.erb} +0 -0
  77. /data/web/views/{scheduled_job_info.erb → scheduled_job_info.html.erb} +0 -0
@@ -1,334 +1,2 @@
1
- # frozen_string_literal: true
2
-
3
- require "securerandom"
4
- require "sidekiq"
5
-
6
- module Sidekiq
7
- class Testing
8
- class TestModeAlreadySetError < RuntimeError; end
9
- class << self
10
- attr_accessor :__global_test_mode
11
-
12
- # Calling without a block sets the global test mode, affecting
13
- # all threads. Calling with a block only affects the current Thread.
14
- def __set_test_mode(mode)
15
- if block_given?
16
- # Reentrant testing modes will lead to a rat's nest of code which is
17
- # hard to reason about. You can set the testing mode once globally and
18
- # you can override that global setting once per-thread.
19
- raise TestModeAlreadySetError, "Nesting test modes is not supported" if __local_test_mode
20
-
21
- self.__local_test_mode = mode
22
- begin
23
- yield
24
- ensure
25
- self.__local_test_mode = nil
26
- end
27
- else
28
- self.__global_test_mode = mode
29
- end
30
- end
31
-
32
- def __test_mode
33
- __local_test_mode || __global_test_mode
34
- end
35
-
36
- def __local_test_mode
37
- Thread.current[:__sidekiq_test_mode]
38
- end
39
-
40
- def __local_test_mode=(value)
41
- Thread.current[:__sidekiq_test_mode] = value
42
- end
43
-
44
- def disable!(&block)
45
- __set_test_mode(:disable, &block)
46
- end
47
-
48
- def fake!(&block)
49
- __set_test_mode(:fake, &block)
50
- end
51
-
52
- def inline!(&block)
53
- __set_test_mode(:inline, &block)
54
- end
55
-
56
- def enabled?
57
- __test_mode != :disable
58
- end
59
-
60
- def disabled?
61
- __test_mode == :disable
62
- end
63
-
64
- def fake?
65
- __test_mode == :fake
66
- end
67
-
68
- def inline?
69
- __test_mode == :inline
70
- end
71
-
72
- def server_middleware
73
- @server_chain ||= Middleware::Chain.new(Sidekiq.default_configuration)
74
- yield @server_chain if block_given?
75
- @server_chain
76
- end
77
- end
78
- end
79
-
80
- # Default to fake testing to keep old behavior
81
- Sidekiq::Testing.fake!
82
-
83
- class EmptyQueueError < RuntimeError; end
84
-
85
- module TestingClient
86
- private def atomic_push(conn, payloads)
87
- if Sidekiq::Testing.fake?
88
- payloads.each do |job|
89
- job = Sidekiq.load_json(Sidekiq.dump_json(job))
90
- job["enqueued_at"] = ::Process.clock_gettime(::Process::CLOCK_REALTIME, :millisecond) unless job["at"]
91
- Queues.push(job["queue"], job["class"], job)
92
- end
93
- true
94
- elsif Sidekiq::Testing.inline?
95
- payloads.each do |job|
96
- klass = Object.const_get(job["class"])
97
- job["id"] ||= SecureRandom.hex(12)
98
- job_hash = Sidekiq.load_json(Sidekiq.dump_json(job))
99
- klass.process_job(job_hash)
100
- end
101
- true
102
- else
103
- super
104
- end
105
- end
106
- end
107
-
108
- Sidekiq::Client.prepend TestingClient
109
-
110
- module Queues
111
- ##
112
- # The Queues class is only for testing the fake queue implementation.
113
- # There are 2 data structures involved in tandem. This is due to the
114
- # Rspec syntax of change(HardJob.jobs, :size). It keeps a reference
115
- # to the array. Because the array was derived from a filter of the total
116
- # jobs enqueued, it appeared as though the array didn't change.
117
- #
118
- # To solve this, we'll keep 2 hashes containing the jobs. One with keys based
119
- # on the queue, and another with keys of the job type, so the array for
120
- # HardJob.jobs is a straight reference to a real array.
121
- #
122
- # Queue-based hash:
123
- #
124
- # {
125
- # "default"=>[
126
- # {
127
- # "class"=>"TestTesting::HardJob",
128
- # "args"=>[1, 2],
129
- # "retry"=>true,
130
- # "queue"=>"default",
131
- # "jid"=>"abc5b065c5c4b27fc1102833",
132
- # "created_at"=>1447445554.419934
133
- # }
134
- # ]
135
- # }
136
- #
137
- # Job-based hash:
138
- #
139
- # {
140
- # "TestTesting::HardJob"=>[
141
- # {
142
- # "class"=>"TestTesting::HardJob",
143
- # "args"=>[1, 2],
144
- # "retry"=>true,
145
- # "queue"=>"default",
146
- # "jid"=>"abc5b065c5c4b27fc1102833",
147
- # "created_at"=>1447445554.419934
148
- # }
149
- # ]
150
- # }
151
- #
152
- # Example:
153
- #
154
- # require 'sidekiq/testing'
155
- #
156
- # assert_equal 0, Sidekiq::Queues["default"].size
157
- # HardJob.perform_async(:something)
158
- # assert_equal 1, Sidekiq::Queues["default"].size
159
- # assert_equal :something, Sidekiq::Queues["default"].first['args'][0]
160
- #
161
- # You can also clear all jobs:
162
- #
163
- # assert_equal 0, Sidekiq::Queues["default"].size
164
- # HardJob.perform_async(:something)
165
- # Sidekiq::Queues.clear_all
166
- # assert_equal 0, Sidekiq::Queues["default"].size
167
- #
168
- # This can be useful to make sure jobs don't linger between tests:
169
- #
170
- # RSpec.configure do |config|
171
- # config.before(:each) do
172
- # Sidekiq::Queues.clear_all
173
- # end
174
- # end
175
- #
176
- class << self
177
- def [](queue)
178
- jobs_by_queue[queue]
179
- end
180
-
181
- def push(queue, klass, job)
182
- jobs_by_queue[queue] << job
183
- jobs_by_class[klass] << job
184
- end
185
-
186
- def jobs_by_queue
187
- @jobs_by_queue ||= Hash.new { |hash, key| hash[key] = [] }
188
- end
189
-
190
- def jobs_by_class
191
- @jobs_by_class ||= Hash.new { |hash, key| hash[key] = [] }
192
- end
193
- alias_method :jobs_by_worker, :jobs_by_class
194
-
195
- def delete_for(jid, queue, klass)
196
- jobs_by_queue[queue.to_s].delete_if { |job| job["jid"] == jid }
197
- jobs_by_class[klass].delete_if { |job| job["jid"] == jid }
198
- end
199
-
200
- def clear_for(queue, klass)
201
- jobs_by_queue[queue.to_s].clear
202
- jobs_by_class[klass].clear
203
- end
204
-
205
- def clear_all
206
- jobs_by_queue.clear
207
- jobs_by_class.clear
208
- end
209
- end
210
- end
211
-
212
- module Job
213
- ##
214
- # The Sidekiq testing infrastructure overrides perform_async
215
- # so that it does not actually touch the network. Instead it
216
- # stores the asynchronous jobs in a per-class array so that
217
- # their presence/absence can be asserted by your tests.
218
- #
219
- # This is similar to ActionMailer's :test delivery_method and its
220
- # ActionMailer::Base.deliveries array.
221
- #
222
- # Example:
223
- #
224
- # require 'sidekiq/testing'
225
- #
226
- # assert_equal 0, HardJob.jobs.size
227
- # HardJob.perform_async(:something)
228
- # assert_equal 1, HardJob.jobs.size
229
- # assert_equal :something, HardJob.jobs[0]['args'][0]
230
- #
231
- # You can also clear and drain all job types:
232
- #
233
- # Sidekiq::Job.clear_all # or .drain_all
234
- #
235
- # This can be useful to make sure jobs don't linger between tests:
236
- #
237
- # RSpec.configure do |config|
238
- # config.before(:each) do
239
- # Sidekiq::Job.clear_all
240
- # end
241
- # end
242
- #
243
- # or for acceptance testing, i.e. with cucumber:
244
- #
245
- # AfterStep do
246
- # Sidekiq::Job.drain_all
247
- # end
248
- #
249
- # When I sign up as "foo@example.com"
250
- # Then I should receive a welcome email to "foo@example.com"
251
- #
252
- module ClassMethods
253
- # Queue for this worker
254
- def queue
255
- get_sidekiq_options["queue"]
256
- end
257
-
258
- # Jobs queued for this worker
259
- def jobs
260
- Queues.jobs_by_class[to_s]
261
- end
262
-
263
- # Clear all jobs for this worker
264
- def clear
265
- Queues.clear_for(queue, to_s)
266
- end
267
-
268
- # Drain and run all jobs for this worker
269
- def drain
270
- while jobs.any?
271
- next_job = jobs.first
272
- Queues.delete_for(next_job["jid"], next_job["queue"], to_s)
273
- process_job(next_job)
274
- end
275
- end
276
-
277
- # Pop out a single job and perform it
278
- def perform_one
279
- raise(EmptyQueueError, "perform_one called with empty job queue") if jobs.empty?
280
- next_job = jobs.first
281
- Queues.delete_for(next_job["jid"], next_job["queue"], to_s)
282
- process_job(next_job)
283
- end
284
-
285
- def process_job(job)
286
- instance = new
287
- instance.jid = job["jid"]
288
- instance.bid = job["bid"] if instance.respond_to?(:bid=)
289
- Sidekiq::Testing.server_middleware.invoke(instance, job, job["queue"]) do
290
- execute_job(instance, job["args"])
291
- end
292
- end
293
-
294
- def execute_job(worker, args)
295
- worker.perform(*args)
296
- end
297
- end
298
-
299
- class << self
300
- def jobs # :nodoc:
301
- Queues.jobs_by_queue.values.flatten
302
- end
303
-
304
- # Clear all queued jobs
305
- def clear_all
306
- Queues.clear_all
307
- end
308
-
309
- # Drain (execute) all queued jobs
310
- def drain_all
311
- while jobs.any?
312
- job_classes = jobs.map { |job| job["class"] }.uniq
313
-
314
- job_classes.each do |job_class|
315
- Object.const_get(job_class).drain
316
- end
317
- end
318
- end
319
- end
320
- end
321
-
322
- module TestingExtensions
323
- def jobs_for(klass)
324
- jobs.select do |job|
325
- marshalled = job["args"][0]
326
- marshalled.index(klass.to_s) && YAML.safe_load(marshalled)[0] == klass
327
- end
328
- end
329
- end
330
- end
331
-
332
- if defined?(::Rails) && Rails.respond_to?(:env) && !Rails.env.test? && !$TESTING # rubocop:disable Style/GlobalVars
333
- warn("⛔️ WARNING: Sidekiq testing API enabled, but this is not the test environment. Your jobs will not go to Redis.", uplevel: 1)
334
- end
1
+ Sidekiq.testing!(:fake)
2
+ warn('⛔️ `require "sidekiq/testing"` is deprecated and will be removed in Sidekiq 9.0. See https://sidekiq.org/wiki/Testing#new-api')
@@ -0,0 +1,53 @@
1
+ module Sidekiq
2
+ class TUI
3
+ module Controls
4
+ # Defines data for input handling and for displaying controls.
5
+ # :code is the key code for input handling.
6
+ # :display and :description are shown in the controls area, with different
7
+ # styling between them. If :display is omitted, :code is displayed instead.
8
+ # :action is a lambda to execute when the control is triggered.
9
+ # :refresh means the action requires immediate refreshing of data
10
+ #
11
+ # Conventions: dangerous/irreversible actions should use UPPERCASE codes.
12
+ # The Shift button means "I'm sure".
13
+ GLOBAL = [
14
+ {code: "?", display: "?", description: "Help", action: ->(tui, tab) { tui.show_help }},
15
+ {code: "left", display: "←/→", description: "Select Tab", action: ->(tui, tab) { tui.navigate(:left) }, refresh: true},
16
+ {code: "right", action: ->(tui, tab) { tui.navigate(:right) }, refresh: true},
17
+ {code: "q", description: "Quit", action: ->(tui, tab) { :quit }},
18
+ {code: "c", modifiers: ["ctrl"], action: ->(tui, tab) { :quit }}
19
+ ].freeze
20
+
21
+ SHARED = {
22
+ pageable: [
23
+ {code: "h", display: "h/l", description: "Prev/Next Page",
24
+ action: ->(tui, tab) { tab.prev_page }, refresh: true},
25
+ {code: "l", action: ->(tui, tab) { tab.next_page }, refresh: true}
26
+ ],
27
+ selectable: [
28
+ {code: "k", display: "j/k", description: "Prev/Next Row",
29
+ action: ->(tui, tab) { tab.navigate_row(:up) }},
30
+ {code: "j", action: ->(tui, tab) { tab.navigate_row(:down) }},
31
+ {code: "x", description: "Select", action: ->(tui, tab) { tab.toggle_select }},
32
+ {code: "A", modifiers: ["shift"], display: "A", description: "Select All",
33
+ action: ->(tui, tab) { tab.toggle_select(:all) }}
34
+ ],
35
+ filterable: [
36
+ {code: "/", display: "/", description: "Filter", action: ->(tui, tab) { tab.start_filtering }},
37
+ {code: "backspace", action: ->(tui, tab) { tab.remove_last_char_from_filter }, refresh: true},
38
+ {code: "enter", action: ->(tui, tab) { tab.stop_filtering }, refresh: true},
39
+ {code: "esc", action: ->(tui, tab) { tab.stop_and_clear_filtering }, refresh: true}
40
+ ]
41
+ }.freeze
42
+
43
+ # Returns an array of symbols for functionality which this tab implements
44
+ def features
45
+ []
46
+ end
47
+
48
+ def controls
49
+ GLOBAL + SHARED.slice(*features).values.flatten
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,53 @@
1
+ module Sidekiq
2
+ class TUI
3
+ module Filtering
4
+ def filtering?
5
+ @data[:filtering]
6
+ end
7
+
8
+ def current_filter
9
+ @data[:filter]
10
+ end
11
+
12
+ def start_filtering
13
+ @data[:filtering] = true
14
+ @data[:filter] = ""
15
+ end
16
+
17
+ def stop_filtering
18
+ return unless @data[:filtering]
19
+
20
+ @data[:filtering] = false
21
+ @data[:selected] = []
22
+ end
23
+
24
+ def stop_and_clear_filtering
25
+ return unless @data[:filtering]
26
+
27
+ @data[:filtering] = false
28
+ @data[:filter] = nil
29
+ @data[:selected] = []
30
+ on_filter_change
31
+ end
32
+
33
+ def remove_last_char_from_filter
34
+ return unless @data[:filtering]
35
+
36
+ @data[:filter] = @data[:filter].empty? ? "" : @data[:filter][0..-2]
37
+ on_filter_change
38
+ end
39
+
40
+ def append_to_filter(string)
41
+ return unless @data[:filtering]
42
+
43
+ @data[:filter] += string
44
+ @data[:selected] = []
45
+ on_filter_change
46
+ end
47
+
48
+ def on_filter_change
49
+ # callback for subclasses
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,187 @@
1
+ module Sidekiq
2
+ class TUI
3
+ class BaseTab
4
+ include Controls
5
+
6
+ attr_reader :name
7
+ attr_reader :data
8
+
9
+ def initialize(parent)
10
+ @parent = parent
11
+ @name = self.class.name.split("::").last
12
+ reset_data
13
+ end
14
+
15
+ def t(*)
16
+ @parent.t(*)
17
+ end
18
+
19
+ def reset_data
20
+ @data = {selected: [], selected_row_index: 0}
21
+ end
22
+
23
+ def error
24
+ @data[:error]
25
+ end
26
+
27
+ def error=(e)
28
+ @data[:error] = e
29
+ end
30
+
31
+ def selected?(entry)
32
+ @data[:selected].index(entry.id)
33
+ end
34
+
35
+ def filtering?
36
+ false
37
+ end
38
+
39
+ def each_selection(unselect: true, &)
40
+ sel = @data[:selected]
41
+ finished = []
42
+ if !sel.empty?
43
+ sel.each do |id|
44
+ yield id
45
+ # When processing multiple items in bulk, we want to unselect
46
+ # each row if its operation succeeds so our UI will not
47
+ # re-process rows 1-3 if row 4 fails.
48
+ finished << id
49
+ end
50
+ else
51
+ ids = @data.dig(:table, :row_ids)
52
+ return if !ids || ids.empty?
53
+ yield ids[@data[:selected_row_index]]
54
+ end
55
+ ensure
56
+ @data[:selected] = sel - finished if unselect
57
+ end
58
+
59
+ # Navigate the row selection up or down in the current tab's table.
60
+ # @param direction [Symbol] :up or :down
61
+ def navigate_row(direction)
62
+ ids = @data.dig(:table, :row_ids)
63
+ return if !ids || ids.empty?
64
+
65
+ index_change = (direction == :down) ? 1 : -1
66
+ @data[:selected_row_index] = (@data[:selected_row_index] + index_change) % ids.count
67
+ end
68
+
69
+ def prev_page
70
+ opts = @data.dig(:table, :pager)
71
+ return unless opts
72
+ return if opts.page < 2
73
+
74
+ @data[:table][:pager] = Sidekiq::TUI::PageOptions.new(opts.page - 1, opts.size)
75
+ end
76
+
77
+ def next_page
78
+ np = @data.dig(:table, :next_page)
79
+ return unless np
80
+ opts = @data.dig(:table, :pager)
81
+ return unless opts
82
+
83
+ @data[:table][:pager] = Sidekiq::TUI::PageOptions.new(np, opts.size)
84
+ end
85
+
86
+ def toggle_select(which = :current)
87
+ sel = @data[:selected]
88
+ # log(which, sel)
89
+ if which == :current
90
+ x = @data[:table][:row_ids][@data[:selected_row_index]]
91
+ if sel.index(x)
92
+ # already checked, uncheck it
93
+ sel.delete(x)
94
+ else
95
+ sel << x
96
+ end
97
+ elsif sel.empty?
98
+ @data[:selected] = @data[:table][:row_ids]
99
+ else
100
+ sel.clear
101
+ end
102
+ end
103
+
104
+ def refresh_data_for_stats
105
+ stats = Sidekiq::Stats.new
106
+ @data[:stats] = {
107
+ processed: stats.processed,
108
+ failed: stats.failed,
109
+ busy: stats.workers_size,
110
+ enqueued: stats.enqueued,
111
+ retries: stats.retry_size,
112
+ scheduled: stats.scheduled_size,
113
+ dead: stats.dead_size
114
+ }
115
+ end
116
+
117
+ def render_table(tui, frame, area)
118
+ page = @data.dig(:table, :current_page) || 1
119
+ rows = @data.dig(:table, :rows) || []
120
+ total = @data.dig(:table, :total) || 0
121
+ footer = ["", "Page: #{page}", "Count: #{rows.size}", "Total: #{total}"]
122
+ footer << "Selected: #{@data[:selected].size}" unless @data[:selected].empty?
123
+
124
+ defaults = {
125
+ title: "TableName",
126
+ footer: footer
127
+ }
128
+ if features.include?(:selectable)
129
+ defaults.merge!({
130
+ highlight_symbol: "➡️",
131
+ selected_row: @data[:selected_row_index],
132
+ row_highlight_style: tui.style(fg: :white, bg: :blue)
133
+ })
134
+ end
135
+ hash = defaults.merge(yield)
136
+ hash[:block] ||= tui.block(title: hash.delete(:title), borders: :all)
137
+ table = tui.table(**hash)
138
+ frame.render_widget(table, area)
139
+ end
140
+
141
+ def render_stats_section(tui, frame, area)
142
+ stats = @data[:stats]
143
+
144
+ keys = ["Processed", "Failed", "Busy", "Enqueued", "Retries", "Scheduled", "Dead"]
145
+ values = [
146
+ stats[:processed],
147
+ stats[:failed],
148
+ stats[:busy],
149
+ stats[:enqueued],
150
+ stats[:retries],
151
+ stats[:scheduled],
152
+ stats[:dead]
153
+ ]
154
+
155
+ # Format keys and values with spacing
156
+ keys_line = keys.map { |k| t(k).to_s.ljust(12) }.join(" ")
157
+ values_line = values.map { |v| v.to_s.ljust(12) }.join(" ")
158
+
159
+ frame.render_widget(
160
+ tui.paragraph(
161
+ text: [keys_line, values_line],
162
+ block: tui.block(title: "Statistics", borders: [:all])
163
+ ),
164
+ area
165
+ )
166
+ end
167
+
168
+ # TODO Implement I18n delimiter
169
+ def number_with_delimiter(number, options = {})
170
+ precision = options[:precision] || 0
171
+ number.round(precision)
172
+ end
173
+
174
+ def format_memory(rss_kb)
175
+ return "0" if rss_kb.nil? || rss_kb == 0
176
+
177
+ if rss_kb < 100_000
178
+ "#{number_with_delimiter(rss_kb)} KB"
179
+ elsif rss_kb < 10_000_000
180
+ "#{number_with_delimiter((rss_kb / 1024.0).to_i)} MB"
181
+ else
182
+ "#{number_with_delimiter(rss_kb / (1024.0 * 1024.0), precision: 1)} GB"
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end