sktop 0.1.5 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5a3ed9a8721d477335f08d009c76cd65793b96ab0c0a045466ebdfa05a5e7f12
4
- data.tar.gz: cba94dc737356f0b9295cc04c00155dd2cc7eb60372dec1774b60489ae4c476d
3
+ metadata.gz: d1e0891fc2a69763f5784c7cc96fc69103d14362aa67a08b82b6c13aa578985b
4
+ data.tar.gz: fea7b7284c37d666516d42c2bcc97e0f6b3db4cbb53628e2e3b35c6521c44845
5
5
  SHA512:
6
- metadata.gz: e26bc9fe20fdb3d540c417802ca15db53b5263f52dcab6f696afb1b4cf6d52134e8dafe6d09abf8378e694fdb5c0307c61b7c84a0e523aee12f9db5b2cb00bd1
7
- data.tar.gz: 93eac6acd09dda870a90311fbd606a54ee7ad072919bba959951ecec91e31eb75d01e4a851aec1f111f9e0f193328c0f02e3adad914a15afdd17c67c02ec6ea5
6
+ metadata.gz: 59b4378e2dae18d69bcbd9c3a6492ff0bcad4d830ad9d0c893fa0b9f70f6845d87b6652dcc850d7a02efaea10c00343203d8b495c8eded6318a9b6c648e1fb5e
7
+ data.tar.gz: eb54c39df9aa1ec70bcb719b11a7ab0aac88970586345793e890b67dda9f753f207e8f7e5ea0923153118b66c59952f60ff46309e28da281e2bc4f87cbd48c4a
data/lib/sktop/cli.rb CHANGED
@@ -7,7 +7,18 @@ require "io/console"
7
7
  require "io/wait"
8
8
 
9
9
  module Sktop
10
+ # Command-line interface for the Sktop Sidekiq monitor.
11
+ # Handles argument parsing, Redis configuration, and the main event loop.
12
+ #
13
+ # @example Basic usage
14
+ # Sktop::CLI.new.run
15
+ #
16
+ # @example With custom arguments
17
+ # Sktop::CLI.new(["-r", "redis://myhost:6379/1", "-q"]).run
10
18
  class CLI
19
+ # Create a new CLI instance.
20
+ #
21
+ # @param args [Array<String>] command-line arguments (defaults to ARGV)
11
22
  def initialize(args = ARGV)
12
23
  @args = args
13
24
  @options = {
@@ -20,6 +31,11 @@ module Sktop
20
31
  @running = true
21
32
  end
22
33
 
34
+ # Run the CLI application.
35
+ # Parses options, configures Redis, and starts the TUI.
36
+ #
37
+ # @return [void]
38
+ # @raise [SystemExit] on connection errors or interrupts
23
39
  def run
24
40
  parse_options!
25
41
  configure_redis
@@ -39,6 +55,10 @@ module Sktop
39
55
  exit 1
40
56
  end
41
57
 
58
+ # Gracefully shutdown the application.
59
+ # Restores terminal state and shows the cursor.
60
+ #
61
+ # @return [void]
42
62
  def shutdown
43
63
  @running = false
44
64
  print "\e[?25h" # Show cursor
@@ -48,6 +68,9 @@ module Sktop
48
68
 
49
69
  private
50
70
 
71
+ # Parse command-line options and populate @options hash.
72
+ # @return [void]
73
+ # @api private
51
74
  def parse_options!
52
75
  parser = OptionParser.new do |opts|
53
76
  opts.banner = "Usage: sktop [options]"
@@ -117,6 +140,9 @@ module Sktop
117
140
  parser.parse!(@args)
118
141
  end
119
142
 
143
+ # Configure Sidekiq's Redis connection from CLI options.
144
+ # @return [void]
145
+ # @api private
120
146
  def configure_redis
121
147
  Sidekiq.configure_client do |config|
122
148
  if @options[:namespace]
@@ -130,6 +156,10 @@ module Sktop
130
156
  end
131
157
  end
132
158
 
159
+ # Start the main TUI loop with background data fetching.
160
+ # Handles both one-shot mode and interactive auto-refresh mode.
161
+ # @return [void]
162
+ # @api private
133
163
  def start_watcher
134
164
  collector = StatsCollector.new
135
165
  @display = Display.new
@@ -195,7 +225,17 @@ module Sktop
195
225
  scheduled_jobs: collector.scheduled_jobs(limit: 500),
196
226
  dead_jobs: collector.dead_jobs(limit: 500)
197
227
  }
228
+
229
+ # If viewing queue jobs, refresh that data too
230
+ if @display.current_view == :queue_jobs && @display.selected_queue
231
+ snapshot[:queue_jobs] = collector.queue_jobs(@display.selected_queue, limit: 500)
232
+ end
233
+
198
234
  @data_mutex.synchronize do
235
+ # Preserve queue_jobs if not refreshed above but still in that view
236
+ if @cached_data && @cached_data[:queue_jobs] && !snapshot[:queue_jobs]
237
+ snapshot[:queue_jobs] = @cached_data[:queue_jobs]
238
+ end
199
239
  @cached_data = snapshot
200
240
  @data_version += 1
201
241
  end
@@ -257,6 +297,10 @@ module Sktop
257
297
  end
258
298
  end
259
299
 
300
+ # Render the display from the cached data snapshot.
301
+ # Shows loading screen if no data is available yet.
302
+ # @return [void]
303
+ # @api private
260
304
  def render_cached_data
261
305
  data = @data_mutex.synchronize { @cached_data }
262
306
  if data
@@ -266,6 +310,13 @@ module Sktop
266
310
  end
267
311
  end
268
312
 
313
+ # Handle a keyboard input event.
314
+ # Routes to appropriate view or action handler.
315
+ #
316
+ # @param key [String] the key character pressed
317
+ # @param stdin [IO] the stdin IO object for reading escape sequences
318
+ # @return [void]
319
+ # @api private
269
320
  def handle_keypress(key, stdin)
270
321
  case key
271
322
  when 'q', 'Q'
@@ -282,6 +333,8 @@ module Sktop
282
333
  @display.current_view = :dead
283
334
  when 'm', 'M'
284
335
  @display.current_view = :main
336
+ when "\r", "\n" # Enter key
337
+ handle_enter_action
285
338
  when "\x12" # Ctrl+R - retry job
286
339
  handle_retry_action
287
340
  when "\x18" # Ctrl+X - delete job
@@ -299,8 +352,10 @@ module Sktop
299
352
  @display.select_up
300
353
  when "[B" # Down arrow
301
354
  @display.select_down
302
- when "[C" # Right arrow (unused for now)
303
- when "[D" # Left arrow (unused for now)
355
+ when "[C" # Right arrow - next view
356
+ @display.next_view
357
+ when "[D" # Left arrow - previous view
358
+ @display.previous_view
304
359
  when "[5~" # Page Up
