sidekiq 8.1.1 → 8.1.2

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.
data/lib/sidekiq/tui.rb CHANGED
@@ -1,458 +1,251 @@
1
- require "bundler/inline"
2
-
3
- gemfile do
4
- source "https://gem.coop"
5
- gem "ratatui_ruby", "1.3.0"
6
- gem "sidekiq"
7
- end
8
-
9
- RatatuiRuby.debug_mode!
10
-
11
1
  # https://sr.ht/~kerrick/ratatui_ruby/
12
2
  # https://git.sr.ht/~kerrick/ratatui_ruby/tree/stable/item/examples/
3
+ gem "ratatui_ruby", ">=1.4.0"
13
4
  require "ratatui_ruby"
5
+
6
+ RatatuiRuby.debug_mode! if !!ENV["DEBUG"]
7
+
14
8
  require "sidekiq/api"
15
9
  require "sidekiq/paginator"
16
10
 
17
- # Suppress Sidekiq logger output to prevent interference with TUI rendering
18
- require "logger"
19
- Sidekiq.default_configuration.logger = Logger.new(IO::NULL)
20
-
21
- DebugLogger = Logger.new("tui.log")
22
- def log(*x)
23
- x.each { |item| DebugLogger.info { item } }
24
- end
11
+ require_relative "tui/filtering"
12
+ require_relative "tui/controls"
13
+ require_relative "tui/tabs"
25
14
 
26
15
  module Sidekiq
27
16
  class TUI
28
- include Sidekiq::Paginator
17
+ include Sidekiq::Component
29
18
 
30
19
  PageOptions = Data.define(:page, :size)
31
20
 
32
21
  REFRESH_INTERVAL_SECONDS = 2
22
+ LOCALE_DIRECTORIES = [File.expand_path("#{File.dirname(__FILE__)}/../../web/locales")]
33
23
 
34
- TABS = %w[Home Busy Queues Scheduled Retries Dead Metrics].freeze
35
- # CONTROLS defines data for input handling and for displaying controls.
36
- # :code is the key code for input handling.
37
- # :display and :description are shown in the controls area, with different
38
- # styling between them. If :display is omitted, :code is displayed instead.
39
- # Duplicate :display and :description values are ignored, shown only once.
40
- # :tabs is an array of tab names where the control is active.
41
- # :action is a lambda to execute when the control is triggered.
42
- #
43
- # Conventions: dangerous/irreversible actions should use UPPERCASE codes.
44
- # The Shift button means "I'm sure".
45
- CONTROLS = [
46
- {code: "?", display: "?", description: "Help", tabs: TABS,
47
- action: ->(tui) { tui.show_help }},
48
- {code: "left", display: "←/→", description: "Select Tab", tabs: TABS,
49
- action: ->(tui) { tui.navigate_tab(:left) }, refresh: true},
50
- {code: "right", display: "←/→", description: "Select Tab", tabs: TABS,
51
- action: ->(tui) { tui.navigate_tab(:right) }, refresh: true},
52
- {code: "q", display: "q", description: "Quit", tabs: TABS,
53
- action: ->(tui) { :quit }},
54
- {code: "c", modifiers: ["ctrl"], display: "q", description: "Quit", tabs: TABS,
55
- action: ->(tui) { :quit }},
56
- {code: "h", display: "h/l", description: "Prev/Next Page", tabs: TABS - ["Home"],
57
- action: ->(tui) { tui.prev_page }, refresh: true},
58
- {code: "l", display: "h/l", description: "Prev/Next Page", tabs: TABS - ["Home"],
59
- action: ->(tui) { tui.next_page }, refresh: true},
60
- {code: "k", display: "j/k", description: "Prev/Next Row", tabs: TABS - ["Home"],
61
- action: ->(tui) { tui.navigate_row(:up) }},
62
- {code: "j", display: "j/k", description: "Prev/Next Row", tabs: TABS - ["Home"],
63
- action: ->(tui) { tui.navigate_row(:down) }},
64
- {code: "x", display: "x", description: "Select", tabs: TABS - ["Home"],
65
- action: ->(tui) { tui.toggle_select }},
66
- {code: "A", modifiers: ["shift"], display: "A", description: "Select All", tabs: TABS - ["Home"],
67
- action: ->(tui) { tui.toggle_select(:all) }},
68
- {code: "D", modifiers: ["shift"], display: "D", description: "Delete", tabs: %w[Scheduled Retries Dead],
69
- action: ->(tui) { tui.alter_rows!(:delete) }, refresh: true},
70
- {code: "R", modifiers: ["shift"], display: "R", description: "Retry", tabs: %w[Retries],
71
- action: ->(tui) { tui.alter_rows!(:retry) }, refresh: true},
72
- {code: "E", modifiers: ["shift"], display: "E", description: "Enqueue", tabs: %w[Scheduled Dead],
73
- action: ->(tui) { tui.alter_rows!(:add_to_queue) }, refresh: true},
74
- {code: "K", modifiers: ["shift"], display: "K", description: "Kill", tabs: %w[Scheduled Retries],
75
- action: ->(tui) { tui.alter_rows!(:kill) }, refresh: true},
76
- {code: "D", modifiers: ["shift"], display: "D", description: "Delete", tabs: %w[Queues],
77
- action: ->(tui) { tui.delete_queue! }, refresh: true},
78
- {code: "p", description: "Pause/Unpause Queue", tabs: ["Queues"],
79
- action: ->(tui) { tui.toggle_pause_queue! }},
80
- {code: "T", modifiers: ["shift"], description: "Terminate", tabs: ["Busy"],
81
- action: ->(tui) { tui.terminate! }},
82
- {code: "Q", modifiers: ["shift"], description: "Quiet", tabs: ["Busy"],
83
- action: ->(tui) { tui.quiet! }},
84
- {code: "/", display: "/", description: "Filter", tabs: %w[Scheduled Retries Dead],
85
- action: ->(tui) { tui.start_filtering }}
86
- ].freeze
87
-
88
- def initialize
89
- @current_tab = "Home"
90
- @selected_row_index = 0
24
+ # language is meant to be a locale code, e.g.
25
+ # LANG=en_US.utf-8
26
+ def initialize(cfg, language: ENV["LANG"] || "en")
27
+ @lang = language
28
+ @config = cfg
91
29
  @base_style = nil
