sktop 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c99f0dede27934a3422767109eb5d1870b61ee9d0131e83d9731335b5e968f29
4
+ data.tar.gz: 070bf9de8114f114555b841abf898864070aebdaefb899f4b97e04c62a2dae64
5
+ SHA512:
6
+ metadata.gz: ce99bdcc62ae52bbf105ae7c9fdeb7735d58e3ce354fb393e96b307d7f2c5d2a3db0672244b318c6dd0d6f4d496ba6134708c7ea6d8233eed93e829311ad0fe4
7
+ data.tar.gz: 62eb8486ef2c299d937268f26b9ce339119d628f15c0b5dbb33a911c3ed06e2c0a8f21149a3e942d7e4a953e70beed42ec9416872d38e4acd72503065bb36e16
data/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # sktop
2
+
3
+ A CLI tool to monitor Sidekiq queues and processes - like htop for Sidekiq.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem 'sktop'
11
+ ```
12
+
13
+ Or install directly:
14
+
15
+ ```bash
16
+ gem install sktop
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```bash
22
+ # Basic usage (auto-refresh every 2 seconds)
23
+ sktop
24
+
25
+ # Connect to a specific Redis instance
26
+ sktop -r redis://myhost:6379/1
27
+
28
+ # Custom refresh interval
29
+ sktop -i 5
30
+
31
+ # Start with a specific view
32
+ sktop -w # Workers view
33
+ sktop -R # Retries view
34
+ sktop -d # Dead jobs view
35
+
36
+ # Display once and exit (no auto-refresh)
37
+ sktop -1
38
+ sktop -R -1 # Show retries view once and exit
39
+ ```
40
+
41
+ ## Options
42
+
43
+ | Option | Description |
44
+ |--------|-------------|
45
+ | `-r, --redis URL` | Redis URL (default: redis://localhost:6379/0 or REDIS_URL env var) |
46
+ | `-n, --namespace NS` | Redis namespace (e.g., 'myapp') |
47
+ | `-i, --interval SECONDS` | Auto-refresh interval in seconds (default: 2) |
48
+ | `-1, --once` | Display once and exit (no auto-refresh) |
49
+ | `-v, --version` | Show version |
50
+ | `-h, --help` | Show help |
51
+
52
+ ### View Options (set initial view)
53
+
54
+ | Option | Description |
55
+ |--------|-------------|
56
+ | `-m, --main` | Main view - processes + workers (default) |
57
+ | `-q, --queues` | Queues view |
58
+ | `-p, --processes` | Processes view |
59
+ | `-w, --workers` | Workers view |
60
+ | `-R, --retries` | Retries view |
61
+ | `-s, --scheduled` | Scheduled jobs view |
62
+ | `-d, --dead` | Dead jobs view |
63
+
64
+ ## Keyboard Navigation
65
+
66
+ | Key | Action |
67
+ |-----|--------|
68
+ | `m` | Main view (processes + workers) |
69
+ | `q` | Queues view |
70
+ | `p` | Processes view |
71
+ | `w` | Workers view |
72
+ | `r` | Retry queue view |
73
+ | `s` | Scheduled jobs view |
74
+ | `d` | Dead jobs view |
75
+ | `↑/↓` | Scroll / select items |
76
+ | `PgUp/PgDn` | Scroll by page |
77
+ | `Esc` | Return to main view |
78
+ | `Ctrl+C` | Quit |
79
+
80
+ ### Process Actions (in Processes view)
81
+
82
+ | Key | Action |
83
+ |-----|--------|
84
+ | `Ctrl+Q` | Quiet selected process |
85
+ | `Ctrl+K` | Stop/kill selected process |
86
+
87
+ ### Job Actions (in Retry/Dead views)
88
+
89
+ | Key | Action |
90
+ |-----|--------|
91
+ | `Ctrl+R` | Retry selected job |
92
+ | `Ctrl+X` | Delete selected job |
93
+ | `Alt+R` | Retry all jobs |
94
+ | `Alt+X` | Delete all jobs |
95
+
96
+ ## Environment Variables
97
+
98
+ - `REDIS_URL` - Default Redis connection URL
99
+ - `SIDEKIQ_NAMESPACE` - Default Redis namespace
100
+ - `DEBUG` - Show stack traces on errors
101
+
102
+ ## License
103
+
104
+ MIT License
data/bin/sktop ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/sktop"
5
+
6
+ Sktop::CLI.new.run
data/lib/sktop/cli.rb ADDED
@@ -0,0 +1,501 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "redis"
5
+ require "redis-namespace"
6
+ require "io/console"
7
+ require "io/wait"
8
+
9
+ module Sktop
10
+ class CLI
11
+ def initialize(args = ARGV)
12
+ @args = args
13
+ @options = {
14
+ redis_url: ENV["REDIS_URL"] || "redis://localhost:6379/0",
15
+ namespace: ENV["SIDEKIQ_NAMESPACE"],
16
+ refresh_interval: 2,
17
+ initial_view: :main,
18
+ once: false
19
+ }
20
+ @running = true
21
+ end
22
+
23
+ def run
24
+ parse_options!
25
+ configure_redis
26
+ start_watcher
27
+ rescue Interrupt
28
+ shutdown
29
+ rescue RedisClient::CannotConnectError, Redis::CannotConnectError => e
30
+ shutdown
31
+ puts "Error: Cannot connect to Redis at #{@options[:redis_url]}"
32
+ puts "Make sure Redis is running and the URL is correct."
33
+ puts "You can specify a different URL with: sktop -r redis://host:port/db"
34
+ exit 1
35
+ rescue StandardError => e
36
+ shutdown
37
+ puts "Error: #{e.message}"
38
+ puts e.backtrace.first(5).join("\n") if ENV["DEBUG"]
39
+ exit 1
40
+ end
41
+
42
+ def shutdown
43
+ @running = false
44
+ print "\e[?25h" # Show cursor
45
+ print "\e[?1049l" # Restore main screen
46
+ $stdout.flush
47
+ end
48
+
49
+ private
50
+
51
+ def parse_options!
52
+ parser = OptionParser.new do |opts|
53
+ opts.banner = "Usage: sktop [options]"
54
+ opts.separator ""
55
+ opts.separator "Options:"
56
+
57
+ opts.on("-r", "--redis URL", "Redis URL (default: redis://localhost:6379/0)") do |url|
58
+ @options[:redis_url] = url
59
+ end
60
+
61
+ opts.on("-n", "--namespace NS", "Redis namespace (e.g., 'myapp')") do |ns|
62
+ @options[:namespace] = ns
63
+ end
64
+
65
+ opts.on("-i", "--interval SECONDS", Integer, "Refresh interval in seconds (default: 2)") do |interval|
66
+ @options[:refresh_interval] = interval
67
+ end
68
+
69
+ opts.separator ""
70
+ opts.separator "Views (set initial view):"
71
+
72
+ opts.on("-m", "--main", "Main view (default)") do
73
+ @options[:initial_view] = :main
74
+ end
75
+
76
+ opts.on("-q", "--queues", "Queues view") do
77
+ @options[:initial_view] = :queues
78
+ end
79
+
80
+ opts.on("-p", "--processes", "Processes view") do
81
+ @options[:initial_view] = :processes
82
+ end
83
+
84
+ opts.on("-w", "--workers", "Workers view") do
85
+ @options[:initial_view] = :workers
86
+ end
87
+
88
+ opts.on("-R", "--retries", "Retries view") do
89
+ @options[:initial_view] = :retries
90
+ end
91
+
92
+ opts.on("-s", "--scheduled", "Scheduled jobs view") do
93
+ @options[:initial_view] = :scheduled
94
+ end
95
+
96
+ opts.on("-d", "--dead", "Dead jobs view") do
97
+ @options[:initial_view] = :dead
98
+ end
99
+
100
+ opts.separator ""
101
+
102
+ opts.on("-1", "--once", "Display once and exit (no auto-refresh)") do
103
+ @options[:once] = true
104
+ end
105
+
106
+ opts.on("-v", "--version", "Show version") do
107
+ puts "sktop #{Sktop::VERSION}"
108
+ exit 0
109
+ end
110
+
111
+ opts.on("-h", "--help", "Show this help") do
112
+ puts opts
113
+ exit 0
114
+ end
115
+ end
116
+
117
+ parser.parse!(@args)
118
+ end
119
+
120
+ def configure_redis
121
+ Sidekiq.configure_client do |config|
122
+ if @options[:namespace]
123
+ config.redis = {
124
+ url: @options[:redis_url],
125
+ namespace: @options[:namespace]
126
+ }
127
+ else
128
+ config.redis = { url: @options[:redis_url] }
129
+ end
130
+ end
131
+ end
132
+
133
+ def start_watcher
134
+ collector = StatsCollector.new
135
+ @display = Display.new
136
+ @display.current_view = @options[:initial_view]
137
+
138
+ if @options[:once]
139
+ # One-shot mode: just print and exit
140
+ puts @display.render(collector)
141
+ return
142
+ end
143
+
144
+ # Auto-refresh mode with keyboard input
145
+ $stdout.sync = true
146
+
147
+ # Get terminal size before entering raw mode
148
+ @display.update_terminal_size
149
+
150
+ # Switch to alternate screen buffer (like htop/vim)
151
+ print "\e[?1049h" # Enable alternate screen
152
+ print "\e[?25l" # Hide cursor
153
+ print "\e[2J" # Clear screen
154
+ $stdout.flush
155
+
156
+ # Thread-safe data cache
157
+ @cached_data = nil
158
+ @data_version = 0
159
+ @rendered_version = 0
160
+ @data_mutex = Mutex.new
161
+ @fetch_in_progress = false
162
+ @fetch_mutex = Mutex.new
163
+
164
+ # Set up signal handler for Ctrl+C (works even when blocked)
165
+ Signal.trap("INT") { @running = false }
166
+
167
+ # Start background thread for data fetching
168
+ fetch_thread = Thread.new do
169
+ while @running
170
+ # Skip if a fetch is already in progress (defensive guard)
171
+ can_fetch = @fetch_mutex.synchronize do
172
+ if @fetch_in_progress
173
+ false
174
+ else
175
+ @fetch_in_progress = true
176
+ end
177
+ end
178
+
179
+ next unless can_fetch
180
+
181
+ begin
182
+ collector.refresh!
183
+ # Cache a snapshot of the data
184
+ snapshot = {
185
+ overview: collector.overview,
186
+ queues: collector.queues,
187
+ processes: collector.processes,
188
+ workers: collector.workers,
189
+ retry_jobs: collector.retry_jobs(limit: 500),
190
+ scheduled_jobs: collector.scheduled_jobs(limit: 500),
191
+ dead_jobs: collector.dead_jobs(limit: 500)
192
+ }
193
+ @data_mutex.synchronize do
194
+ @cached_data = snapshot
195
+ @data_version += 1
196
+ end
197
+ rescue => e
198
+ # Ignore fetch errors, will retry next interval
199
+ ensure
200
+ @fetch_mutex.synchronize { @fetch_in_progress = false }
201
+ end
202
+
203
+ # Sleep in small increments so we can exit quickly
204
+ (@options[:refresh_interval] * 10).to_i.times do
205
+ break unless @running
206
+ sleep 0.1
207
+ end
208
+ end
209
+ end
210
+
211
+ begin
212
+ # Set up raw mode for keyboard input
213
+ STDIN.raw do |stdin|
214
+ # Wait for initial data (with timeout)
215
+ 10.times do
216
+ break if @data_mutex.synchronize { @cached_data }
217
+ sleep 0.1
218
+ end
219
+
220
+ # Initial render
221
+ render_cached_data
222
+
223
+ while @running
224
+ # Wait for keyboard input with short timeout
225
+ ready = IO.select([stdin], nil, nil, 0.03)
226
+
227
+ if ready
228
+ key = stdin.read_nonblock(1) rescue nil
229
+ if key
230
+ handle_keypress(key, stdin)
231
+ # Immediate refresh on keypress
232
+ render_cached_data
233
+ end
234
+ end
235
+
236
+ # Check if background thread has new data
237
+ current_version = @data_mutex.synchronize { @data_version }
238
+ if current_version != @rendered_version
239
+ @rendered_version = current_version
240
+ render_cached_data
241
+ end
242
+ end
243
+ end
244
+ ensure
245
+ # Stop fetch thread
246
+ @running = false
247
+ fetch_thread.join(0.5) rescue nil
248
+
249
+ # Restore normal screen
250
+ print "\e[?25h" # Show cursor
251
+ print "\e[?1049l" # Disable alternate screen
252
+ $stdout.flush
253
+
254
+ # Reset signal handler
255
+ Signal.trap("INT", "DEFAULT")
256
+ end
257
+ end
258
+
259
+ def render_cached_data
260
+ data = @data_mutex.synchronize { @cached_data }
261
+ return unless data
262
+
263
+ @display.render_refresh_from_cache(data)
264
+ end
265
+
266
+ def handle_keypress(key, stdin)
267
+ case key
268
+ when 'q', 'Q'
269
+ @display.current_view = :queues
270
+ when 'p', 'P'
271
+ @display.current_view = :processes
272
+ when 'w', 'W'
273
+ @display.current_view = :workers
274
+ when 'r', 'R' # Retries view
275
+ @display.current_view = :retries
276
+ when 's', 'S'
277
+ @display.current_view = :scheduled
278
+ when 'd', 'D'
279
+ @display.current_view = :dead
280
+ when 'm', 'M'
281
+ @display.current_view = :main
282
+ when "\x12" # Ctrl+R - retry job
283
+ handle_retry_action
284
+ when "\x18" # Ctrl+X - delete job
285
+ handle_delete_action
286
+ when "\x11" # Ctrl+Q - quiet process
287
+ handle_quiet_process_action
288
+ when "\x0B" # Ctrl+K - stop/kill process
289
+ handle_stop_process_action
290
+ when "\e" # Escape sequence - could be arrow keys, Alt+key, or just Escape
291
+ # Try to read more characters (arrow keys send \e[A, \e[B, etc.)
292
+ if IO.select([stdin], nil, nil, 0.05)
293
+ seq = stdin.read_nonblock(10) rescue ""
294
+ case seq
295
+ when "[A" # Up arrow
296
+ @display.select_up
297
+ when "[B" # Down arrow
298
+ @display.select_down
299
+ when "[C" # Right arrow (unused for now)
300
+ when "[D" # Left arrow (unused for now)
301
+ when "[5~" # Page Up
302
+ @display.page_up
303
+ when "[6~" # Page Down
304
+ @display.page_down
305
+ when "r", "R" # Alt+R - Retry All
306
+ handle_retry_all_action
307
+ when "x", "X" # Alt+X - Delete All
308
+ handle_delete_all_action
309
+ else
310
+ # Just Escape key - go to main
311
+ @display.current_view = :main
312
+ end
313
+ else
314
+ # Just Escape key - go to main
315
+ @display.current_view = :main
316
+ end
317
+ when "\u0003" # Ctrl+C
318
+ raise Interrupt
319
+ end
320
+ end
321
+
322
+ def handle_retry_action
323
+ return unless [:retries, :dead].include?(@display.current_view)
324
+
325
+ data = @data_mutex.synchronize { @cached_data }
326
+ unless data
327
+ @display.set_status("No data available")
328
+ return
329
+ end
330
+
331
+ jobs = @display.current_view == :retries ? data[:retry_jobs] : data[:dead_jobs]
332
+ selected_idx = @display.selected_index
333
+
334
+ if jobs.empty?
335
+ @display.set_status("No jobs to retry")
336
+ return
337
+ end
338
+
339
+ if selected_idx >= jobs.length
340
+ @display.set_status("Invalid selection")
341
+ return
342
+ end
343
+
344
+ job = jobs[selected_idx]
345
+ unless job[:jid]
346
+ @display.set_status("Job has no JID")
347
+ return
348
+ end
349
+
350
+ begin
351
+ source = @display.current_view == :retries ? :retry : :dead
352
+ Sktop::JobActions.retry_job(job[:jid], source)
353
+ @display.set_status("Retrying #{job[:class]}")
354
+ # Force data refresh
355
+ @rendered_version = -1
356
+ rescue => e
357
+ @display.set_status("Error: #{e.message}")
358
+ end
359
+ end
360
+
361
+ def handle_delete_action
362
+ return unless [:retries, :dead].include?(@display.current_view)
363
+
364
+ data = @data_mutex.synchronize { @cached_data }
365
+ unless data
366
+ @display.set_status("No data available")
367
+ return
368
+ end
369
+
370
+ jobs = @display.current_view == :retries ? data[:retry_jobs] : data[:dead_jobs]
371
+ selected_idx = @display.selected_index
372
+
373
+ if jobs.empty?
374
+ @display.set_status("No jobs to delete")
375
+ return
376
+ end
377
+
378
+ if selected_idx >= jobs.length
379
+ @display.set_status("Invalid selection")
380
+ return
381
+ end
382
+
383
+ job = jobs[selected_idx]
384
+ unless job[:jid]
385
+ @display.set_status("Job has no JID")
386
+ return
387
+ end
388
+
389
+ begin
390
+ source = @display.current_view == :retries ? :retry : :dead
391
+ Sktop::JobActions.delete_job(job[:jid], source)
392
+ @display.set_status("Deleted #{job[:class]}")
393
+ # Force data refresh
394
+ @rendered_version = -1
395
+ rescue => e
396
+ @display.set_status("Error: #{e.message}")
397
+ end
398
+ end
399
+
400
+ def handle_retry_all_action
401
+ return unless [:retries, :dead].include?(@display.current_view)
402
+
403
+ begin
404
+ source = @display.current_view == :retries ? :retry : :dead
405
+ count = Sktop::JobActions.retry_all(source)
406
+ @display.set_status("Retrying all #{count} jobs")
407
+ @rendered_version = -1
408
+ rescue => e
409
+ @display.set_status("Error: #{e.message}")
410
+ end
411
+ end
412
+
413
+ def handle_delete_all_action
414
+ return unless [:retries, :dead].include?(@display.current_view)
415
+
416
+ begin
417
+ source = @display.current_view == :retries ? :retry : :dead
418
+ count = Sktop::JobActions.delete_all(source)
419
+ @display.set_status("Deleted all #{count} jobs")
420
+ @rendered_version = -1
421
+ rescue => e
422
+ @display.set_status("Error: #{e.message}")
423
+ end
424
+ end
425
+
426
+ def handle_quiet_process_action
427
+ return unless @display.current_view == :processes
428
+
429
+ data = @data_mutex.synchronize { @cached_data }
430
+ unless data
431
+ @display.set_status("No data available")
432
+ return
433
+ end
434
+
435
+ processes = data[:processes]
436
+ selected_idx = @display.selected_index
437
+
438
+ if processes.empty?
439
+ @display.set_status("No processes")
440
+ return
441
+ end
442
+
443
+ if selected_idx >= processes.length
444
+ @display.set_status("Invalid selection")
445
+ return
446
+ end
447
+
448
+ process = processes[selected_idx]
449
+ unless process[:identity]
450
+ @display.set_status("Process has no identity")
451
+ return
452
+ end
453
+
454
+ begin
455
+ Sktop::JobActions.quiet_process(process[:identity])
456
+ @display.set_status("Quieting #{process[:hostname]}:#{process[:pid]}")
457
+ @rendered_version = -1
458
+ rescue => e
459
+ @display.set_status("Error: #{e.message}")
460
+ end
461
+ end
462
+
463
+ def handle_stop_process_action
464
+ return unless @display.current_view == :processes
465
+
466
+ data = @data_mutex.synchronize { @cached_data }
467
+ unless data
468
+ @display.set_status("No data available")
469
+ return
470
+ end
471
+
472
+ processes = data[:processes]
473
+ selected_idx = @display.selected_index
474
+
475
+ if processes.empty?
476
+ @display.set_status("No processes")
477
+ return
478
+ end
479
+
480
+ if selected_idx >= processes.length
481
+ @display.set_status("Invalid selection")
482
+ return
483
+ end
484
+
485
+ process = processes[selected_idx]
486
+ unless process[:identity]
487
+ @display.set_status("Process has no identity")
488
+ return
489
+ end
490
+
491
+ begin
492
+ Sktop::JobActions.stop_process(process[:identity])
493
+ @display.set_status("Stopping #{process[:hostname]}:#{process[:pid]}")
494
+ @rendered_version = -1
495
+ rescue => e
496
+ @display.set_status("Error: #{e.message}")
497
+ end
498
+ end
499
+
500
+ end
501
+ end