305
360
  @display.page_up
306
361
  when "[6~" # Page Down
@@ -310,18 +365,22 @@ module Sktop
310
365
  when "x", "X" # Alt+X - Delete All
311
366
  handle_delete_all_action
312
367
  else
313
- # Just Escape key - go to main
314
- @display.current_view = :main
368
+ # Just Escape key - go back or to main
369
+ handle_escape_action
315
370
  end
316
371
  else
317
- # Just Escape key - go to main
318
- @display.current_view = :main
372
+ # Just Escape key - go back or to main
373
+ handle_escape_action
319
374
  end
320
375
  when "\u0003" # Ctrl+C
321
376
  raise Interrupt
322
377
  end
323
378
  end
324
379
 
380
+ # Handle Ctrl+R to retry the selected job.
381
+ # Only works in retries and dead views.
382
+ # @return [void]
383
+ # @api private
325
384
  def handle_retry_action
326
385
  return unless [:retries, :dead].include?(@display.current_view)
327
386
 
@@ -361,7 +420,17 @@ module Sktop
361
420
  end
362
421
  end
363
422
 
423
+ # Handle Ctrl+X to delete the selected job.
424
+ # Works in retries, dead, and queue_jobs views.
425
+ # @return [void]
426
+ # @api private
364
427
  def handle_delete_action
428
+ # Handle queue_jobs view separately
429
+ if @display.current_view == :queue_jobs
430
+ handle_delete_queue_job_action
431
+ return
432
+ end
433
+
365
434
  return unless [:retries, :dead].include?(@display.current_view)
366
435
 
367
436
  data = @data_mutex.synchronize { @cached_data }
@@ -400,6 +469,10 @@ module Sktop
400
469
  end
401
470
  end
402
471
 
472
+ # Handle Alt+R to retry all jobs in the current view.
473
+ # Only works in retries and dead views.
474
+ # @return [void]
475
+ # @api private
403
476
  def handle_retry_all_action
404
477
  return unless [:retries, :dead].include?(@display.current_view)
405
478
 
@@ -413,6 +486,10 @@ module Sktop
413
486
  end
414
487
  end
415
488
 
489
+ # Handle Alt+X to delete all jobs in the current view.
490
+ # Only works in retries and dead views.
491
+ # @return [void]
492
+ # @api private
416
493
  def handle_delete_all_action
417
494
  return unless [:retries, :dead].include?(@display.current_view)
418
495
 
@@ -426,6 +503,10 @@ module Sktop
426
503
  end
427
504
  end
428
505
 
506
+ # Handle Ctrl+Q to quiet the selected Sidekiq process.
507
+ # Only works in processes view.
508
+ # @return [void]
509
+ # @api private
429
510
  def handle_quiet_process_action
430
511
  return unless @display.current_view == :processes
431
512
 
@@ -463,6 +544,10 @@ module Sktop
463
544
  end
464
545
  end
465
546
 
547
+ # Handle Ctrl+K to stop/kill the selected Sidekiq process.
548
+ # Only works in processes view.
549
+ # @return [void]
550
+ # @api private
466
551
  def handle_stop_process_action
467
552
  return unless @display.current_view == :processes
468
553
 
@@ -500,5 +585,116 @@ module Sktop
500
585
  end
501
586
  end
502
587
 
588
+ # Handle Enter key to view jobs in the selected queue.
589
+ # Only works in queues view.
590
+ # @return [void]
591
+ # @api private
592
+ def handle_enter_action
593
+ return unless @display.current_view == :queues
594
+
595
+ data = @data_mutex.synchronize { @cached_data }
596
+ unless data
597
+ @display.set_status("No data available")
598
+ return
599
+ end
600
+
601
+ queues = data[:queues]
602
+ selected_idx = @display.selected_index
603
+
604
+ if queues.empty?
605
+ @display.set_status("No queues")
606
+ return
607
+ end
608
+
609
+ if selected_idx >= queues.length
610
+ @display.set_status("Invalid selection")
611
+ return
612
+ end
613
+
614
+ queue = queues[selected_idx]
615
+ queue_name = queue[:name]
616
+
617
+ # Fetch jobs from the queue
618
+ begin
619
+ collector = StatsCollector.new
620
+ jobs = collector.queue_jobs(queue_name, limit: 500)
621
+
622
+ # Update cached data with queue jobs
623
+ @data_mutex.synchronize do
624
+ @cached_data[:queue_jobs] = jobs
625
+ end
626
+
627
+ @display.selected_queue = queue_name
628
+ @display.current_view = :queue_jobs
629
+ @display.set_status("Loaded #{jobs.length} jobs from #{queue_name}")
630
+ rescue => e
631
+ @display.set_status("Error loading queue: #{e.message}")
632
+ end
633
+ end
634
+
635
+ # Handle Escape key to go back or return to main view.
636
+ # From queue_jobs returns to queues, otherwise returns to main.
637
+ # @return [void]
638
+ # @api private
639
+ def handle_escape_action
640
+ if @display.current_view == :queue_jobs
641
+ # Go back to queues view
642
+ @display.current_view = :queues
643
+ @display.selected_queue = nil
644
+ else
645
+ # Go to main view
646
+ @display.current_view = :main
647
+ end
648
+ end
649
+
650
+ # Handle Ctrl+X to delete a job from the current queue.
651
+ # Only works in queue_jobs view.
652
+ # @return [void]
653
+ # @api private
654
+ def handle_delete_queue_job_action
655
+ return unless @display.current_view == :queue_jobs
656
+
657
+ data = @data_mutex.synchronize { @cached_data }
658
+ unless data
659
+ @display.set_status("No data available")
660
+ return
661
+ end
662
+
663
+ jobs = data[:queue_jobs] || []
664
+ selected_idx = @display.selected_index
665
+ queue_name = @display.selected_queue
666
+
667
+ if jobs.empty?
668
+ @display.set_status("No jobs to delete")
669
+ return
670
+ end
671
+
672
+ if selected_idx >= jobs.length
673
+ @display.set_status("Invalid selection")
674
+ return
675
+ end
676
+
677
+ job = jobs[selected_idx]
678
+ unless job[:jid]
679
+ @display.set_status("Job has no JID")
680
+ return
681
+ end
682
+
683
+ begin
684
+ Sktop::JobActions.delete_queue_job(queue_name, job[:jid])
685
+ @display.set_status("Deleted #{job[:class]}")
686
+
687
+ # Refresh the queue jobs
688
+ collector = StatsCollector.new
689
+ new_jobs = collector.queue_jobs(queue_name, limit: 500)
690
+ @data_mutex.synchronize do
691
+ @cached_data[:queue_jobs] = new_jobs
692
+ end
693
+ @rendered_version = -1
694
+ rescue => e
695
+ @display.set_status("Error: #{e.message}")
696
+ end
697
+ end
698
+
503
699
  end