92
- @data = {}
93
- @last_refresh = Time.now
30
+ @last_refresh = Time.at(0)
31
+ @fps = Array.new(2) { 0 }
32
+ @previous_fps = 0
94
33
  @showing = :main
95
34
  end
96
35
 
97
- def run
98
- RatatuiRuby.run do |tui|
99
- @tui = tui
100
- @highlight_style = @tui.style(fg: :red, modifiers: [:underlined])
101
- @hotkey_style = @tui.style(modifiers: [:bold, :underlined])
36
+ def prepare(tui)
37
+ load_locale
102
38
 
103
- refresh_data
39
+ @tui = tui
40
+ @highlight_style = @tui.style(fg: :light_red, modifiers: [:underlined])
41
+ @hotkey_style = @tui.style(modifiers: [:bold, :underlined])
42
+ # eager load tabs
43
+ all
44
+ end
104
45
 
105
- loop do
106
- refresh_data if should_refresh?
107
- render
108
- break if handle_input == :quit
109
- end
46
+ def run_loop
47
+ # Must log to a file, terminal is now controlled by Ratatui
48
+ config.logger = Logger.new("tui.log")
49
+
50
+ loop do
51
+ refresh_data if should_refresh?
52
+ render
53
+ break if handle_input == :quit
110
54
  end
111
55
  end
112
56
 
113
57
  def render
114
- if @showing == :main
115
- @tui.draw do |frame|
116
- main_area, controls_area = @tui.layout_split(
117
- frame.area,
118
- direction: :vertical,
119
- constraints: [
120
- @tui.constraint_fill(1),
121
- @tui.constraint_length(4)
122
- ]
123
- )
124
-
125
- # Split main area into tabs and content
126
- tabs_area, content_area = @tui.layout_split(
127
- main_area,
128
- direction: :vertical,
129
- constraints: [
130
- @tui.constraint_length(3),
131
- @tui.constraint_fill(1)
132
- ]
133
- )
134
-
135
- tabs = @tui.tabs(
136
- titles: TABS,
137
- selected_index: TABS.index(@current_tab),
138
- block: @tui.block(title: Sidekiq::NAME, borders: [:all], title_style: @tui.style(fg: :red, modifiers: [:bold])),
139
- divider: " | ",
140
- highlight_style: @highlight_style,
141
- style: @base_style
142
- )
143
- frame.render_widget(tabs, tabs_area)
144
-
145
- render_content_area(frame, content_area)
146
- render_controls(frame, controls_area)
58
+ track_fps do
59
+ if @showing == :main
60
+ @tui.draw do |frame|
61
+ main_area, controls_area = @tui.layout_split(
62
+ frame.area,
63
+ direction: :vertical,
64
+ constraints: [
65
+ @tui.constraint_fill(1),
66
+ @tui.constraint_length(5)
67
+ ]
68
+ )
69
+
70
+ # Split main area into tabs and content
71
+ tabs_area, content_area = @tui.layout_split(
72
+ main_area,
73
+ direction: :vertical,
74
+ constraints: [
75
+ @tui.constraint_length(3),
76
+ @tui.constraint_fill(1)
77
+ ]
78
+ )
79
+
80
+ all_tabs = all
81
+ tabs = @tui.tabs(
82
+ titles: all_tabs.map { |tab| t(tab.name) },
83
+ selected_index: all_tabs.index(current_tab),
84
+ block: @tui.block(title: " #{Sidekiq::NAME}", borders: [:all], title_style: @tui.style(fg: :light_red, modifiers: [:bold])),
85
+ divider: " | ",
86
+ highlight_style: @highlight_style,
87
+ style: @base_style
88
+ )
89
+ frame.render_widget(tabs, tabs_area)
90
+
91
+ render_content_area(frame, content_area)
92
+ render_controls(frame, controls_area)
93
+ end
147
94
  end
148
- end
149
95
 
