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 +4 -4
- data/lib/sktop/cli.rb +202 -6
- data/lib/sktop/display.rb +299 -33
- data/lib/sktop/job_actions.rb +84 -0
- data/lib/sktop/stats_collector.rb +133 -0
- data/lib/sktop/version.rb +4 -1
- data/lib/sktop.rb +21 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d1e0891fc2a69763f5784c7cc96fc69103d14362aa67a08b82b6c13aa578985b
|
|
4
|
+
data.tar.gz: fea7b7284c37d666516d42c2bcc97e0f6b3db4cbb53628e2e3b35c6521c44845
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
303
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
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
|
-
|
|
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
|
data/lib/sktop/job_actions.rb
CHANGED
|
@@ -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
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.
|
|
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-
|
|
11
|
+
date: 2026-01-30 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: sidekiq
|