504
700
  end
data/lib/sktop/display.rb CHANGED
@@ -1,9 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sktop
4
+ # Handles all terminal display rendering for the Sktop TUI.
5
+ # Manages views, scrolling, selection, and ANSI-colored output.
6
+ #
7
+ # @example Basic usage
8
+ # display = Sktop::Display.new
9
+ # display.current_view = :queues
10
+ # display.render(collector)
11
+ #
12
+ # @example With cached data for auto-refresh
13
+ # display = Sktop::Display.new
14
+ # display.render_refresh_from_cache(data_hash)
4
15
  class Display
16
+ # @return [Symbol] the current view (:main, :queues, :processes, :workers, :retries, :scheduled, :dead, :queue_jobs)
17
+ # @return [Symbol] the connection status (:connecting, :connected, :updating, :error)
18
+ # @return [Time, nil] the timestamp of the last successful data update
5
19
  attr_accessor :current_view, :connection_status, :last_update
6
20
 
21
+ # Create a new Display instance with default settings.
7
22
  def initialize
8
23
  @pastel = Pastel.new
9
24
  @cursor = TTY::Cursor
@@ -15,16 +30,26 @@ module Sktop
15
30
  @status_time = nil
16
31
  @connection_status = :connecting # :connecting, :connected, :updating, :error
17
32
  @last_update = nil
33
+ @selected_queue = nil # Track which queue is being viewed in queue_jobs view
18
34
  end
19
35
 
36
+ # @return [String, nil] the name of the currently selected queue (for queue_jobs view)
37
+ attr_accessor :selected_queue
38
+
39
+ # Scroll the current view up by one line.
40
+ # @return [Integer] the new scroll offset
20
41
  def scroll_up
21
42
  @scroll_offsets[@current_view] = [@scroll_offsets[@current_view] - 1, 0].max
22
43
  end
23
44
 
45
+ # Scroll the current view down by one line.
46
+ # @return [Integer] the new scroll offset
24
47
  def scroll_down
25
48
  @scroll_offsets[@current_view] += 1
26
49
  end
27
50
 
51
+ # Move selection up in selectable views, or scroll up in non-selectable views.
52
+ # @return [Integer] the new selected index or scroll offset
28
53
  def select_up
29
54
  if selectable_view?
30
55
  @selected_index[@current_view] = [@selected_index[@current_view] - 1, 0].max
@@ -33,6 +58,8 @@ module Sktop
33
58
  end
34
59
  end
35
60
 
61
+ # Move selection down in selectable views, or scroll down in non-selectable views.
62
+ # @return [Integer] the new selected index or scroll offset
36
63
  def select_down
37
64
  if selectable_view?
38
65
  @selected_index[@current_view] += 1
@@ -41,10 +68,15 @@ module Sktop
41
68
  end
42
69
  end
43
70
 
71
+ # Check if the current view supports item selection.
72
+ # @return [Boolean] true if the view supports selection
44
73
  def selectable_view?
45
- [:processes, :retries, :dead].include?(@current_view)
74
+ [:queues, :queue_jobs, :processes, :retries, :dead].include?(@current_view)
46
75
  end
47
76
 
77
+ # Move up by one page in the current view.
78
+ # @param page_size [Integer, nil] custom page size (defaults to terminal height - 8)
79
+ # @return [Integer] the new selected index or scroll offset
48
80
  def page_up(page_size = nil)
49
81
  page_size ||= default_page_size
50
82
  if selectable_view?
@@ -54,6 +86,9 @@ module Sktop
54
86
  end
55
87
  end
56
88
 
89
+ # Move down by one page in the current view.
90
+ # @param page_size [Integer, nil] custom page size (defaults to terminal height - 8)
91
+ # @return [Integer] the new selected index or scroll offset
57
92
  def page_down(page_size = nil)
58
93
  page_size ||= default_page_size
59
94
  if selectable_view?
@@ -63,39 +98,77 @@ module Sktop
63
98
  end
64
99
  end
65
100
 
101
+ # Calculate the default page size based on terminal height.
102
+ # @return [Integer] the page size (minimum 5)
66
103
  def default_page_size
67
104
  # Use terminal height minus header/footer overhead as page size
68
105
  [terminal_height - 8, 5].max
69
106
  end
70
107
 
108
+ # Get the currently selected index for the current view.
109
+ # @return [Integer] the selected index (0-based)
71
110
  def selected_index
72
111
  @selected_index[@current_view]
73
112
  end
74
113
 
114
+ # Set a temporary status message to display.
115
+ # The message will be shown for approximately 3 seconds.
116
+ # @param message [String] the status message to display
117
+ # @return [void]
75
118
  def set_status(message)
76
119
  @status_message = message
77
120
  @status_time = Time.now
78
121
  end
79
122
 
123
+ # Reset the scroll offset for the current view to zero.
124
+ # @return [Integer] 0
80
125
  def reset_scroll
81
126
  @scroll_offsets[@current_view] = 0
82
127
  end
83
128
 
129
+ # Set the current view, preserving scroll position.
130
+ # @param view [Symbol] the view to switch to
131
+ # @return [Symbol] the new current view
84
132
  def current_view=(view)
85
133
  @current_view = view
86
134
  # Don't reset scroll when switching views - preserve position
87
135
  end
88
136
 
137
+ # Ordered list of views for cycling with arrow keys.
138
+ # @return [Array<Symbol>] the view order
139
+ VIEW_ORDER = [:main, :queues, :processes, :workers, :retries, :scheduled, :dead].freeze
140
+
141
+ # Switch to the next view in the cycle.
142
+ # @return [Symbol] the new current view
143
+ def next_view
144
+ current_idx = VIEW_ORDER.index(@current_view) || 0
145
+ @current_view = VIEW_ORDER[(current_idx + 1) % VIEW_ORDER.length]
146
+ end
147
+
148
+ # Switch to the previous view in the cycle.
149
+ # @return [Symbol] the new current view
150
+ def previous_view
151
+ current_idx = VIEW_ORDER.index(@current_view) || 0
152
+ @current_view = VIEW_ORDER[(current_idx - 1) % VIEW_ORDER.length]
153
+ end
154
+
155
+ # Reset cursor to top-left and hide it.
156
+ # @return [void]
89
157
  def reset_cursor
90
158
  print @cursor.move_to(0, 0)
91
159
  print @cursor.hide
92
160
  $stdout.flush
93
161
  end
94
162
 
163
+ # Show the cursor (call on exit).
164
+ # @return [void]
95
165
  def show_cursor
96
166
  print @cursor.show
97
167
  end
98
168
 