150
- if @showing == :help
151
- @tui.draw do |frame|
152
- main_area, controls_area = @tui.layout_split(
153
- frame.area,
154
- direction: :vertical,
155
- constraints: [
156
- @tui.constraint_fill(1),
157
- @tui.constraint_length(4)
158
- ]
159
- )
160
- content = @tui.block(
161
- title: Sidekiq::NAME,
162
- borders: [:all],
163
- title_style: @tui.style(fg: :red, modifiers: [:bold]),
164
- children: [
165
- # TODO convert to table
166
- @tui.paragraph(
167
- text: [
168
- @tui.text_line(spans: ["Welcome to the Sidekiq Terminal UI"], alignment: :center),
169
- @tui.text_line(spans: [
170
- @tui.text_span(content: "Esc", style: @hotkey_style),
171
- @tui.text_span(content: ": Close")
172
- ]),
173
- @tui.text_line(spans: [
174
- @tui.text_span(content: "←/→", style: @hotkey_style),
175
- @tui.text_span(content: ": Move between tabs")
176
- ]),
177
- @tui.text_line(spans: [
178
- @tui.text_span(content: "j/k", style: @hotkey_style),
179
- @tui.text_span(content: ": Use vim keys to move to prev/next row")
180
- ]),
181
- @tui.text_line(spans: [
182
- @tui.text_span(content: "x", style: @hotkey_style),
183
- @tui.text_span(content: ": Select/deselect current row")
184
- ]),
185
- @tui.text_line(spans: [
186
- @tui.text_span(content: "A", style: @hotkey_style),
187
- @tui.text_span(content: ": Select/deselect All visible rows")
188
- ]),
189
- @tui.text_line(spans: [
190
- @tui.text_span(content: "h/l", style: @hotkey_style),
191
- @tui.text_span(content: ": Use vim keys to move to prev/next page")
192
- ]),
193
- @tui.text_line(spans: [
194
- @tui.text_span(content: "q", style: @hotkey_style),
195
- @tui.text_span(content: ": Quit")
196
- ])
197
- ]
198
- )
199
- ]
200
- )
201
- frame.render_widget(content, main_area)
202
- controls = @tui.block(
203
- title: "Controls",
204
- borders: [:all],
205
- children: [
206
- @tui.paragraph(
207
- text: [
208
- @tui.text_line(spans: [
209
- @tui.text_span(content: "Esc", style: @hotkey_style),
210
- @tui.text_span(content: ": Close ")
211
- ])
212
- ]
213
- )
214
- ]
215
- )
216
- frame.render_widget(controls, controls_area)
96
+ if @showing == :help
97
+ @tui.draw do |frame|
98
+ main_area, controls_area = @tui.layout_split(
99
+ frame.area,
100
+ direction: :vertical,
101
+ constraints: [
102
+ @tui.constraint_fill(1),
103
+ @tui.constraint_length(4)
104
+ ]
105
+ )
106
+ content = @tui.block(
107
+ title: " #{Sidekiq::NAME} ",
108
+ borders: [:all],
109
+ title_style: @tui.style(fg: :light_red, modifiers: [:bold]),
110
+ children: [
111
+ # TODO convert to table
112
+ @tui.paragraph(
113
+ text: [
114
+ @tui.text_line(spans: ["Welcome to the Sidekiq Terminal UI"], alignment: :center),
115
+ @tui.text_line(spans: [
116
+ @tui.text_span(content: "Global hotkeys")
117
+ ]),
118
+ @tui.text_line(spans: []),
119
+ @tui.text_line(spans: [
120
+ @tui.text_span(content: "Esc", style: @hotkey_style),
121
+ @tui.text_span(content: ": Close this window")
122
+ ]),
123
+ @tui.text_line(spans: [
124
+ @tui.text_span(content: "←/→", style: @hotkey_style),
125
+ @tui.text_span(content: ": Move between tabs")
126
+ ]),
127
+ @tui.text_line(spans: [
128
+ @tui.text_span(content: "h/l", style: @hotkey_style),
129
+ @tui.text_span(content: ": Move to prev/next page of data")
130
+ ]),
131
+ @tui.text_line(spans: [
132
+ @tui.text_span(content: "j/k", style: @hotkey_style),
133
+ @tui.text_span(content: ": Move to prev/next row in current page")
134
+ ]),
135
+ @tui.text_line(spans: [
136
+ @tui.text_span(content: "x", style: @hotkey_style),
137
+ @tui.text_span(content: ": Select/deselect current row")
138
+ ]),
139
+ @tui.text_line(spans: [
140
+ @tui.text_span(content: "A", style: @hotkey_style),
141
+ @tui.text_span(content: ": Select/deselect All rows in current page")
142
+ ]),
143
+ @tui.text_line(spans: [
144
+ @tui.text_span(content: "q", style: @hotkey_style),
145
+ @tui.text_span(content: ": Quit")
146
+ ])
147
+ ]
148
+ )
149
+ ]
150
+ )
151
+ frame.render_widget(content, main_area)
152
+ controls = @tui.block(
153
+ title: t("Controls"),
154
+ borders: [:all],
155
+ children: [
156
+ @tui.paragraph(
157
+ text: [
158
+ @tui.text_line(spans: [
159
+ @tui.text_span(content: "Esc", style: @hotkey_style),
160
+ @tui.text_span(content: ": Close ")
161
+ ])
162
+ ]
163
+ )
164
+ ]
165
+ )
166
+ frame.render_widget(controls, controls_area)
167
+ end
217
168
  end
218
169
  end
219
170
  end
220
171
 
221
172
  def render_content_area(frame, content_area)
222
- return render_error(frame, content_area, @data[:error]) if @data[:error]
223
-
224
- case @current_tab
225
- when "Home"
226
- render_home(frame, content_area)
227
- when "Busy"
228
- render_busy(frame, content_area)
229
- when "Queues"
230
- render_queues(frame, content_area)
231
- when "Scheduled", "Retries", "Dead"
232
- render_set(frame, content_area)
233
- when "Metrics"
234
- render_metrics(frame, content_area)
235
- else
236
- frame.render_widget(
237
- @tui.paragraph(
238
- text: "Tab '#{@current_tab}' - Coming soon",
239
- alignment: :center,
240
- block: @tui.block(title: @current_tab, borders: [:all])
241
- ),
242
- content_area
243
- )
244
- end
173
+ return render_error(frame, content_area, current_tab.error) if current_tab.error
174
+
175
+ current_tab.render(@tui, frame, content_area)
245
176
  end
246
177
 
247
178
  def render_controls(frame, area)