169
+ # Update the cached terminal size from TTY::Screen.
170
+ # Call this before entering raw mode or when terminal resizes.
171
+ # @return [Array<Integer>, nil] [height, width] or nil if unchanged
99
172
  def update_terminal_size
100
173
  # Force refresh of terminal size using TTY::Screen
101
174
  height = TTY::Screen.height
@@ -105,16 +178,26 @@ module Sktop
105
178
  end
106
179
  end
107
180
 
181
+ # Render the current view as a string (for one-shot mode).
182
+ # @param collector [StatsCollector, CachedData] the data source
183
+ # @return [String] the rendered output
108
184
  def render(collector)
109
185
  content_parts = build_output(collector)
110
186
  content_parts.reject { |p| p == :footer }.map(&:to_s).join("\n")
111
187
  end
112
188
 
189
+ # Render the current view directly to the terminal with screen clearing.
190
+ # @param collector [StatsCollector] the data source
191
+ # @return [void]
113
192
  def render_refresh(collector)
114
193
  content = build_output(collector)
115
194
  render_with_overwrite(content)
116
195
  end
117
196
 
197
+ # Render the current view from cached data hash.
198
+ # Updates connection status and last update time.
199
+ # @param data [Hash] cached data hash with :overview, :queues, :processes, etc.
200
+ # @return [void]
118
201
  def render_refresh_from_cache(data)
119
202
  @connection_status = :connected
120
203
  @last_update = Time.now
@@ -123,6 +206,8 @@ module Sktop
123
206
  render_with_overwrite(content)
124
207
  end
125
208
 
209
+ # Render a loading screen while waiting for initial data.
210
+ # @return [void]
126
211
  def render_loading
127
212
  lines = []
128
213
  lines << header_bar
@@ -134,39 +219,61 @@ module Sktop
134
219
  render_with_overwrite(lines)
135
220
  end
136
221
 
137
- # Simple wrapper to make cached hash act like collector
222
+ # Wrapper class to make a cached data hash act like a StatsCollector.
223
+ # Provides the same interface methods that Display expects.
224
+ #
225
+ # @example
226
+ # cached = CachedData.new({ overview: {...}, queues: [...] })
227
+ # cached.overview # => {...}
138
228
  class CachedData
229
+ # Create a new CachedData wrapper.
230
+ # @param data [Hash] the raw data hash
139
231
  def initialize(data)
140
232
  @data = data
141
233
  end
142
234
 
235
+ # @return [Hash] overview statistics
143
236
  def overview
144
237
  @data[:overview]
145
238
  end
146
239
 
240
+ # @return [Array<Hash>] queue information
147
241
  def queues
148
242
  @data[:queues]
149
243
  end
150
244
 
245
+ # @return [Array<Hash>] process information
151
246
  def processes
152
247
  @data[:processes]
153
248
  end
154
249
 
250
+ # @return [Array<Hash>] worker information
155
251
  def workers
156
252
  @data[:workers]
157
253
  end
158
254
 
255
+ # @param limit [Integer] maximum jobs to return
256
+ # @return [Array<Hash>] retry job information
159
257
  def retry_jobs(limit: 50)
160
258
  @data[:retry_jobs]&.first(limit) || []
161
259
  end
162
260
 
261
+ # @param limit [Integer] maximum jobs to return
262
+ # @return [Array<Hash>] scheduled job information
163
263
  def scheduled_jobs(limit: 50)
164
264
  @data[:scheduled_jobs]&.first(limit) || []
165
265
  end
166
266
 
267
+ # @param limit [Integer] maximum jobs to return
268
+ # @return [Array<Hash>] dead job information
167
269
  def dead_jobs(limit: 50)
168
270
  @data[:dead_jobs]&.first(limit) || []
169
271
  end
272
+
273
+ # @return [Array<Hash>] cached queue jobs data
274
+ def queue_jobs_cache
275
+ @data[:queue_jobs] || []
276
+ end
170
277
  end
171
278
 
172
279
  private
@@ -175,6 +282,8 @@ module Sktop
175
282
  case @current_view
176
283
  when :queues
177
284
  build_queues_detail(collector)
285
+ when :queue_jobs
286
+ build_queue_jobs_detail(collector)
178
287
  when :processes
179
288
  build_processes_detail(collector)
180
289
  when :workers
@@ -243,9 +352,24 @@ module Sktop
243
352
  lines = []
244
353
  lines << header_bar
245
354
  lines << ""
246
- # Calculate available rows: height - header(1) - blank(1) - section(1) - table_header(1) - footer(1) = height - 5
247
- max_rows = terminal_height - 5
248
- queues_scrollable(collector.queues, max_rows).each_line(chomp: true) { |l| lines << l }
355
+ stats_meters(collector.overview, collector.processes).each_line(chomp: true) { |l| lines << l }
356
+ lines << ""
357
+ # Calculate available rows: height - header(1) - blank(1) - stats(6) - blank(1) - section(1) - table_header(1) - footer(1) = height - 12
358
+ max_rows = terminal_height - 12
359
+ queues_selectable(collector.queues, max_rows).each_line(chomp: true) { |l| lines << l }
360
+ lines << :footer
361
+ lines
362
+ end
363
+
364
+ def build_queue_jobs_detail(collector)
365
+ lines = []
366
+ lines << header_bar
367
+ lines << ""
368
+ stats_meters(collector.overview, collector.processes).each_line(chomp: true) { |l| lines << l }
369
+ lines << ""
370
+ max_rows = terminal_height - 12
371
+ jobs = collector.respond_to?(:queue_jobs_cache) ? collector.queue_jobs_cache : []
372
+ queue_jobs_selectable(jobs, max_rows).each_line(chomp: true) { |l| lines << l }
249
373
  lines << :footer
250
374
  lines
251
375
  end
@@ -254,7 +378,9 @@ module Sktop
254
378
  lines = []
255
379
  lines << header_bar
256
380
  lines << ""
257
- max_rows = terminal_height - 5
381
+ stats_meters(collector.overview, collector.processes).each_line(chomp: true) { |l| lines << l }
382
+ lines << ""
383
+ max_rows = terminal_height - 12
258
384
  processes_selectable(collector.processes, max_rows).each_line(chomp: true) { |l| lines << l }
259
385
  lines << :footer
260
386
  lines
@@ -264,7 +390,9 @@ module Sktop
264
390
  lines = []
265
391
  lines << header_bar
266
392
  lines << ""
267
- max_rows = terminal_height - 5
393
+ stats_meters(collector.overview, collector.processes).each_line(chomp: true) { |l| lines << l }
394
+ lines << ""
395
+ max_rows = terminal_height - 12
268
396
  workers_section(collector.workers, max_rows: max_rows, scrollable: true).each_line(chomp: true) { |l| lines << l }
269
397
  lines << :footer
270
398
  lines
@@ -274,7 +402,9 @@ module Sktop
274
402
  lines = []
275
403
  lines << header_bar
276
404
  lines << ""
277
- max_rows = terminal_height - 5
405
+ stats_meters(collector.overview, collector.processes).each_line(chomp: true) { |l| lines << l }
406
+ lines << ""
407
+ max_rows = terminal_height - 12
278
408
  retries_scrollable(collector.retry_jobs(limit: 500), max_rows).each_line(chomp: true) { |l| lines << l }
279
409
  lines << :footer
280
410
  lines
@@ -284,7 +414,9 @@ module Sktop
284
414
  lines = []
285
415
  lines << header_bar
286
416
  lines << ""
287
- max_rows = terminal_height - 5
417
+ stats_meters(collector.overview, collector.processes).each_line(chomp: true) { |l| lines << l }
418
+ lines << ""
419
+ max_rows = terminal_height - 12
288
420
  scheduled_scrollable(collector.scheduled_jobs(limit: 500), max_rows).each_line(chomp: true) { |l| lines << l }
289
421
  lines << :footer
290
422
  lines
@@ -294,7 +426,9 @@ module Sktop
294
426
  lines = []
295
427
  lines << header_bar
296
428
  lines << ""
297
- max_rows = terminal_height - 5
429
+ stats_meters(collector.overview, collector.processes).each_line(chomp: true) { |l| lines << l }
430
+ lines << ""
431
+ max_rows = terminal_height - 12
298
432
  dead_scrollable(collector.dead_jobs(limit: 500), max_rows).each_line(chomp: true) { |l| lines << l }
299
433
  lines << :footer
300
434
  lines
@@ -601,6 +735,133 @@ module Sktop
601
735
  lines.join("\n")
602
736
  end
603
737
 
738
+ # Selectable queues view - press Enter to view queue contents
739
+ def queues_selectable(queues, max_rows)
740
+ width = terminal_width
741
+ lines = []
742
+
743
+ scroll_offset = @scroll_offsets[@current_view]
744
+ data_rows = max_rows - 3 # Account for section bar, header, and status line
745
+ max_scroll = [queues.length - data_rows, 0].max
746
+ scroll_offset = [[scroll_offset, 0].max, max_scroll].min
747
+ @scroll_offsets[@current_view] = scroll_offset
748
+
749
+ # Clamp selected index
750
+ @selected_index[@current_view] = [[@selected_index[@current_view], 0].max, [queues.length - 1, 0].max].min
751
+
752
+ # Auto-scroll to keep selection visible
753
+ selected = @selected_index[@current_view]
754
+ if selected < scroll_offset
755
+ scroll_offset = selected
756
+ @scroll_offsets[@current_view] = scroll_offset
757
+ elsif selected >= scroll_offset + data_rows
758
+ scroll_offset = selected - data_rows + 1
759
+ @scroll_offsets[@current_view] = scroll_offset
760
+ end
761
+
762
+ scroll_indicator = queues.length > data_rows ? " [#{scroll_offset + 1}-#{[scroll_offset + data_rows, queues.length].min}/#{queues.length}]" : ""
763
+ lines << section_bar("Queues#{scroll_indicator} - ↑↓ select, Enter=view jobs, m=main")
764
+
765
+ if queues.empty?
766
+ lines << @pastel.dim(" No queues")
767
+ return lines.join("\n")
768
+ end
769
+
770
+ name_width = [40, width - 40].max
771
+ header = sprintf(" %-#{name_width}s %10s %10s %10s", "NAME", "SIZE", "LATENCY", "STATUS")
772
+ lines << format_table_header(header)
773
+
774
+ queues.drop(scroll_offset).first(data_rows).each_with_index do |queue, idx|
775
+ actual_idx = scroll_offset + idx
776
+ row = format_queue_row(queue, name_width)
777
+
778
+ if actual_idx == selected
779
+ lines << @pastel.black.on_white(row + " " * [width - visible_string_length(row), 0].max)
780
+ else
781
+ lines << row
782
+ end
783
+ end
784
+
785
+ remaining = queues.length - scroll_offset - data_rows
786
+ if remaining > 0
787
+ lines << @pastel.dim(" ↓ #{remaining} more")
788
+ end
789
+
790
+ # Status message
791
+ if @status_message && @status_time && (Time.now - @status_time) < 3
792
+ lines << @pastel.green(" #{@status_message}")
793
+ end
794
+
795
+ lines.join("\n")
796
+ end
797
+
798
+ # Selectable queue jobs view - press Ctrl+X to delete job
799
+ def queue_jobs_selectable(jobs, max_rows)
800
+ width = terminal_width
801
+ lines = []
802
+
803
+ scroll_offset = @scroll_offsets[@current_view]
804
+ data_rows = max_rows - 3 # Account for section bar, header, and status line
805
+ max_scroll = [jobs.length - data_rows, 0].max
806
+ scroll_offset = [[scroll_offset, 0].max, max_scroll].min
807
+ @scroll_offsets[@current_view] = scroll_offset
808
+
809
+ # Clamp selected index
810
+ @selected_index[@current_view] = [[@selected_index[@current_view], 0].max, [jobs.length - 1, 0].max].min
811
+
812
+ # Auto-scroll to keep selection visible
813
+ selected = @selected_index[@current_view]
814
+ if selected < scroll_offset
815
+ scroll_offset = selected
816
+ @scroll_offsets[@current_view] = scroll_offset
817
+ elsif selected >= scroll_offset + data_rows
818
+ scroll_offset = selected - data_rows + 1
819
+ @scroll_offsets[@current_view] = scroll_offset
820
+ end
821
+
822
+ queue_name = @selected_queue || "unknown"
823
+ scroll_indicator = jobs.length > data_rows ? " [#{scroll_offset + 1}-#{[scroll_offset + data_rows, jobs.length].min}/#{jobs.length}]" : ""
824
+ lines << section_bar("Queue: #{queue_name}#{scroll_indicator} - ↑↓ select, ^X=delete, Esc=back")
825
+
826
+ if jobs.empty?
827
+ lines << @pastel.dim(" No jobs in queue")
828
+ return lines.join("\n")
829
+ end
830
+
831
+ job_width = [35, (width - 50) / 2].max
832
+ args_width = [35, (width - 50) / 2].max
833
+
834
+ header = sprintf(" %-#{job_width}s %-20s %-#{args_width}s", "JOB", "ENQUEUED", "ARGS")
835
+ lines << format_table_header(header)
836
+
837
+ jobs.drop(scroll_offset).first(data_rows).each_with_index do |job, idx|
838
+ actual_idx = scroll_offset + idx
839
+ klass = truncate(job[:class].to_s, job_width)
840
+ enqueued = job[:enqueued_at]&.strftime("%Y-%m-%d %H:%M:%S") || "N/A"
841
+ args = truncate(job[:args].inspect, args_width)
842
+
843
+ row = sprintf(" %-#{job_width}s %-20s %-#{args_width}s", klass, enqueued, args)
844
+
845
+ if actual_idx == selected
846
+ lines << @pastel.black.on_white(row + " " * [width - visible_string_length(row), 0].max)
847
+ else
848
+ lines << row
849
+ end
850
+ end
851
+
852
+ remaining = jobs.length - scroll_offset - data_rows
853
+ if remaining > 0
854
+ lines << @pastel.dim(" ↓ #{remaining} more")
855
+ end
856
+
857
+ # Status message
858
+ if @status_message && @status_time && (Time.now - @status_time) < 3
859
+ lines << @pastel.green(" #{@status_message}")
860
+ end
861
+
862
+ lines.join("\n")
863
+ end
864
+
604
865
  def format_queue_row(queue, name_width)