248
- keys_and_descriptions = CONTROLS
249
- .select { |ctrl|
250
- ctrl[:tabs].include?(@current_tab)
251
- }.map { |ctrl|
252
- [ctrl[:display] || ctrl[:code], ctrl[:description]]
253
- }.to_h
254
-
255
- controls = @tui.block(
256
- title: "Controls",
257
- borders: [:all],
258
- children: [
259
- @tui.paragraph(
260
- text: [
261
- @tui.text_line(spans: keys_and_descriptions.map { |key, desc|
262
- [
263
- @tui.text_span(content: key, style: @hotkey_style),
264
- @tui.text_span(content: ": #{desc} ")
265
- ]
266
- }.flatten),
267
- # @tui.text_line(spans: [
268
- # @tui.text_span(content: "d", style: @hotkey_style),
269
- # @tui.text_span(content: ": Divider (#{@dividers[@divider_index]}) "),
270
- # @tui.text_span(content: "s", style: @hotkey_style),
271
- # @tui.text_span(content: ": Highlight (#{@highlight_styles[@highlight_style_index][:name]}) "),
272
- # @tui.text_span(content: "b", style: @hotkey_style),
273
- # @tui.text_span(content: ": Base Style (#{@base_styles[@base_style_index][:name]}) "),
274
- # ]),
275
- @tui.text_line(spans: [
276
- @tui.text_span(content: "Redis: #{redis_url} "),
277
- @tui.text_span(content: "Current Time: #{Time.now.utc}")
278
- ])
279
- ]
280
- )
179
+ active_keys = current_tab.controls.filter { |hash| hash[:description] }
180
+
181
+ # Split controls into two lines, 8 is arbitrary
182
+ # TODO Dynamically split based on term width?
183
+ first = active_keys[...8]
184
+ lines = []
185
+ lines << @tui.text_line(spans: first.map { |hash|
186
+ [
187
+ @tui.text_span(content: hash[:display] || hash[:code], style: @hotkey_style),
188
+ @tui.text_span(content: ": #{t(hash[:description])} ")
281
189
  ]
282
- )
190
+ }.flatten)
191
+
192
+ last = active_keys[8...]
193
+ lines << if last && last.size > 0
194
+ @tui.text_line(spans: last.map { |hash|
195
+ [
196
+ @tui.text_span(content: hash[:display] || hash[:code], style: @hotkey_style),
197
+ @tui.text_span(content: ": #{t(hash[:description])} ")
198
+ ]
199
+ }.flatten)
200
+ else
201
+ @tui.text_line(spans: [])
202
+ end
203
+
204
+ footer = [
205
+ @tui.text_span(content: "Redis: #{redis_url} "),
206
+ @tui.text_span(content: "#{t("Now")}: #{Time.now.utc} "),
207
+ @tui.text_span(content: "#{t("Locale")}: #{@lang}")
208
+ ]
209
+
210
+ if current_tab.data[:filter]
211
+ @filter_style = @tui.style(fg: :white, bg: :dark_gray)
212
+ footer += [
213
+ @tui.text_span(content: " #{t("Filter")}: ", style: @filter_style),
214
+ @tui.text_span(content: current_tab.data[:filter], style: @filter_style),
215
+ @tui.text_span(content: "_", style: @tui.style(fg: :white, bg: :dark_gray, modifiers: [:slow_blink]))
216
+ ]
217
+ end
218
+ footer << @tui.text_span(content: " FPS: #{previous_fps}") if debugging?
219
+ lines << @tui.text_line(spans: footer)
220
+
221
+ controls = @tui.block(title: t("Controls"), borders: [:all],
222
+ children: [@tui.paragraph(text: lines)])
283
223
  frame.render_widget(controls, area)
284
224
  end
285
225
 
286
226
  def handle_input
287
- case @tui.poll_event
288
- in {type: :key, code: "backspace"} if @data[:filtering]
289
- @data[:filter] = @data[:filter].empty? ? "" : @data[:filter][0..-2]
290
- in {type: :key, code: "enter"} if @data[:filtering]
291
- @data[:filtering] = nil
292
- @data[:selected] = []
227
+ # We shouldn't need more than 10 FPS for a data-oriented app.
228
+ # This throttles down our CPU usage. Default is 60 FPS.
229
+ case @tui.poll_event(timeout: 0.1)
293
230
  in {type: :key, code: "esc"} if @showing == :help
294
231
  @showing = :main
295
- in {type: :key, code: "esc"} if @data[:filtering]
296
- @data[:filtering] = nil
297
- @data[:filter] = nil
298
- @data[:selected] = []
299
- in {type: :key, code: code} if @data[:filtering] && code.length == 1
300
- @data[:filter] += code
301
- @data[:selected] = []
232
+ in {type: :key, code: code} if current_tab.filtering? && code.length == 1
233
+ current_tab.append_to_filter(code)
234
+ current_tab.refresh_data
302
235
  in {type: :key, code:, modifiers:}
303
- control = CONTROLS.find { |ctrl|
236
+ control = current_tab.controls.find { |ctrl|
304
237
  ctrl[:code] == code &&
305
- (ctrl[:modifiers] || []) == (modifiers || []) &&
306
- ctrl[:tabs].include?(@current_tab)
238
+ (ctrl[:modifiers] || []) == (modifiers || [])
307
239
  }
308
240
  return unless control
309
- control[:action].call(self).tap {
241
+ control[:action].call(self, current_tab).tap {
310
242
  refresh_data if control[:refresh]
311
243
  }
312
244
  else
313
245
  # Ignore other events
314
246
  end
315
247
  rescue => ex
316
- log(ex.message, ex.backtrace)
317
- end
318
-
319
- def show_help
320
- @showing = :help
321
- end
322
-
323
- # Navigate tabs to the left or right.
324
- # @param direction [Symbol] :left or :right
325
- def navigate_tab(direction)
326
- index_change = (direction == :right) ? 1 : -1
327
- @current_tab = TABS[(TABS.index(@current_tab) + index_change) % TABS.size]
328
- @selected_row_index = 0
329
- @data = {
330
- selected: [],
331
- filter: nil
332
- }
333
- end
334
-
335
- # Navigate the row selection up or down in the current tab's table.
336
- # @param direction [Symbol] :up or :down
337
- def navigate_row(direction)
338
- ids = @data.dig(:table, :row_ids)
339
- return if !ids || ids.empty?
340
-
341
- index_change = (direction == :down) ? 1 : -1
342
- @selected_row_index = (@selected_row_index + index_change) % ids.count
343
- end
344
-
345
- def start_filtering
346
- @data[:filtering] = true
347
- @data[:filter] = ""
348
- end
349
-
350
- def stop_filtering
351
- @data[:filtering] = false
352
- end
353
-
354
- def quiet!
355
- each_selection do |id|
356
- Sidekiq::Process.new("identity" => id).quiet!
357
- end
358
- end
359
-
360
- def terminate!
361
- each_selection do |id|
362
- Sidekiq::Process.new("identity" => id).stop!
363
- end
364
- end
365
-
366
- def each_selection(unselect: true, &)
367
- sel = @data[:selected]
368
- finished = []
369
- if !sel.empty?
370
- sel.each do |id|
371
- yield id
372
- # When processing multiple items in bulk, we want to unselect
373
- # each row if its operation succeeds so our UI will not
374
- # re-process rows 1-3 if row 4 fails.
375
- finished << id
376
- end
377
- else
378
- ids = @data.dig(:table, :row_ids)
379
- return if !ids || ids.empty?
380
- yield ids[@selected_row_index]
381
- end
382
- ensure
383
- @data[:selected] = sel - finished if unselect
384
- end
385
-
386
- def delete_queue!
387
- each_selection do |qname|
388
- Sidekiq::Queue.new(qname).clear
389
- end
390
- end
391
-
392
- def alter_rows!(action = :add_to_queue)
393
- log(@current_tab, @data[:selected])
394
- set = case @current_tab
395
- when "Scheduled"
396
- Sidekiq::ScheduledSet.new
397
- when "Retries"
398
- Sidekiq::RetrySet.new
399
- when "Dead"
400
- Sidekiq::DeadSet.new
401
- end
402
- return unless set
403
- each_selection do |id|
404
- score, jid = id.split("|")
405
- item = set.fetch(score, jid)&.first
406
- item&.send(action)
407
- end
408
- end
409
-
410
- def prev_page
411
- opts = @data.dig(:table, :pager)
412
- return unless opts
413
- return if opts.page < 2
414
-
415
- @data[:table][:pager] = Sidekiq::TUI::PageOptions.new(opts.page - 1, opts.size)
416
- end
417
-
418
- def next_page
419
- np = @data.dig(:table, :next_page)
420
- return unless np
421
- opts = @data.dig(:table, :pager)
422
- return unless opts
423
-
424
- @data[:table][:pager] = Sidekiq::TUI::PageOptions.new(np, opts.size)
425
- end
426
-
427
- def toggle_select(which = :current)
428
- sel = @data[:selected]
429
- log(which, sel)
430
- if which == :current
431
- x = @data[:table][:row_ids][@selected_row_index]
432
- if sel.index(x)
433
- # already checked, uncheck it
434
- sel.delete(x)
435
- else
436
- sel << x
437
- end
438
- elsif sel.empty?
439
- @data[:selected] = @data[:table][:row_ids]
440
- else
441
- sel.clear
442
- end
443
- end
444
-
445
- def toggle_pause_queue!
446
- return unless Sidekiq.pro?
447
-
448
- each_selection do |qname|
449
- queue = Sidekiq::Queue.new(qname)
450
- if queue.paused?
451
- queue.unpause!
452
- else
453
- queue.pause!
454
- end
455
- end
248
+ logger.error { [ex.message, ex.backtrace] }
456
249
  end
457
250
 
458
251
  def redis_url
@@ -468,552 +261,120 @@ module Sidekiq
468
261
  end
469
262
 
470
263
  def refresh_data
471
- stats = Sidekiq::Stats.new
472
- @data[:stats] = {
473
- processed: stats.processed,
474
- failed: stats.failed,
475
- busy: stats.workers_size,
476
- enqueued: stats.enqueued,
477
- retries: stats.retry_size,
478
- scheduled: stats.scheduled_size,
479
- dead: stats.dead_size
480
- }
481
-
482
- case @current_tab
483
- when "Home"
484
- @data[:chart] ||= {
485
- previous_stats: {
486
- processed: stats.processed,
487
- failed: stats.failed
488
- },
489
- deltas: {
490
- processed: Array.new(50, 0),
491
- failed: Array.new(50, 0)
492
- }
493
- }
494
-
495
- processed_delta = stats.processed - @data[:chart][:previous_stats][:processed]
496
- failed_delta = stats.failed - @data[:chart][:previous_stats][:failed]
497
-
498
- @data[:chart][:deltas][:processed].shift
499
- @data[:chart][:deltas][:processed].push(processed_delta)
500
- @data[:chart][:deltas][:failed].shift
501
- @data[:chart][:deltas][:failed].push(failed_delta)
502
-
503
- @data[:chart][:previous_stats] = {
504
- processed: stats.processed,
505
- failed: stats.failed
506
- }
507
-
508
- redis_info = Sidekiq.default_configuration.redis_info
509
-
510
- @data[:redis_info] = {
511
- version: redis_info["redis_version"] || "N/A",
512
- uptime_days: redis_info["uptime_in_days"] || "N/A",
513
- connected_clients: redis_info["connected_clients"] || "N/A",
514
- used_memory: redis_info["used_memory_human"] || "N/A",
515
- peak_memory: redis_info["used_memory_peak_human"] || "N/A"
516
- }
517
- when "Busy"
518
- busy = []
519
- table_row_ids = []
520
-
521
- Sidekiq::ProcessSet.new.each do |p|
522
- name = "#{p["hostname"]}:#{p["pid"]}"
523
- name += " ⭐️" if p.leader?
524
- name += " 🛑" if p.stopping?
525
- busy << [
526
- selected?(p) ? "✅" : "",
527
- name,
528
- Time.at(p["started_at"]).utc,
529
- format_memory(p["rss"].to_i),
530
- number_with_delimiter(p["concurrency"]),
531
- number_with_delimiter(p["busy"])
532
- ]
533
- table_row_ids << p.identity
534
- end
535
-
536
- @data[:busy] = busy
537
- @data[:table] = {row_ids: table_row_ids}
538
- when "Queues"
539
- queue_summaries = Sidekiq::Stats.new.queue_summaries.sort_by(&:name)
540
-
541
- selected = Array(@data[:selected])
542
- queues = queue_summaries.map { |queue_summary|
543
- row_cells = [
544
- selected.index(queue_summary.name) ? "✅" : "",
545
- queue_summary.name,
546
- queue_summary.size.to_s,
547
- number_with_delimiter(queue_summary.latency, {precision: 2})
548
- ]
549
- row_cells << (queue_summary.paused? ? "✅" : "") if Sidekiq.pro?
550
- row_cells
551
- }
552
-
553
- table_row_ids = queue_summaries.map(&:name)
554
-
555
- @data[:queues] = queues
556
- @data[:table] = {row_ids: table_row_ids}
557
- when "Scheduled"
558
- data_for_set(Sidekiq::ScheduledSet.new)
559
- when "Retries"
560
- data_for_set(Sidekiq::RetrySet.new)
561
- when "Dead"
562
- data_for_set(Sidekiq::DeadSet.new)
563
- when "Metrics"
564
- # only need to refresh every 60 seconds
565
- if !@data[:metrics_refresh] || @data[:metrics_refresh] < Time.now
566
- q = Sidekiq::Metrics::Query.new
567
- query_result = q.top_jobs(minutes: 60)
568
- @data[:metrics] = query_result
569
- @data[:metrics_refresh] = Time.now + 60
570
- end
571
- end
572
-
264
+ # logger.info GC.stat
265
+ current_tab.refresh_data
573
266
  @last_refresh = Time.now
574
267
  rescue => e
575
- @data = {error: e}
576
- end
577
-
578
- def data_for_set(set)
579
- f = @data[:filter]
580
- pager, rows, current, total = if f && f.size > 2
581
- rows = set.scan(f).to_a
582
- sz = rows.size
583
- [Sidekiq::TUI::PageOptions.new(1, sz), rows, 1, sz]
584
- else
585
- pager = @data.dig(:table, :pager) || Sidekiq::TUI::PageOptions.new(1, 25)
586
- current, total, items = page(set.name, pager.page, pager.size)
587
- rows = items.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) }
588
- [pager, rows, current, total]
589
- end
590
-
591
- @data.merge!(
592
- table: {pager:, rows:, current_page: current, total:,
593
- next_page: (current * pager.size < total) ? pager.page + 1 : nil,
594
- row_ids: rows.map { |job| [job.score, job["jid"]].join("|") }}
595
- )
268
+ handle_exception(e)
269
+ current_tab.error = e
596
270
  end
597
271
 
598
- def render_busy(frame, area)
599
- chunks = @tui.layout_split(
600
- area,
601
- direction: :vertical,
602
- constraints: [
603
- @tui.constraint_length(4), # Stats
604
- @tui.constraint_length(4), # Status
605
- @tui.constraint_fill(1) # Graph
606
- ]
607
- )
608
-
609
- render_stats_section(frame, chunks[0])
610
- render_status_section(frame, chunks[1])
611
- render_table(frame, chunks[2]) do
612
- {
613
- title: "Processes",
614
- header: ["☑️", "Name", "Started", "RSS", "Threads", "Busy"],
615
- widths: [
616
- @tui.constraint_length(5),
617
- @tui.constraint_fill(1),
618
- @tui.constraint_length(24),
619
- @tui.constraint_length(10),
620
- @tui.constraint_length(6),
621
- @tui.constraint_length(6)
622
- ],
623
- rows: @data[:busy].map.with_index { |cells, idx|
624
- @tui.table_row(
625
- cells:,
626
- style: idx.even? ? nil : @tui.style(bg: :dark_gray)
627
- )
628
- }
629
- }
630
- end
631
- end
632
-
633
- def render_set(frame, area)
634
- chunks = @tui.layout_split(
635
- area,
636
- direction: :vertical,
637
- constraints: [
638
- @tui.constraint_length(4), # Stats
639
- @tui.constraint_fill(1) # Table
640
- ]
641
- )
642
-
643
- render_stats_section(frame, chunks[0])
644
- render_table(frame, chunks[1]) do
645
- {
646
- title: @current_tab,
647
- header: ["☑️", "When", "Queue", "Job", "Arguments"],
648
- widths: [
649
- @tui.constraint_length(5),
650
- @tui.constraint_length(24),
651
- @tui.constraint_length(20),
652
- @tui.constraint_length(30),
653
- @tui.constraint_fill(1)
654
- ]
655
- }.tap do |h|
656
- rows = @data[:table][:rows].map.with_index { |entry, idx|
657
- @tui.table_row(
658
- cells: [
659
- selected?(entry) ? "✅" : "",
660
- entry.at,
661
- entry.queue,
662
- entry.display_class,
663
- entry.display_args
664
- ],
665
- style: idx.even? ? nil : @tui.style(bg: :dark_gray)
666
- )
667
- }
668
- h[:rows] = rows
669
- end
670
- end
671
- end
672
-
673
- def selected?(entry)
674
- @data[:selected].index(entry.id)
675
- end
676
-
677
- def render_home(frame, area)
678
- chunks = @tui.layout_split(
679
- area,
680
- direction: :vertical,
681
- constraints: [
682
- @tui.constraint_length(4), # Stats
683
- @tui.constraint_fill(1), # Graph
684
- @tui.constraint_length(4) # Redis
685
- ]
686
- )
687
-
688
- render_stats_section(frame, chunks[0])
689
- render_chart_section(frame, chunks[1])
690
- render_redis_info_section(frame, chunks[2])
691
- end
692
-
693
- def render_status_section(frame, area)
694
- keys = ["Processes", "Threads", "Busy", "Utilization", "RSS"]
695
- values = []
696
- processes = Sidekiq::ProcessSet.new
697
- workset = Sidekiq::WorkSet.new
698
- ws = workset.size
699
- values << (s = processes.size
700
- number_with_delimiter(s))
701
- values << (x = processes.total_concurrency
702
- number_with_delimiter(x))
703
- values << number_with_delimiter(ws)
704
- values << "#{(x == 0) ? 0 : ((ws / x.to_f) * 100).round(0)}%"
705
- values << format_memory(processes.total_rss)
706
-
707
- keys_line = keys.map { |k| k.to_s.ljust(12) }.join(" ")
708
- values_line = values.map { |v| v.to_s.ljust(12) }.join(" ")
272
+ def render_error(frame, area, err)
273
+ header = [@tui.text_line(
274
+ spans: [@tui.text_span(content: err.message, style: @tui.style(modifiers: [:bold]))],
275
+ alignment: :center
276
+ )]
277
+ lines = Array(err.backtrace).map { |line| @tui.text_line(spans: [@tui.text_span(content: line)]) }
709
278
 
710
279
  frame.render_widget(
711
280
  @tui.paragraph(
712
- text: [keys_line, values_line],
713
- block: @tui.block(title: "Status", borders: [:all])
281
+ text: header + lines,
282
+ alignment: :left,
283
+ block: @tui.block(title: t("Error"), borders: [:all], border_style: @tui.style(fg: :light_red))
714
284
  ),
715
285
  area
716
286
  )
717
287
  end
718
288
 
719
- def render_stats_section(frame, area)
720
- stats = @data[:stats]
721
-
722
- keys = ["Processed", "Failed", "Busy", "Enqueued", "Retries", "Scheduled", "Dead"]
723
- values = [
724
- stats[:processed],
725
- stats[:failed],
726
- stats[:busy],
727
- stats[:enqueued],
728
- stats[:retries],
729
- stats[:scheduled],
730
- stats[:dead]
731
- ]
732
-
733
- # Format keys and values with spacing
734
- keys_line = keys.map { |k| k.to_s.ljust(12) }.join(" ")
735
- values_line = values.map { |v| v.to_s.ljust(12) }.join(" ")
736
-
737
- frame.render_widget(
738
- @tui.paragraph(
739
- text: [keys_line, values_line],
740
- block: @tui.block(title: "Statistics", borders: [:all])
741
- ),
742
- area
743
- )
289
+ def show_help
290
+ @showing = :help
744
291
  end
745
292
 
746
- def render_chart_section(frame, area)
747
- max_value = [@data[:chart][:deltas][:processed].max, @data[:chart][:deltas][:failed].max, 1].max
748
- y_max = [max_value, 5].max
749
-
750
- processed_data = @data[:chart][:deltas][:processed].each_with_index.map { |value, idx| [idx.to_f, value.to_f] }
751
- failed_data = @data[:chart][:deltas][:failed].each_with_index.map { |value, idx| [idx.to_f, value.to_f] }
752
-
753
- datasets = [
754
- @tui.dataset(
755
- name: "",
756
- data: processed_data,
757
- style: @tui.style(fg: :green),
758
- marker: :dot,
759
- graph_type: :line
760
- ),
761
- @tui.dataset(
762
- name: "",
763
- data: failed_data,
764
- style: @tui.style(fg: :red),
765
- marker: :dot,
766
- graph_type: :line
767
- )
768
- ]
769
-
770
- num_labels = 5
771
- y_labels = (0...num_labels).map do |i|
772
- value = ((y_max * i) / (num_labels - 1)).round
773
- value.to_s
774
- end
775
-
776
- beacon_pulse = (Time.now.to_i % 2 == 0) ? "●" : " "
777
-
778
- chart = @tui.chart(
779
- datasets: datasets,
780
- x_axis: @tui.axis(
781
- bounds: [0.0, 49.0],
782
- labels: [],
783
- style: @tui.style(fg: :white)
784
- ),
785
- y_axis: @tui.axis(
786
- bounds: [0.0, y_max.to_f],
787
- labels: y_labels,
788
- style: @tui.style(fg: :white)
789
- ),
790
- block: @tui.block(
791
- title: "Dashboard #{beacon_pulse}",
792
- borders: [:all]
793
- )
794
- )
795
-
796
- frame.render_widget(chart, area)
293
+ def all
294
+ @all ||= Tabs::All.map { |kls| kls.new(self) }
797
295
  end
798
296
 
799
- def render_redis_info_section(frame, area)
800
- redis_info = @data[:redis_info]
801
-
802
- uptime_value = (redis_info[:uptime_days] == "N/A") ? "N/A" : "#{redis_info[:uptime_days]} days"
803
-
804
- keys = ["Version", "Uptime", "Connected Clients", "Memory Usage", "Peak Memory"]
805
- values = [
806
- redis_info[:version].to_s,
807
- uptime_value,
808
- redis_info[:connected_clients].to_s,
809
- redis_info[:used_memory].to_s,
810
- redis_info[:peak_memory].to_s
811
- ]
812
-
813
- # Format keys and values with spacing
814
- keys_line = keys.map { |k| k.ljust(18) }.join(" ")
815
- values_line = values.map { |v| v.ljust(18) }.join(" ")
816
-
817
- frame.render_widget(
818
- @tui.paragraph(
819
- text: [keys_line, values_line],
820
- block: @tui.block(title: "Redis Information", borders: [:all])
821
- ),
822
- area
823
- )
297
+ def current_tab
298
+ @current ||= @all.first
824
299
  end
825
300
 
826
- def render_queues(frame, area)
827
- header = ["☑️", "Queue", "Size", "Latency"]
828
- header << "Paused?" if Sidekiq.pro?
829
-
830
- chunks = @tui.layout_split(
831
- area,
832
- direction: :vertical,
833
- constraints: [
834
- @tui.constraint_length(4), # Stats
835
- @tui.constraint_fill(1) # Table
836
- ]
837
- )
838
-
839
- render_stats_section(frame, chunks[0])
840
- render_table(frame, chunks[1]) do
841
- {
842
- title: "Queues",
843
- header:,
844
- widths: header.map.with_index { |_, idx|
845
- @tui.constraint_length((idx == 1) ? 60 : 10)
846
- },
847
- rows: @data[:queues].map.with_index { |cells, idx|
848
- @tui.table_row(
849
- cells:,
850
- style: idx.even? ? nil : @tui.style(bg: :dark_gray)
851
- )
852
- }
853
- }
854
- end
301
+ # Navigate tabs to the left or right.
302
+ # @param direction [Symbol] :left or :right
303
+ def navigate(direction)
304
+ index_change = (direction == :right) ? 1 : -1
305
+ @current = @all[(@all.index(current_tab) + index_change) % @all.size]
306
+ @current.reset_data
855
307
  end
856
308
 
857
- def render_metrics(frame, area)
858
- chunks = @tui.layout_split(
859
- area,
860
- direction: :vertical,
861
- constraints: [
862
- @tui.constraint_length(4), # Stats
863
- @tui.constraint_fill(1) # Chart
864
- # TOOD Table
865
- ]
866
- )
867
-
868
- render_stats_section(frame, chunks[0])
869
- render_metrics_chart(frame, chunks[1])
309
+ public def t(msg, options = nil)
310
+ string = @strings[msg] || msg
311
+ if options.nil?
312
+ string
313
+ else
314
+ string % options
315
+ end
870
316
  end
871
317
 
872
- COLORS = %i[blue cyan yellow red green white gray]
873
-
874
- # Run to generate metrics data:
875
- # cd myapp && bundle install
876
- # bundle exec rake seed_jobs
877
- # bundle exec sidekiq
878
- def render_metrics_chart(frame, area)
879
- y_max = 5
880
- csize = COLORS.size
881
- q = @data[:metrics]
882
- job_results = q.job_results.sort_by { |(kls, jr)| jr.totals["s"] }.reverse.first(COLORS.size)
883
- # visible_kls = job_results.first(5).map(&:first)
884
- # chart_data = {
885
- # series: job_results.map { |(kls, jr)| [kls, jr.dig("series", "s")] }.to_h,
886
- # marks: query_result.marks.map { |m| [m.bucket, m.label] },
887
- # starts_at: query_result.starts_at.iso8601,
888
- # ends_at: query_result.ends_at.iso8601,
889
- # visibleKls: visible_kls,
890
- # yLabel: 'TotalExecutionTime',
891
- # units: 'seconds',
892
- # markLabel: '*',
893
- # }
894
-
895
- datasets = job_results.map.with_index do |(kls, data), idx|
896
- # log kls, data, idx
897
- hrdata = data.dig("series", "s")
898
- tm = Time.now
899
- tmi = tm.to_i
900
- tm = Time.at(tmi - (tmi % 60)).utc
901
- data = Array.new(60) { |idx| idx }.map do |bucket_idx|
902
- jumpback = bucket_idx * 60
903
- value = hrdata[(tm - jumpback).iso8601] || 0
904
- y_max = value if value > y_max
905
- # we have 60 data points, newest data should be
906
- # at highest indexes so we have to rejigger the index
907
- # here
908
- [59 - bucket_idx, value]
318
+ def load_strings(lang)
319
+ {}.tap do |all|
320
+ find_locale_files(lang).each do |file|
321
+ strs = YAML.safe_load_file(file)
322
+ all.merge! strs[lang]
909
323
  end
910
- # log data
911
-
912
- log(data)
913
- @tui.dataset(name: kls,
914
- data: data,
915
- style: @tui.style(fg: COLORS[idx % csize]),
916
- marker: :dot,
917
- graph_type: :line)
918
- end
919
-
920
- num_labels = 5
921
- y_labels = (0...num_labels).map do |i|
922
- value = ((y_max * i) / (num_labels - 1)).round
923
- value.to_s
924
324
  end
925
- xlabels = [
926
- q.starts_at.iso8601[11..15],
927
- q.ends_at.iso8601[11..15]
928
- ]
929
-
930
- # beacon_pulse = (Time.now.to_i % 2 == 0) ? "●" : " "
931
-
932
- chart = @tui.chart(
933
- datasets: datasets,
934
- x_axis: @tui.axis(
935
- bounds: [0.0, 60.0],
936
- labels: xlabels,
937
- style: @tui.style(fg: :white)
938
- ),
939
- y_axis: @tui.axis(
940
- bounds: [0.0, y_max.to_f],
941
- labels: y_labels,
942
- style: @tui.style(fg: :white)
943
- ),
944
- block: @tui.block(
945
- title: "Metrics",
946
- borders: [:all]
947
- )
948
- )
949
-
950
- frame.render_widget(chart, area)
951
325
  end
952
326
 
953
- def render_error(frame, area, err)
954
- log(err.message, err.backtrace)
955
- header = [@tui.text_line(
956
- spans: [@tui.text_span(content: err.message, style: @tui.style(modifiers: [:bold]))],
957
- alignment: :center
958
- )]
959
- lines = Array(err.backtrace).map { |line| @tui.text_line(spans: [@tui.text_span(content: line)]) }
960
-
961
- frame.render_widget(
962
- @tui.paragraph(
963
- text: header + lines,
964
- alignment: :left,
965
- block: @tui.block(title: "Error", borders: [:all], border_style: @tui.style(fg: :red))
966
- ),
967
- area
968
- )
327
+ def locale_files
328
+ @@locale_files ||= LOCALE_DIRECTORIES.flat_map { |path|
329
+ Dir["#{path}/*.yml"]
330
+ }
969
331
  end
970
332
 
971
- # TODO Implement I18n delimiter
972
- def number_with_delimiter(number, options = {})
973
- precision = options[:precision] || 0
974
- number.round(precision)
333
+ def available_locales
334
+ @@available_locales ||= Set.new(locale_files.map { |path| File.basename(path, ".yml") })
975
335
  end
976
336
 
977
- def format_memory(rss_kb)
978
- return "0" if rss_kb.nil? || rss_kb == 0
337
+ def find_locale_files(lang)
338
+ locale_files.select { |file| file =~ /\/#{lang}\.yml$/ }
339
+ end
979
340
 
980
- if rss_kb < 100_000
981
- "#{number_with_delimiter(rss_kb)} KB"
982
- elsif rss_kb < 10_000_000
983
- "#{number_with_delimiter((rss_kb / 1024.0).to_i)} MB"
984
- else
985
- "#{number_with_delimiter(rss_kb / (1024.0 * 1024.0), precision: 1)} GB"
341
+ def load_locale
342
+ require "yaml"
343
+ lang = @lang.split(".").first # "en_US"
344
+ while lang.size > 0
345
+ hash = load_strings(lang)
346
+ if hash.size > 0
347
+ # found a working language dataset
348
+ @lang = lang
349
+ @strings = hash
350
+ Sidekiq.logger.debug { "using the #{lang} locale" }
351
+ break
352
+ end
353
+ # Try "en_US", "en_U", "en_", "en"
354
+ # It's ugly and bruteforce but it works
355
+ lang = lang[..-2]
986
356
  end
987
357
  end
988
358
 
989
- def render_table(frame, area)
990
- page = @data.dig(:table, :current_page) || 1
991
- rows = @data.dig(:table, :rows) || []
992
- total = @data.dig(:table, :total) || 0
993
- footer = ["", "Page: #{page}", "Count: #{rows.size}", "Total: #{total}"]
994
- footer << "Selected: #{@data[:selected].size}" unless @data[:selected].empty?
359
+ def track_fps
360
+ # We hold two fps buckets: one for current second, one for previous second
361
+ idx = Time.now.to_i % 2
362
+ @fps[idx] += 1
363
+ yield
364
+ end
995
365
 
996
- if @data[:filter]
997
- @filter_style = @tui.style(fg: :white, bg: :dark_gray)
998
- spans = [
999
- @tui.text_span(content: "Filter: ", style: @filter_style),
1000
- @tui.text_span(content: @data[:filter], style: @filter_style)
1001
- ]
1002
- spans << @tui.text_span(content: "_", style: @tui.style(fg: :white, bg: :dark_gray, modifiers: [:slow_blink])) if @data[:filtering]
1003
- footer << @tui.text_line(spans: spans)
366
+ def previous_fps
367
+ curidx = Time.now.to_i % 2
368
+ prev = (curidx == 1) ? 0 : 1
369
+ if (val = @fps[prev]) != 0
370
+ @previous_fps = val
371
+ @fps[prev] = 0
1004
372
  end
373
+ @previous_fps
374
+ end
1005
375
 
1006
- defaults = {
1007
- title: "TableName",
1008
- highlight_symbol: "➡️",
1009
- selected_row: @selected_row_index,
1010
- row_highlight_style: @tui.style(fg: :white, bg: :blue),
1011
- footer: footer
1012
- }
1013
- hash = defaults.merge(yield)
1014
- hash[:block] ||= @tui.block(title: hash.delete(:title), borders: :all)
1015
- table = @tui.table(**hash)
1016
- frame.render_widget(table, area)
376
+ def debugging?
377
+ !!ENV["DEBUG"]
1017
378
  end
1018
379
  end
1019
380
  end