605
866
  name = truncate(queue[:name], name_width)
606
867
  size = format_number(queue[:size])
@@ -1083,31 +1344,36 @@ module Sktop
1083
1344
  end
1084
1345
 
1085
1346
  def function_bar
1086
- items = if @current_view == :main
1087
- [
1088
- ["q", "Queues"],
1089
- ["p", "Procs"],
1090
- ["w", "Workers"],
1091
- ["r", "Retries"],
1092
- ["s", "Sched"],
1093
- ["d", "Dead"],
1094
- ["^C", "Quit"]
1095
- ]
1096
- else
1097
- [
1098
- ["m", "Main"],
1099
- ["q", "Queues"],
1100
- ["p", "Procs"],
1101
- ["w", "Workers"],
1102
- ["r", "Retries"],
1103
- ["s", "Sched"],
1104
- ["d", "Dead"],
1105
- ["^C", "Quit"]
1106
- ]
1107
- end
1347
+ # Map keys to views for highlighting
1348
+ view_keys = {
1349
+ "m" => :main,
1350
+ "q" => :queues,
1351
+ "p" => :processes,
1352
+ "w" => :workers,
1353
+ "r" => :retries,
1354
+ "s" => :scheduled,
1355
+ "d" => :dead
1356
+ }
1357
+
1358
+ items = [
1359
+ ["m", "Main"],
1360
+ ["q", "Queues"],
1361
+ ["p", "Procs"],
1362
+ ["w", "Workers"],
1363
+ ["r", "Retries"],
1364
+ ["s", "Sched"],
1365
+ ["d", "Dead"],
1366
+ ["^C", "Quit"]
1367
+ ]
1108
1368
 
1109
1369
  bar = items.map do |key, label|
1110
- @pastel.black.on_cyan.bold(key) + @pastel.white.on_blue(label)
1370
+ view = view_keys[key]
1371
+ if view && view == @current_view
1372
+ # Highlight current view with inverted colors
1373
+ @pastel.black.on_white.bold(key) + @pastel.black.on_green.bold(label)
1374
+ else
1375
+ @pastel.black.on_cyan.bold(key) + @pastel.white.on_blue(label)
1376
+ end
1111
1377
  end.join(" ")
1112
1378
 
1113
1379
  width = terminal_width
@@ -1,8 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sktop
4
+ # Provides methods for performing actions on Sidekiq jobs and processes.
5
+ # All methods are class methods and can be called directly on the module.
6
+ #
7
+ # @example Retry a failed job
8
+ # Sktop::JobActions.retry_job("abc123", :retry)
9
+ #
10
+ # @example Delete a dead job
11
+ # Sktop::JobActions.delete_job("abc123", :dead)
12
+ #
13
+ # @example Quiet a Sidekiq process
14
+ # Sktop::JobActions.quiet_process("myhost:12345:abc")
4
15
  module JobActions
5
16
  class << self
17
+ # Retry a specific job from the retry or dead queue.
18
+ #
19
+ # @param jid [String] the job ID to retry
20
+ # @param source [Symbol] the source queue (:retry or :dead)
21
+ # @return [Boolean] true if the job was successfully retried
22
+ # @raise [RuntimeError] if the job is not found
23
+ #
24
+ # @example Retry a job from the retry queue
25
+ # Sktop::JobActions.retry_job("abc123def456", :retry)
6
26
  def retry_job(jid, source)
7
27
  job = find_job(jid, source)
8
28
  raise "Job not found (JID: #{jid})" unless job
@@ -11,6 +31,12 @@ module Sktop
11
31
  true
12
32
  end
13
33
 
34
+ # Delete a specific job from the retry or dead queue.
35
+ #
36
+ # @param jid [String] the job ID to delete
37
+ # @param source [Symbol] the source queue (:retry or :dead)
38
+ # @return [Boolean] true if the job was successfully deleted
39
+ # @raise [RuntimeError] if the job is not found
14
40
  def delete_job(jid, source)
15
41
  job = find_job(jid, source)
16
42
  raise "Job not found (JID: #{jid})" unless job
@@ -19,6 +45,12 @@ module Sktop
19
45
  true
20
46
  end
21
47
 
48
+ # Kill a specific job, moving it to the dead queue.
49
+ #
50
+ # @param jid [String] the job ID to kill
51
+ # @param source [Symbol] the source queue (:retry or :scheduled)
52
+ # @return [Boolean] true if the job was successfully killed
53
+ # @raise [RuntimeError] if the job is not found
22
54
  def kill_job(jid, source)
23
55
  job = find_job(jid, source)
24
56
  raise "Job not found (JID: #{jid})" unless job
@@ -27,6 +59,10 @@ module Sktop
27
59
  true
28
60
  end
29
61
 
62
+ # Retry all jobs in a sorted set (retry or dead queue).
63
+ #
64
+ # @param source [Symbol] the source queue (:retry or :dead)
65
+ # @return [Integer] the number of jobs retried
30
66
  def retry_all(source)
31
67
  set = get_set(source)
32
68
  count = set.size
@@ -34,6 +70,10 @@ module Sktop
34
70
  count
35
71
  end
36
72
 
73
+ # Delete all jobs from a sorted set (retry or dead queue).
74
+ #
75
+ # @param source [Symbol] the source queue (:retry or :dead)
76
+ # @return [Integer] the number of jobs deleted
37
77
  def delete_all(source)
38
78
  set = get_set(source)
39
79
  count = set.size
@@ -41,6 +81,27 @@ module Sktop
41
81
  count
42
82
  end
43
83
 
84
+ # Delete a specific job from a named queue.
85
+ #
86
+ # @param queue_name [String] the name of the queue
87
+ # @param jid [String] the job ID to delete
88
+ # @return [Boolean] true if the job was successfully deleted
89
+ # @raise [RuntimeError] if the job is not found in the queue
90
+ def delete_queue_job(queue_name, jid)
91
+ queue = Sidekiq::Queue.new(queue_name)
92
+ job = queue.find { |j| j.jid == jid }
93
+ raise "Job not found in queue #{queue_name} (JID: #{jid})" unless job
94
+
95
+ job.delete
96
+ true
97
+ end
98
+
99
+ # Send the QUIET signal to a Sidekiq process.
100
+ # A quieted process stops fetching new jobs but finishes current work.
101
+ #
102
+ # @param identity [String] the process identity (e.g., "hostname:pid:tag")
103
+ # @return [Boolean] true if the signal was sent successfully
104
+ # @raise [RuntimeError] if the process is not found
44
105
  def quiet_process(identity)
45
106
  process = find_process(identity)
46
107
  raise "Process not found (identity: #{identity})" unless process
@@ -49,6 +110,12 @@ module Sktop
49
110
  true
50
111
  end
51
112
 
113
+ # Send the STOP signal to a Sidekiq process.
114
+ # This initiates a graceful shutdown of the process.
115
+ #
116
+ # @param identity [String] the process identity (e.g., "hostname:pid:tag")
117
+ # @return [Boolean] true if the signal was sent successfully
118
+ # @raise [RuntimeError] if the process is not found
52
119
  def stop_process(identity)
53
120
  process = find_process(identity)
54
121
  raise "Process not found (identity: #{identity})" unless process
@@ -59,6 +126,12 @@ module Sktop
59
126
 
60
127
  private
61
128
 
129
+ # Get the appropriate Sidekiq sorted set for a given source.
130
+ #
131
+ # @param source [Symbol] the source type (:retry, :dead, or :scheduled)
132
+ # @return [Sidekiq::RetrySet, Sidekiq::DeadSet, Sidekiq::ScheduledSet]
133
+ # @raise [RuntimeError] if the source is unknown
134
+ # @api private
62
135
  def get_set(source)
63
136
  case source
64
137
  when :retry
@@ -72,6 +145,12 @@ module Sktop
72
145
  end
73
146
  end
74
147
 
148
+ # Find a job by JID in a sorted set.
149
+ #
150
+ # @param jid [String] the job ID to find
151
+ # @param source [Symbol] the source type (:retry, :dead, or :scheduled)
152
+ # @return [Sidekiq::SortedEntry, nil] the job entry or nil if not found
153
+ # @api private
75
154
  def find_job(jid, source)
76
155
  set = get_set(source)
77
156
 
@@ -89,6 +168,11 @@ module Sktop
89
168
  nil
90
169
  end
91
170
 
171
+ # Find a Sidekiq process by its identity string.
172
+ #
173
+ # @param identity [String] the process identity
174
+ # @return [Sidekiq::Process, nil] the process or nil if not found
175
+ # @api private
92
176
  def find_process(identity)
93
177
  Sidekiq::ProcessSet.new.find { |p| p["identity"] == identity }
94
178
  end
@@ -1,16 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sktop
4
+ # Collects statistics and data from Sidekiq for display in the TUI.
5
+ # Wraps the Sidekiq API to provide a consistent interface for querying
6
+ # queues, processes, workers, and job data.
7
+ #
8
+ # @example Basic usage
9
+ # collector = Sktop::StatsCollector.new
10
+ # puts collector.overview[:processed]
11
+ # puts collector.queues.length
12
+ #
13
+ # @example Refreshing data
14
+ # collector = Sktop::StatsCollector.new
15
+ # loop do
16
+ # collector.refresh!
17
+ # display_stats(collector.overview)
18
+ # sleep 2
19
+ # end
4
20
  class StatsCollector
21
+ # Create a new StatsCollector instance.
22
+ # Initializes with fresh statistics from Sidekiq.
5
23
  def initialize
6
24
  @stats = Sidekiq::Stats.new
7
25
  end
8
26
 
27
+ # Refresh the cached statistics from Sidekiq.
28
+ # Call this method periodically to get updated data.
29
+ #
30
+ # @return [self] the collector instance for method chaining
9
31
  def refresh!
10
32
  @stats = Sidekiq::Stats.new
11
33
  self
12
34
  end
13
35
 
36
+ # Get an overview of Sidekiq statistics.
37
+ #
38
+ # @return [Hash] overview statistics
39
+ # @option return [Integer] :processed total jobs processed
40
+ # @option return [Integer] :failed total jobs failed
41
+ # @option return [Integer] :scheduled_size jobs in scheduled queue
42
+ # @option return [Integer] :retry_size jobs in retry queue
43
+ # @option return [Integer] :dead_size jobs in dead queue
44
+ # @option return [Integer] :enqueued total jobs across all queues
45
+ # @option return [Float] :default_queue_latency latency of default queue in seconds
14
46
  def overview
15
47
  {
16
48
  processed: @stats.processed,
@@ -23,6 +55,13 @@ module Sktop
23
55
  }
24
56
  end
25
57
 
58
+ # Get information about all Sidekiq queues.
59
+ #
60
+ # @return [Array<Hash>] array of queue information hashes
61
+ # @option return [String] :name queue name
62
+ # @option return [Integer] :size number of jobs in queue
63
+ # @option return [Float] :latency queue latency in seconds
64
+ # @option return [Boolean] :paused whether the queue is paused
26
65
  def queues
27
66
  Sidekiq::Queue.all.map do |queue|
28
67
  {
@@ -34,6 +73,21 @@ module Sktop
34
73
  end
35
74
  end
36
75
 
76
+ # Get information about all running Sidekiq processes.
77
+ #
78
+ # @return [Array<Hash>] array of process information hashes
79
+ # @option return [String] :identity unique process identifier
80
+ # @option return [String] :hostname the host running the process
81
+ # @option return [Integer] :pid process ID
82
+ # @option return [String, nil] :tag optional process tag
83
+ # @option return [Time] :started_at when the process started
84
+ # @option return [Integer] :concurrency number of worker threads
85
+ # @option return [Integer] :busy number of threads currently processing
86
+ # @option return [Array<String>] :queues queues this process listens to
87
+ # @option return [Array<String>] :labels process labels
88
+ # @option return [Boolean] :quiet whether the process is quieted
89
+ # @option return [Boolean] :stopping whether the process is stopping
90
+ # @option return [Integer, nil] :rss memory usage in KB
37
91
  def processes
38
92
  Sidekiq::ProcessSet.new.map do |process|
39
93
  # Use direct hash access for status flags - the method accessors
@@ -61,12 +115,30 @@ module Sktop
61
115
  end
62
116
  end
63
117
 
118
+ # Get information about all currently running workers.
119
+ #
120
+ # @return [Array<Hash>] array of worker information hashes
121
+ # @option return [String] :process_id the process identity
122
+ # @option return [String] :thread_id the thread identifier
123
+ # @option return [String] :queue the queue being processed
124
+ # @option return [String] :class the job class name
125
+ # @option return [Array] :args the job arguments
126
+ # @option return [Time] :run_at when the job started
127
+ # @option return [Float] :elapsed seconds since job started
64
128
  def workers
65
129
  Sidekiq::Workers.new.map do |process_id, thread_id, work|
66
130
  extract_worker_info(process_id, thread_id, work)
67
131
  end
68
132
  end
69
133
 
134
+ # Extract worker information from Sidekiq's Workers API.
135
+ # Handles both Sidekiq 6.x (hash-based) and 7.x (Work object) formats.
136
+ #
137
+ # @param process_id [String] the process identity
138
+ # @param thread_id [String] the thread identifier
139
+ # @param work [Hash, Sidekiq::Work] the work data (format depends on Sidekiq version)
140
+ # @return [Hash] normalized worker information
141
+ # @api private
70
142
  def extract_worker_info(process_id, thread_id, work)
71
143
  # Sidekiq 7+ returns Work objects, older versions return hashes
72
144
  if work.is_a?(Hash)
@@ -108,6 +180,38 @@ module Sktop
108
180
  end
109
181
  end
110
182
 
183
+ # Get jobs from a specific queue.
184
+ #
185
+ # @param queue_name [String] the name of the queue
186
+ # @param limit [Integer] maximum number of jobs to return (default: 100)
187
+ # @return [Array<Hash>] array of job information hashes
188
+ # @option return [String] :jid the job ID
189
+ # @option return [String] :class the job class name
190
+ # @option return [Array] :args the job arguments
191
+ # @option return [Time, nil] :enqueued_at when the job was enqueued
192
+ # @option return [Time, nil] :created_at when the job was created
193
+ def queue_jobs(queue_name, limit: 100)
194
+ queue = Sidekiq::Queue.new(queue_name)
195
+ queue.first(limit).map do |job|
196
+ {
197
+ jid: job.jid,
198
+ class: job.klass,
199
+ args: job.args,
200
+ enqueued_at: job.enqueued_at,
201
+ created_at: job.created_at
202
+ }
203
+ end
204
+ end
205
+
206
+ # Get jobs from the scheduled queue.
207
+ #
208
+ # @param limit [Integer] maximum number of jobs to return (default: 10)
209
+ # @return [Array<Hash>] array of scheduled job information hashes
210
+ # @option return [String] :class the job class name
211
+ # @option return [String] :queue the target queue
212
+ # @option return [Array] :args the job arguments
213
+ # @option return [Time] :scheduled_at when the job is scheduled to run
214
+ # @option return [Time, nil] :created_at when the job was created
111
215
  def scheduled_jobs(limit: 10)
112
216
  Sidekiq::ScheduledSet.new.first(limit).map do |job|
113
217
  {
@@ -120,6 +224,18 @@ module Sktop
120
224
  end
121
225
  end
122
226
 
227
+ # Get jobs from the retry queue.
228
+ #
229
+ # @param limit [Integer] maximum number of jobs to return (default: 10)
230
+ # @return [Array<Hash>] array of retry job information hashes
231
+ # @option return [String] :jid the job ID
232
+ # @option return [String] :class the job class name
233
+ # @option return [String] :queue the original queue
234
+ # @option return [Array] :args the job arguments
235
+ # @option return [Time, nil] :failed_at when the job failed
236
+ # @option return [Integer] :retry_count number of retry attempts
237
+ # @option return [String] :error_class the exception class name
238
+ # @option return [String] :error_message the exception message
123
239
  def retry_jobs(limit: 10)
124
240
  Sidekiq::RetrySet.new.first(limit).map do |job|
125
241
  {
@@ -135,6 +251,17 @@ module Sktop
135
251
  end
136
252
  end
137
253
 
254
+ # Get jobs from the dead (morgue) queue.
255
+ #
256
+ # @param limit [Integer] maximum number of jobs to return (default: 10)
257
+ # @return [Array<Hash>] array of dead job information hashes
258
+ # @option return [String] :jid the job ID
259
+ # @option return [String] :class the job class name
260
+ # @option return [String] :queue the original queue
261
+ # @option return [Array] :args the job arguments
262
+ # @option return [Time, nil] :failed_at when the job failed
263
+ # @option return [String] :error_class the exception class name
264
+ # @option return [String] :error_message the exception message
138
265
  def dead_jobs(limit: 10)
139
266
  Sidekiq::DeadSet.new.first(limit).map do |job|
140
267
  {
@@ -149,6 +276,12 @@ module Sktop
149
276
  end
150
277
  end
151
278
 
279
+ # Get historical statistics for processed and failed jobs.
280
+ #
281
+ # @param days [Integer] number of days of history to retrieve (default: 7)
282
+ # @return [Hash] history data
283
+ # @option return [Hash] :processed daily processed counts keyed by date
284
+ # @option return [Hash] :failed daily failed counts keyed by date
152
285
  def history(days: 7)
153
286
  stats_history = Sidekiq::Stats::History.new(days)
154
287
  {
data/lib/sktop/version.rb CHANGED
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sktop
4
- VERSION = "0.1.5"
4
+ # Current version of the Sktop gem.
5
+ # Follows semantic versioning (major.minor.patch).
6
+ # @return [String] the version string
7
+ VERSION = "0.2.0"
5
8
  end
data/lib/sktop.rb CHANGED
@@ -13,10 +13,31 @@ require_relative "sktop/job_actions"
13
13
  require_relative "sktop/display"
14
14
  require_relative "sktop/cli"
15
15
 
16
+ # Sktop is a terminal-based Sidekiq monitoring tool similar to htop.
17
+ # It provides real-time visibility into Sidekiq processes, queues,
18
+ # workers, and job status with an interactive TUI.
19
+ #
20
+ # @example Running from command line
21
+ # sktop -r redis://localhost:6379/0
22
+ #
23
+ # @example Using in Ruby code
24
+ # Sktop.configure_redis(url: "redis://localhost:6379/0")
25
+ # Sktop::CLI.new.run
26
+ #
27
+ # @see https://github.com/jamez01/sktop
16
28
  module Sktop
29
+ # Base error class for all Sktop errors
17
30
  class Error < StandardError; end
18
31
 
19
32
  class << self
33
+ # Configure the Redis connection for Sidekiq client mode.
34
+ #
35
+ # @param url [String, nil] Redis connection URL (e.g., "redis://localhost:6379/0")
36
+ # @param namespace [String, nil] Redis namespace for Sidekiq keys (currently unused)
37
+ # @return [void]
38
+ #
39
+ # @example Configure with a custom Redis URL
40
+ # Sktop.configure_redis(url: "redis://myredis:6379/1")
20
41
  def configure_redis(url: nil, namespace: nil)
21
42
  options = {}
22
43
  options[:url] = url if url
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sktop
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - James
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-01-17 00:00:00.000000000 Z
11
+ date: 2026-01-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sidekiq