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.
@@ -0,0 +1,1150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sktop
4
+ class Display
5
+ attr_accessor :current_view
6
+
7
+ def initialize
8
+ @pastel = Pastel.new
9
+ @cursor = TTY::Cursor
10
+ @current_view = :main
11
+ @terminal_size = nil
12
+ @scroll_offsets = Hash.new(0) # Track scroll position per view
13
+ @selected_index = Hash.new(0) # Track selected row per view
14
+ @status_message = nil
15
+ @status_time = nil
16
+ end
17
+
18
+ def scroll_up
19
+ @scroll_offsets[@current_view] = [@scroll_offsets[@current_view] - 1, 0].max
20
+ end
21
+
22
+ def scroll_down
23
+ @scroll_offsets[@current_view] += 1
24
+ end
25
+
26
+ def select_up
27
+ if selectable_view?
28
+ @selected_index[@current_view] = [@selected_index[@current_view] - 1, 0].max
29
+ else
30
+ scroll_up
31
+ end
32
+ end
33
+
34
+ def select_down
35
+ if selectable_view?
36
+ @selected_index[@current_view] += 1
37
+ else
38
+ scroll_down
39
+ end
40
+ end
41
+
42
+ def selectable_view?
43
+ [:processes, :retries, :dead].include?(@current_view)
44
+ end
45
+
46
+ def page_up(page_size = nil)
47
+ page_size ||= default_page_size
48
+ if selectable_view?
49
+ @selected_index[@current_view] = [@selected_index[@current_view] - page_size, 0].max
50
+ else
51
+ @scroll_offsets[@current_view] = [@scroll_offsets[@current_view] - page_size, 0].max
52
+ end
53
+ end
54
+
55
+ def page_down(page_size = nil)
56
+ page_size ||= default_page_size
57
+ if selectable_view?
58
+ @selected_index[@current_view] += page_size
59
+ else
60
+ @scroll_offsets[@current_view] += page_size
61
+ end
62
+ end
63
+
64
+ def default_page_size
65
+ # Use terminal height minus header/footer overhead as page size
66
+ [terminal_height - 8, 5].max
67
+ end
68
+
69
+ def selected_index
70
+ @selected_index[@current_view]
71
+ end
72
+
73
+ def set_status(message)
74
+ @status_message = message
75
+ @status_time = Time.now
76
+ end
77
+
78
+ def reset_scroll
79
+ @scroll_offsets[@current_view] = 0
80
+ end
81
+
82
+ def current_view=(view)
83
+ @current_view = view
84
+ # Don't reset scroll when switching views - preserve position
85
+ end
86
+
87
+ def reset_cursor
88
+ print @cursor.move_to(0, 0)
89
+ print @cursor.hide
90
+ $stdout.flush
91
+ end
92
+
93
+ def show_cursor
94
+ print @cursor.show
95
+ end
96
+
97
+ def update_terminal_size
98
+ # Force refresh of terminal size using TTY::Screen
99
+ height = TTY::Screen.height
100
+ width = TTY::Screen.width
101
+ if height > 0 && width > 0
102
+ @terminal_size = [height, width]
103
+ end
104
+ end
105
+
106
+ def render(collector)
107
+ content_parts = build_output(collector)
108
+ content_parts.reject { |p| p == :footer }.map(&:to_s).join("\n")
109
+ end
110
+
111
+ def render_refresh(collector)
112
+ content = build_output(collector)
113
+ render_with_overwrite(content)
114
+ end
115
+
116
+ def render_refresh_from_cache(data)
117
+ cached = CachedData.new(data)
118
+ content = build_output(cached)
119
+ render_with_overwrite(content)
120
+ end
121
+
122
+ # Simple wrapper to make cached hash act like collector
123
+ class CachedData
124
+ def initialize(data)
125
+ @data = data
126
+ end
127
+
128
+ def overview
129
+ @data[:overview]
130
+ end
131
+
132
+ def queues
133
+ @data[:queues]
134
+ end
135
+
136
+ def processes
137
+ @data[:processes]
138
+ end
139
+
140
+ def workers
141
+ @data[:workers]
142
+ end
143
+
144
+ def retry_jobs(limit: 50)
145
+ @data[:retry_jobs]&.first(limit) || []
146
+ end
147
+
148
+ def scheduled_jobs(limit: 50)
149
+ @data[:scheduled_jobs]&.first(limit) || []
150
+ end
151
+
152
+ def dead_jobs(limit: 50)
153
+ @data[:dead_jobs]&.first(limit) || []
154
+ end
155
+ end
156
+
157
+ private
158
+
159
+ def build_output(collector)
160
+ case @current_view
161
+ when :queues
162
+ build_queues_detail(collector)
163
+ when :processes
164
+ build_processes_detail(collector)
165
+ when :workers
166
+ build_workers_detail(collector)
167
+ when :retries
168
+ build_retries_detail(collector)
169
+ when :scheduled
170
+ build_scheduled_detail(collector)
171
+ when :dead
172
+ build_dead_detail(collector)
173
+ else
174
+ build_main_view(collector)
175
+ end
176
+ end
177
+
178
+ def build_main_view(collector)
179
+ queues = collector.queues
180
+ processes = collector.processes
181
+
182
+ lines = []
183
+ lines << header_bar
184
+ lines << ""
185
+ stats_meters(collector.overview, processes).each_line(chomp: true) { |l| lines << l }
186
+ lines << ""
187
+
188
+ # Calculate available space for queues and processes
189
+ # Fixed: header(1) + blank(1) + stats(6) + blank(1) + blank(1) + footer(1) = 11
190
+ # Each section needs: section_bar(1) + header(1) = 2 lines overhead
191
+ height = terminal_height
192
+ fixed_overhead = 11
193
+ section_overhead = 4 # 2 for queues section header, 2 for processes section header
194
+ available_rows = height - fixed_overhead - section_overhead
195
+
196
+ # Allocate rows based on actual data counts
197
+ workers = collector.workers
198
+ process_rows_needed = processes.length
199
+ worker_rows_needed = workers.length
200
+ total_needed = process_rows_needed + worker_rows_needed
201
+
202
+ if total_needed <= available_rows
203
+ # Everything fits
204
+ max_process_rows = process_rows_needed
205
+ max_worker_rows = worker_rows_needed
206
+ else
207
+ # Need to limit - split proportionally with minimum of 3 each
208
+ min_rows = 3
209
+ if available_rows >= min_rows * 2
210
+ process_share = (available_rows * process_rows_needed.to_f / [total_needed, 1].max).round
211
+ process_share = [[process_share, min_rows].max, available_rows - min_rows].min
212
+ max_process_rows = process_share
213
+ max_worker_rows = available_rows - process_share
214
+ else
215
+ max_process_rows = available_rows / 2
216
+ max_worker_rows = available_rows - max_process_rows
217
+ end
218
+ end
219
+
220
+ processes_section(processes, max_rows: max_process_rows).each_line(chomp: true) { |l| lines << l }
221
+ lines << ""
222
+ workers_section(workers, max_rows: max_worker_rows).each_line(chomp: true) { |l| lines << l }
223
+ lines << :footer
224
+ lines
225
+ end
226
+
227
+ def build_queues_detail(collector)
228
+ lines = []
229
+ lines << header_bar
230
+ lines << ""
231
+ # Calculate available rows: height - header(1) - blank(1) - section(1) - table_header(1) - footer(1) = height - 5
232
+ max_rows = terminal_height - 5
233
+ queues_scrollable(collector.queues, max_rows).each_line(chomp: true) { |l| lines << l }
234
+ lines << :footer
235
+ lines
236
+ end
237
+
238
+ def build_processes_detail(collector)
239
+ lines = []
240
+ lines << header_bar
241
+ lines << ""
242
+ max_rows = terminal_height - 5
243
+ processes_selectable(collector.processes, max_rows).each_line(chomp: true) { |l| lines << l }
244
+ lines << :footer
245
+ lines
246
+ end
247
+
248
+ def build_workers_detail(collector)
249
+ lines = []
250
+ lines << header_bar
251
+ lines << ""
252
+ max_rows = terminal_height - 5
253
+ workers_section(collector.workers, max_rows: max_rows, scrollable: true).each_line(chomp: true) { |l| lines << l }
254
+ lines << :footer
255
+ lines
256
+ end
257
+
258
+ def build_retries_detail(collector)
259
+ lines = []
260
+ lines << header_bar
261
+ lines << ""
262
+ max_rows = terminal_height - 5
263
+ retries_scrollable(collector.retry_jobs(limit: 500), max_rows).each_line(chomp: true) { |l| lines << l }
264
+ lines << :footer
265
+ lines
266
+ end
267
+
268
+ def build_scheduled_detail(collector)
269
+ lines = []
270
+ lines << header_bar
271
+ lines << ""
272
+ max_rows = terminal_height - 5
273
+ scheduled_scrollable(collector.scheduled_jobs(limit: 500), max_rows).each_line(chomp: true) { |l| lines << l }
274
+ lines << :footer
275
+ lines
276
+ end
277
+
278
+ def build_dead_detail(collector)
279
+ lines = []
280
+ lines << header_bar
281
+ lines << ""
282
+ max_rows = terminal_height - 5
283
+ dead_scrollable(collector.dead_jobs(limit: 500), max_rows).each_line(chomp: true) { |l| lines << l }
284
+ lines << :footer
285
+ lines
286
+ end
287
+
288
+ def render_with_overwrite(content_parts)
289
+ width = terminal_width
290
+ height = terminal_height
291
+
292
+ footer_content = function_bar
293
+ lines = content_parts.reject { |p| p == :footer }.map(&:to_s)
294
+
295
+ # Truncate content to fit screen (leave 1 line for footer)
296
+ max_content_lines = height - 1
297
+ lines = lines.first(max_content_lines)
298
+
299
+ # Build output buffer
300
+ output = String.new
301
+
302
+ # Render each content line with explicit cursor positioning
303
+ lines.each_with_index do |line, row|
304
+ output << "\e[#{row + 1};1H" # Move to row (1-indexed), column 1
305
+ visible_length = visible_string_length(line)
306
+ if visible_length > width
307
+ output << truncate_to_width(line, width)
308
+ else
309
+ output << line
310
+ output << " " * (width - visible_length)
311
+ end
312
+ end
313
+
314
+ # Fill remaining rows with blank lines
315
+ blank_line = " " * width
316
+ (lines.length...max_content_lines).each do |row|
317
+ output << "\e[#{row + 1};1H"
318
+ output << blank_line
319
+ end
320
+
321
+ # Render footer on the last line
322
+ output << "\e[#{height};1H"
323
+ footer_visible = visible_string_length(footer_content)
324
+ if footer_visible > width
325
+ output << truncate_to_width(footer_content, width)
326
+ else
327
+ output << footer_content
328
+ output << " " * (width - footer_visible)
329
+ end
330
+
331
+ print output
332
+ $stdout.flush
333
+ end
334
+
335
+ def truncate_to_width(str, width)
336
+ visible_len = 0
337
+ result = ""
338
+ in_escape = false
339
+ escape_seq = ""
340
+
341
+ str.each_char do |char|
342
+ if char == "\e"
343
+ in_escape = true
344
+ escape_seq = char
345
+ elsif in_escape
346
+ escape_seq += char
347
+ if char =~ /[a-zA-Z]/
348
+ result += escape_seq
349
+ in_escape = false
350
+ escape_seq = ""
351
+ end
352
+ else
353
+ if visible_len < width
354
+ result += char
355
+ visible_len += 1
356
+ end
357
+ end
358
+ end
359
+
360
+ # Pad if needed
361
+ result + " " * [width - visible_len, 0].max
362
+ end
363
+
364
+ def visible_string_length(str)
365
+ str.gsub(/\e\[[0-9;]*m/, '').length
366
+ end
367
+
368
+ def header_bar
369
+ width = terminal_width
370
+ timestamp = Time.now.strftime("%H:%M:%S")
371
+ title = "sktop"
372
+
373
+ left = @pastel.white.on_blue.bold(" #{title} ")
374
+ right = @pastel.white.on_blue.bold(" #{timestamp} ")
375
+
376
+ left_len = visible_string_length(left)
377
+ right_len = visible_string_length(right)
378
+ middle_width = width - left_len - right_len
379
+ middle = @pastel.on_blue(" " * middle_width)
380
+
381
+ left + middle + right
382
+ end
383
+
384
+ def section_bar(title)
385
+ width = terminal_width
386
+ left = @pastel.black.on_green.bold(" #{title} ")
387
+ left_len = visible_string_length(left)
388
+ padding = @pastel.on_green(" " * (width - left_len))
389
+ left + padding
390
+ end
391
+
392
+ def format_table_header(header)
393
+ width = terminal_width
394
+ header_len = header.length
395
+ padding = width - header_len
396
+ @pastel.black.on_cyan(header + " " * [padding, 0].max)
397
+ end
398
+
399
+ def stats_meters(overview, processes = [])
400
+ width = terminal_width
401
+ col_width = (width / 2) - 2
402
+
403
+ lines = []
404
+
405
+ # Calculate worker utilization
406
+ total_busy = processes.sum { |p| p[:busy] || 0 }
407
+ total_threads = processes.sum { |p| p[:concurrency] || 0 }
408
+
409
+ # Worker utilization bar
410
+ worker_bar = utilization_bar("Workers", total_busy, total_threads, col_width)
411
+ lines << " #{worker_bar}"
412
+ lines << ""
413
+
414
+ processed = format_number(overview[:processed])
415
+ failed = format_number(overview[:failed])
416
+ left = meter_line("Processed", processed, :green, col_width)
417
+ right = meter_line("Failed", failed, overview[:failed] > 0 ? :red : :white, col_width)
418
+ lines << " #{left} #{right}"
419
+
420
+ enqueued = format_number(overview[:enqueued])
421
+ scheduled = format_number(overview[:scheduled_size])
422
+ left = meter_line("Enqueued", enqueued, overview[:enqueued] > 0 ? :yellow : :white, col_width)
423
+ right = meter_line("Scheduled", scheduled, :cyan, col_width)
424
+ lines << " #{left} #{right}"
425
+
426
+ retries = format_number(overview[:retry_size])
427
+ dead = format_number(overview[:dead_size])
428
+ left = meter_line("Retries", retries, overview[:retry_size] > 0 ? :yellow : :white, col_width)
429
+ right = meter_line("Dead", dead, overview[:dead_size] > 0 ? :red : :white, col_width)
430
+ lines << " #{left} #{right}"
431
+
432
+ latency = format_latency(overview[:default_queue_latency])
433
+ left = meter_line("Latency", latency, overview[:default_queue_latency] > 1 ? :yellow : :green, col_width)
434
+ lines << " #{left}"
435
+
436
+ lines.join("\n")
437
+ end
438
+
439
+ def utilization_bar(label, used, total, width)
440
+ return "#{label}: No workers" if total == 0
441
+
442
+ # Calculate bar width (leave room for label, brackets, and count)
443
+ label_part = "#{label}: ["
444
+ count_part = " #{used}/#{total}]"
445
+ bar_width = width - label_part.length - count_part.length
446
+
447
+ bar_width = [bar_width, 10].max # Minimum bar width
448
+
449
+ # Calculate fill amount
450
+ percentage = used.to_f / total
451
+ filled = (percentage * bar_width).round
452
+ filled = [filled, bar_width].min
453
+
454
+ # Determine color based on utilization
455
+ color = if percentage < 0.5
456
+ :green
457
+ elsif percentage < 0.8
458
+ :yellow
459
+ else
460
+ :red
461
+ end
462
+
463
+ # Build the bar
464
+ filled_part = "|" * filled
465
+ empty_part = " " * (bar_width - filled)
466
+
467
+ colored_bar = @pastel.send(color, filled_part)
468
+
469
+ "#{@pastel.cyan(label_part)}#{colored_bar}#{empty_part}#{@pastel.send(color, count_part)}"
470
+ end
471
+
472
+ def meter_line(label, value, color, width)
473
+ label_str = "#{label}:"
474
+ value_str = @pastel.send(color).bold(value.to_s)
475
+ spacing = width - label_str.length - visible_string_length(value_str)
476
+ spacing = 1 if spacing < 1
477
+ "#{@pastel.cyan(label_str)}#{' ' * spacing}#{value_str}"
478
+ end
479
+
480
+ # Compact queues for main view
481
+ def queues_compact(queues, max_rows: nil)
482
+ width = terminal_width
483
+ lines = []
484
+ lines << section_bar("Queues (#{queues.length}) - Press 'q' for details")
485
+
486
+ return lines.join("\n") + "\n" + @pastel.dim(" No queues") if queues.empty?
487
+
488
+ # Calculate column widths
489
+ name_width = [32, width - 40].max
490
+ header = sprintf(" %-#{name_width}s %10s %10s %10s", "NAME", "SIZE", "LATENCY", "STATUS")
491
+ lines << format_table_header(header)
492
+
493
+ # Show as many as we have room for (default to all if no limit)
494
+ display_count = max_rows ? [queues.length, max_rows].min : queues.length
495
+ queues.first(display_count).each do |queue|
496
+ lines << format_queue_row(queue, name_width)
497
+ end
498
+
499
+ if queues.length > display_count
500
+ lines << @pastel.dim(" ... and #{queues.length - display_count} more queues")
501
+ end
502
+
503
+ lines.join("\n")
504
+ end
505
+
506
+ # Full queues view
507
+ def queues_full(queues)
508
+ width = terminal_width
509
+ lines = []
510
+
511
+ return @pastel.dim(" No queues") if queues.empty?
512
+
513
+ name_width = [40, width - 40].max
514
+ header = sprintf(" %-#{name_width}s %10s %10s %10s", "NAME", "SIZE", "LATENCY", "STATUS")
515
+ lines << format_table_header(header)
516
+
517
+ queues.each do |queue|
518
+ lines << format_queue_row(queue, name_width)
519
+ end
520
+
521
+ lines.join("\n")
522
+ end
523
+
524
+ # Scrollable queues view
525
+ def queues_scrollable(queues, max_rows)
526
+ width = terminal_width
527
+ lines = []
528
+
529
+ scroll_offset = @scroll_offsets[@current_view]
530
+ # Account for section bar and header in max_rows
531
+ data_rows = max_rows - 2
532
+ max_scroll = [queues.length - data_rows, 0].max
533
+ scroll_offset = [[scroll_offset, 0].max, max_scroll].min
534
+ @scroll_offsets[@current_view] = scroll_offset
535
+
536
+ scroll_indicator = queues.length > data_rows ? " [#{scroll_offset + 1}-#{[scroll_offset + data_rows, queues.length].min}/#{queues.length}]" : ""
537
+ lines << section_bar("Queues#{scroll_indicator} - ↑↓ to scroll, 'm' for main")
538
+
539
+ return lines.join("\n") + "\n" + @pastel.dim(" No queues") if queues.empty?
540
+
541
+ name_width = [40, width - 40].max
542
+ header = sprintf(" %-#{name_width}s %10s %10s %10s", "NAME", "SIZE", "LATENCY", "STATUS")
543
+ lines << format_table_header(header)
544
+
545
+ queues.drop(scroll_offset).first(data_rows).each do |queue|
546
+ lines << format_queue_row(queue, name_width)
547
+ end
548
+
549
+ remaining = queues.length - scroll_offset - data_rows
550
+ if remaining > 0
551
+ lines << @pastel.dim(" ↓ #{remaining} more")
552
+ end
553
+
554
+ lines.join("\n")
555
+ end
556
+
557
+ def format_queue_row(queue, name_width)
558
+ name = truncate(queue[:name], name_width)
559
+ size = format_number(queue[:size])
560
+ latency = format_latency(queue[:latency])
561
+ status = queue[:paused] ? "PAUSED" : "ACTIVE"
562
+
563
+ size_colored = queue[:size] > 0 ? @pastel.yellow(sprintf("%10s", size)) : sprintf("%10s", size)
564
+ status_colored = queue[:paused] ? @pastel.red(sprintf("%10s", status)) : @pastel.green(sprintf("%10s", status))
565
+
566
+ sprintf(" %-#{name_width}s %s %10s %s", name, size_colored, latency, status_colored)
567
+ end
568
+
569
+ # Compact processes for main view
570
+ def processes_section(processes, max_rows: nil, scrollable: false)
571
+ width = terminal_width
572
+ lines = []
573
+
574
+ scroll_offset = scrollable ? @scroll_offsets[@current_view] : 0
575
+ # Clamp scroll offset to valid range
576
+ max_scroll = [processes.length - (max_rows || processes.length), 0].max
577
+ scroll_offset = [[scroll_offset, 0].max, max_scroll].min
578
+ @scroll_offsets[@current_view] = scroll_offset if scrollable
579
+
580
+ scroll_indicator = scrollable && processes.length > (max_rows || processes.length) ? " [#{scroll_offset + 1}-#{[scroll_offset + (max_rows || processes.length), processes.length].min}/#{processes.length}]" : ""
581
+ hint = scrollable ? "↑↓ to scroll, 'm' for main" : "Press 'p' for details"
582
+ lines << section_bar("Processes (#{processes.length})#{scroll_indicator} - #{hint}")
583
+
584
+ if processes.empty?
585
+ lines << @pastel.dim(" No processes running")
586
+ return lines.join("\n")
587
+ end
588
+
589
+ host_width = [20, (width - 80) / 2].max
590
+ queue_width = [24, (width - 80) / 2].max
591
+
592
+ header = sprintf(" %-#{host_width}s %6s %9s %8s %-#{queue_width}s %8s %8s", "HOST", "PID", "BUSY", "MEM", "QUEUES", "UPTIME", "STATUS")
593
+ lines << format_table_header(header)
594
+
595
+ # Show as many as we have room for (default to all if no limit)
596
+ display_count = max_rows ? [processes.length - scroll_offset, max_rows].min : processes.length
597
+ processes.drop(scroll_offset).first(display_count).each do |proc|
598
+ lines << format_process_row(proc, host_width, queue_width)
599
+ end
600
+
601
+ remaining = processes.length - scroll_offset - display_count
602
+ if remaining > 0
603
+ lines << @pastel.dim(" ↓ #{remaining} more (use arrow keys to scroll)")
604
+ end
605
+
606
+ lines.join("\n")
607
+ end
608
+
609
+ # Full processes view
610
+ def processes_full(processes)
611
+ width = terminal_width
612
+ lines = []
613
+
614
+ if processes.empty?
615
+ return @pastel.dim(" No processes running")
616
+ end
617
+
618
+ host_width = [26, (width - 80) / 2].max
619
+ queue_width = [34, (width - 80) / 2].max
620
+
621
+ header = sprintf(" %-#{host_width}s %6s %9s %8s %-#{queue_width}s %8s %8s", "HOST", "PID", "BUSY", "MEM", "QUEUES", "UPTIME", "STATUS")
622
+ lines << format_table_header(header)
623
+
624
+ processes.each do |proc|
625
+ lines << format_process_row(proc, host_width, queue_width)
626
+ end
627
+
628
+ lines.join("\n")
629
+ end
630
+
631
+ # Selectable processes view with quiet/stop actions
632
+ def processes_selectable(processes, max_rows)
633
+ width = terminal_width
634
+ lines = []
635
+
636
+ scroll_offset = @scroll_offsets[@current_view]
637
+ data_rows = max_rows - 3 # Account for section bar, header, and status line
638
+ max_scroll = [processes.length - data_rows, 0].max
639
+ scroll_offset = [[scroll_offset, 0].max, max_scroll].min
640
+ @scroll_offsets[@current_view] = scroll_offset
641
+
642
+ # Clamp selected index
643
+ @selected_index[@current_view] = [[@selected_index[@current_view], 0].max, [processes.length - 1, 0].max].min
644
+
645
+ # Auto-scroll to keep selection visible
646
+ selected = @selected_index[@current_view]
647
+ if selected < scroll_offset
648
+ scroll_offset = selected
649
+ @scroll_offsets[@current_view] = scroll_offset
650
+ elsif selected >= scroll_offset + data_rows
651
+ scroll_offset = selected - data_rows + 1
652
+ @scroll_offsets[@current_view] = scroll_offset
653
+ end
654
+
655
+ scroll_indicator = processes.length > data_rows ? " [#{scroll_offset + 1}-#{[scroll_offset + data_rows, processes.length].min}/#{processes.length}]" : ""
656
+ lines << section_bar("Processes#{scroll_indicator} - ↑↓ select, ^Q=quiet, ^K=stop, m=main")
657
+
658
+ if processes.empty?
659
+ lines << @pastel.dim(" No processes running")
660
+ return lines.join("\n")
661
+ end
662
+
663
+ host_width = [26, (width - 80) / 2].max
664
+ queue_width = [34, (width - 80) / 2].max
665
+
666
+ header = sprintf(" %-#{host_width}s %6s %9s %8s %-#{queue_width}s %8s %8s", "HOST", "PID", "BUSY", "MEM", "QUEUES", "UPTIME", "STATUS")
667
+ lines << format_table_header(header)
668
+
669
+ processes.drop(scroll_offset).first(data_rows).each_with_index do |proc, idx|
670
+ actual_idx = scroll_offset + idx
671
+ row = format_process_row(proc, host_width, queue_width)
672
+
673
+ if actual_idx == selected
674
+ lines << @pastel.black.on_white(row + " " * [width - visible_string_length(row), 0].max)
675
+ else
676
+ lines << row
677
+ end
678
+ end
679
+
680
+ remaining = processes.length - scroll_offset - data_rows
681
+ if remaining > 0
682
+ lines << @pastel.dim(" ↓ #{remaining} more")
683
+ end
684
+
685
+ # Status message
686
+ if @status_message && @status_time && (Time.now - @status_time) < 3
687
+ lines << @pastel.green(" #{@status_message}")
688
+ end
689
+
690
+ lines.join("\n")
691
+ end
692
+
693
+ def format_process_row(proc, host_width, queue_width)
694
+ host = truncate(proc[:hostname], host_width)
695
+ pid = proc[:pid].to_s
696
+ busy = "#{proc[:busy]}/#{proc[:concurrency]}"
697
+ mem = format_memory(proc[:rss])
698
+ queues = truncate(proc[:queues].join(","), queue_width)
699
+ uptime = format_time_ago(proc[:started_at])
700
+
701
+ status = if proc[:quiet] && proc[:stopping]
702
+ @pastel.red("STOPPING") # Quiet process now shutting down
703
+ elsif proc[:quiet]
704
+ @pastel.yellow("QUIET")
705
+ elsif proc[:stopping]
706
+ @pastel.red("STOPPING")
707
+ else
708
+ @pastel.green("RUNNING")
709
+ end
710
+
711
+ busy_colored = proc[:busy] > 0 ? @pastel.yellow.bold(sprintf("%9s", busy)) : sprintf("%9s", busy)
712
+
713
+ sprintf(" %-#{host_width}s %6s %s %8s %-#{queue_width}s %8s %s", host, pid, busy_colored, mem, queues, uptime, status)
714
+ end
715
+
716
+ def format_memory(kb)
717
+ return "N/A" if kb.nil? || kb == 0
718
+
719
+ if kb < 1024
720
+ "#{kb}K"
721
+ elsif kb < 1024 * 1024
722
+ "#{(kb / 1024.0).round(1)}M"
723
+ else
724
+ "#{(kb / 1024.0 / 1024.0).round(2)}G"
725
+ end
726
+ end
727
+
728
+ # Compact workers for main view
729
+ def workers_section(workers, max_rows: nil, scrollable: false)
730
+ width = terminal_width
731
+ lines = []
732
+
733
+ scroll_offset = scrollable ? @scroll_offsets[@current_view] : 0
734
+ max_scroll = [workers.length - (max_rows || workers.length), 0].max
735
+ scroll_offset = [[scroll_offset, 0].max, max_scroll].min
736
+ @scroll_offsets[@current_view] = scroll_offset if scrollable
737
+
738
+ scroll_indicator = scrollable && workers.length > (max_rows || workers.length) ? " [#{scroll_offset + 1}-#{[scroll_offset + (max_rows || workers.length), workers.length].min}/#{workers.length}]" : ""
739
+ hint = scrollable ? "↑↓ to scroll, 'm' for main" : "Press 'w' for details"
740
+ lines << section_bar("Active Workers (#{workers.length})#{scroll_indicator} - #{hint}")
741
+
742
+ if workers.empty?
743
+ lines << @pastel.dim(" No active workers")
744
+ return lines.join("\n")
745
+ end
746
+
747
+ job_width = [30, (width - 50) / 2].max
748
+ args_width = [30, (width - 50) / 2].max
749
+
750
+ header = sprintf(" %-15s %-#{job_width}s %12s %-#{args_width}s", "QUEUE", "JOB", "RUNNING", "ARGS")
751
+ lines << format_table_header(header)
752
+
753
+ # Show as many as we have room for (default to all if no limit)
754
+ display_count = max_rows ? [workers.length - scroll_offset, max_rows].min : workers.length
755
+ workers.drop(scroll_offset).first(display_count).each do |worker|
756
+ queue = truncate(worker[:queue], 15)
757
+ job = truncate(worker[:class], job_width)
758
+ running = format_duration(worker[:elapsed])
759
+ args = truncate(worker[:args].inspect, args_width)
760
+
761
+ running_colored = worker[:elapsed] > 60 ? @pastel.yellow(sprintf("%12s", running)) : sprintf("%12s", running)
762
+
763
+ lines << sprintf(" %-15s %-#{job_width}s %s %-#{args_width}s", queue, job, running_colored, args)
764
+ end
765
+
766
+ remaining = workers.length - scroll_offset - display_count
767
+ if remaining > 0
768
+ lines << @pastel.dim(" ↓ #{remaining} more (use arrow keys to scroll)")
769
+ end
770
+
771
+ lines.join("\n")
772
+ end
773
+
774
+ # Full workers view
775
+ def workers_full(workers)
776
+ width = terminal_width
777
+ lines = []
778
+
779
+ if workers.empty?
780
+ return @pastel.dim(" No active workers")
781
+ end
782
+
783
+ job_width = [40, (width - 50) / 2].max
784
+ args_width = [40, (width - 50) / 2].max
785
+
786
+ header = sprintf(" %-15s %-#{job_width}s %12s %-#{args_width}s", "QUEUE", "JOB", "RUNNING", "ARGS")
787
+ lines << format_table_header(header)
788
+
789
+ workers.each do |worker|
790
+ queue = truncate(worker[:queue], 15)
791
+ job = truncate(worker[:class], job_width)
792
+ running = format_duration(worker[:elapsed])
793
+ args = truncate(worker[:args].inspect, args_width)
794
+
795
+ running_colored = worker[:elapsed] > 60 ? @pastel.yellow(sprintf("%12s", running)) : sprintf("%12s", running)
796
+
797
+ lines << sprintf(" %-15s %-#{job_width}s %s %-#{args_width}s", queue, job, running_colored, args)
798
+ end
799
+
800
+ lines.join("\n")
801
+ end
802
+
803
+ # Full retries view
804
+ def retries_full(jobs)
805
+ width = terminal_width
806
+ lines = []
807
+
808
+ if jobs.empty?
809
+ return @pastel.dim(" No retries pending")
810
+ end
811
+
812
+ job_width = [35, (width - 60) / 2].max
813
+ error_width = [35, (width - 60) / 2].max
814
+
815
+ header = sprintf(" %-#{job_width}s %-15s %6s %-#{error_width}s %16s", "JOB", "QUEUE", "COUNT", "ERROR", "FAILED AT")
816
+ lines << format_table_header(header)
817
+
818
+ jobs.each do |job|
819
+ klass = truncate(job[:class], job_width)
820
+ queue = truncate(job[:queue], 15)
821
+ count = job[:retry_count].to_s
822
+ error = truncate(job[:error_class].to_s, error_width)
823
+ failed_at = job[:failed_at]&.strftime("%Y-%m-%d %H:%M") || "N/A"
824
+
825
+ lines << sprintf(" %-#{job_width}s %-15s %6s %-#{error_width}s %16s",
826
+ klass, queue, @pastel.yellow(count), @pastel.red(error), failed_at)
827
+ end
828
+
829
+ lines.join("\n")
830
+ end
831
+
832
+ # Scrollable retries view with selection
833
+ def retries_scrollable(jobs, max_rows)
834
+ width = terminal_width
835
+ lines = []
836
+
837
+ scroll_offset = @scroll_offsets[@current_view]
838
+ data_rows = max_rows - 3 # Account for section bar, header, and status line
839
+ max_scroll = [jobs.length - data_rows, 0].max
840
+ scroll_offset = [[scroll_offset, 0].max, max_scroll].min
841
+ @scroll_offsets[@current_view] = scroll_offset
842
+
843
+ # Clamp selected index
844
+ @selected_index[@current_view] = [[@selected_index[@current_view], 0].max, [jobs.length - 1, 0].max].min
845
+
846
+ # Auto-scroll to keep selection visible
847
+ selected = @selected_index[@current_view]
848
+ if selected < scroll_offset
849
+ scroll_offset = selected
850
+ @scroll_offsets[@current_view] = scroll_offset
851
+ elsif selected >= scroll_offset + data_rows
852
+ scroll_offset = selected - data_rows + 1
853
+ @scroll_offsets[@current_view] = scroll_offset
854
+ end
855
+
856
+ scroll_indicator = jobs.length > data_rows ? " [#{scroll_offset + 1}-#{[scroll_offset + data_rows, jobs.length].min}/#{jobs.length}]" : ""
857
+ lines << section_bar("Retry Queue#{scroll_indicator} - ↑↓ select, ^R=retry, ^X=del, Alt+R=retryAll, Alt+X=delAll")
858
+
859
+ if jobs.empty?
860
+ lines << @pastel.dim(" No retries pending")
861
+ return lines.join("\n")
862
+ end
863
+
864
+ job_width = [35, (width - 60) / 2].max
865
+ error_width = [35, (width - 60) / 2].max
866
+
867
+ header = sprintf(" %-#{job_width}s %-15s %6s %-#{error_width}s %16s", "JOB", "QUEUE", "COUNT", "ERROR", "FAILED AT")
868
+ lines << format_table_header(header)
869
+
870
+ jobs.drop(scroll_offset).first(data_rows).each_with_index do |job, idx|
871
+ actual_idx = scroll_offset + idx
872
+ klass = truncate(job[:class], job_width)
873
+ queue = truncate(job[:queue], 15)
874
+ count = job[:retry_count].to_s
875
+ error = truncate(job[:error_class].to_s, error_width)
876
+ failed_at = job[:failed_at]&.strftime("%Y-%m-%d %H:%M") || "N/A"
877
+
878
+ row = sprintf(" %-#{job_width}s %-15s %6s %-#{error_width}s %16s",
879
+ klass, queue, @pastel.yellow(count), @pastel.red(error), failed_at)
880
+
881
+ if actual_idx == selected
882
+ lines << @pastel.black.on_white(row + " " * [width - visible_string_length(row), 0].max)
883
+ else
884
+ lines << row
885
+ end
886
+ end
887
+
888
+ remaining = jobs.length - scroll_offset - data_rows
889
+ if remaining > 0
890
+ lines << @pastel.dim(" ↓ #{remaining} more")
891
+ end
892
+
893
+ # Status message
894
+ if @status_message && @status_time && (Time.now - @status_time) < 3
895
+ lines << @pastel.green(" #{@status_message}")
896
+ end
897
+
898
+ lines.join("\n")
899
+ end
900
+
901
+ # Full scheduled view
902
+ def scheduled_full(jobs)
903
+ width = terminal_width
904
+ lines = []
905
+
906
+ if jobs.empty?
907
+ return @pastel.dim(" No scheduled jobs")
908
+ end
909
+
910
+ job_width = [35, (width - 60) / 2].max
911
+ args_width = [35, (width - 60) / 2].max
912
+
913
+ header = sprintf(" %-#{job_width}s %-15s %-20s %-#{args_width}s", "JOB", "QUEUE", "SCHEDULED FOR", "ARGS")
914
+ lines << format_table_header(header)
915
+
916
+ jobs.each do |job|
917
+ klass = truncate(job[:class], job_width)
918
+ queue = truncate(job[:queue], 15)
919
+ scheduled = job[:scheduled_at].strftime("%Y-%m-%d %H:%M:%S")
920
+ args = truncate(job[:args].inspect, args_width)
921
+
922
+ lines << sprintf(" %-#{job_width}s %-15s %-20s %-#{args_width}s", klass, queue, @pastel.cyan(scheduled), args)
923
+ end
924
+
925
+ lines.join("\n")
926
+ end
927
+
928
+ # Scrollable scheduled view
929
+ def scheduled_scrollable(jobs, max_rows)
930
+ width = terminal_width
931
+ lines = []
932
+
933
+ scroll_offset = @scroll_offsets[@current_view]
934
+ data_rows = max_rows - 2
935
+ max_scroll = [jobs.length - data_rows, 0].max
936
+ scroll_offset = [[scroll_offset, 0].max, max_scroll].min
937
+ @scroll_offsets[@current_view] = scroll_offset
938
+
939
+ scroll_indicator = jobs.length > data_rows ? " [#{scroll_offset + 1}-#{[scroll_offset + data_rows, jobs.length].min}/#{jobs.length}]" : ""
940
+ lines << section_bar("Scheduled Jobs#{scroll_indicator} - ↑↓ to scroll, 'm' for main")
941
+
942
+ if jobs.empty?
943
+ lines << @pastel.dim(" No scheduled jobs")
944
+ return lines.join("\n")
945
+ end
946
+
947
+ job_width = [35, (width - 60) / 2].max
948
+ args_width = [35, (width - 60) / 2].max
949
+
950
+ header = sprintf(" %-#{job_width}s %-15s %-20s %-#{args_width}s", "JOB", "QUEUE", "SCHEDULED FOR", "ARGS")
951
+ lines << format_table_header(header)
952
+
953
+ jobs.drop(scroll_offset).first(data_rows).each do |job|
954
+ klass = truncate(job[:class], job_width)
955
+ queue = truncate(job[:queue], 15)
956
+ scheduled = job[:scheduled_at].strftime("%Y-%m-%d %H:%M:%S")
957
+ args = truncate(job[:args].inspect, args_width)
958
+
959
+ lines << sprintf(" %-#{job_width}s %-15s %-20s %-#{args_width}s", klass, queue, @pastel.cyan(scheduled), args)
960
+ end
961
+
962
+ remaining = jobs.length - scroll_offset - data_rows
963
+ if remaining > 0
964
+ lines << @pastel.dim(" ↓ #{remaining} more")
965
+ end
966
+
967
+ lines.join("\n")
968
+ end
969
+
970
+ # Scrollable dead jobs view with selection
971
+ def dead_scrollable(jobs, max_rows)
972
+ width = terminal_width
973
+ lines = []
974
+
975
+ scroll_offset = @scroll_offsets[@current_view]
976
+ data_rows = max_rows - 3 # Account for section bar, header, and status line
977
+ max_scroll = [jobs.length - data_rows, 0].max
978
+ scroll_offset = [[scroll_offset, 0].max, max_scroll].min
979
+ @scroll_offsets[@current_view] = scroll_offset
980
+
981
+ # Clamp selected index
982
+ @selected_index[@current_view] = [[@selected_index[@current_view], 0].max, [jobs.length - 1, 0].max].min
983
+
984
+ # Auto-scroll to keep selection visible
985
+ selected = @selected_index[@current_view]
986
+ if selected < scroll_offset
987
+ scroll_offset = selected
988
+ @scroll_offsets[@current_view] = scroll_offset
989
+ elsif selected >= scroll_offset + data_rows
990
+ scroll_offset = selected - data_rows + 1
991
+ @scroll_offsets[@current_view] = scroll_offset
992
+ end
993
+
994
+ scroll_indicator = jobs.length > data_rows ? " [#{scroll_offset + 1}-#{[scroll_offset + data_rows, jobs.length].min}/#{jobs.length}]" : ""
995
+ lines << section_bar("Dead Jobs#{scroll_indicator} - ↑↓ select, ^R=retry, ^X=del, Alt+R=retryAll, Alt+X=delAll")
996
+
997
+ if jobs.empty?
998
+ lines << @pastel.dim(" No dead jobs")
999
+ return lines.join("\n")
1000
+ end
1001
+
1002
+ job_width = [35, (width - 60) / 2].max
1003
+ error_width = [35, (width - 60) / 2].max
1004
+
1005
+ header = sprintf(" %-#{job_width}s %-15s %-#{error_width}s %16s", "JOB", "QUEUE", "ERROR", "FAILED AT")
1006
+ lines << format_table_header(header)
1007
+
1008
+ jobs.drop(scroll_offset).first(data_rows).each_with_index do |job, idx|
1009
+ actual_idx = scroll_offset + idx
1010
+ klass = truncate(job[:class], job_width)
1011
+ queue = truncate(job[:queue], 15)
1012
+ error = truncate(job[:error_class].to_s, error_width)
1013
+ failed_at = job[:failed_at]&.strftime("%Y-%m-%d %H:%M") || "N/A"
1014
+
1015
+ row = sprintf(" %-#{job_width}s %-15s %-#{error_width}s %16s",
1016
+ klass, queue, @pastel.red(error), failed_at)
1017
+
1018
+ if actual_idx == selected
1019
+ lines << @pastel.black.on_white(row + " " * [width - visible_string_length(row), 0].max)
1020
+ else
1021
+ lines << row
1022
+ end
1023
+ end
1024
+
1025
+ remaining = jobs.length - scroll_offset - data_rows
1026
+ if remaining > 0
1027
+ lines << @pastel.dim(" ↓ #{remaining} more")
1028
+ end
1029
+
1030
+ # Status message
1031
+ if @status_message && @status_time && (Time.now - @status_time) < 3
1032
+ lines << @pastel.green(" #{@status_message}")
1033
+ end
1034
+
1035
+ lines.join("\n")
1036
+ end
1037
+
1038
+ def function_bar
1039
+ items = if @current_view == :main
1040
+ [
1041
+ ["q", "Queues"],
1042
+ ["p", "Procs"],
1043
+ ["w", "Workers"],
1044
+ ["r", "Retries"],
1045
+ ["s", "Sched"],
1046
+ ["d", "Dead"],
1047
+ ["^C", "Quit"]
1048
+ ]
1049
+ else
1050
+ [
1051
+ ["m", "Main"],
1052
+ ["q", "Queues"],
1053
+ ["p", "Procs"],
1054
+ ["w", "Workers"],
1055
+ ["r", "Retries"],
1056
+ ["s", "Sched"],
1057
+ ["d", "Dead"],
1058
+ ["^C", "Quit"]
1059
+ ]
1060
+ end
1061
+
1062
+ bar = items.map do |key, label|
1063
+ @pastel.black.on_cyan.bold(key) + @pastel.white.on_blue(label)
1064
+ end.join(" ")
1065
+
1066
+ width = terminal_width
1067
+ bar_len = visible_string_length(bar)
1068
+ padding = width - bar_len
1069
+ bar + @pastel.on_blue(" " * [padding, 0].max)
1070
+ end
1071
+
1072
+ def format_number(num)
1073
+ num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
1074
+ end
1075
+
1076
+ def format_latency(seconds)
1077
+ return "0s" if seconds.nil? || seconds == 0
1078
+
1079
+ if seconds < 1
1080
+ "#{(seconds * 1000).round}ms"
1081
+ elsif seconds < 60
1082
+ "#{seconds.round(1)}s"
1083
+ elsif seconds < 3600
1084
+ "#{(seconds / 60).round(1)}m"
1085
+ else
1086
+ "#{(seconds / 3600).round(1)}h"
1087
+ end
1088
+ end
1089
+
1090
+ def format_duration(seconds)
1091
+ return "0s" if seconds.nil? || seconds == 0
1092
+
1093
+ if seconds < 60
1094
+ "#{seconds.round}s"
1095
+ elsif seconds < 3600
1096
+ mins = (seconds / 60).floor
1097
+ secs = (seconds % 60).round
1098
+ "#{mins}m#{secs}s"
1099
+ else
1100
+ hours = (seconds / 3600).floor
1101
+ mins = ((seconds % 3600) / 60).round
1102
+ "#{hours}h#{mins}m"
1103
+ end
1104
+ end
1105
+
1106
+ def format_time_ago(time)
1107
+ seconds = Time.now - time
1108
+ if seconds < 60
1109
+ "now"
1110
+ elsif seconds < 3600
1111
+ "#{(seconds / 60).round}m"
1112
+ elsif seconds < 86400
1113
+ "#{(seconds / 3600).round}h"
1114
+ else
1115
+ "#{(seconds / 86400).round}d"
1116
+ end
1117
+ end
1118
+
1119
+ def truncate(str, length)
1120
+ str = str.to_s
1121
+ str.length > length ? "#{str[0...length - 1]}~" : str
1122
+ end
1123
+
1124
+ def terminal_size
1125
+ # Use TTY::Screen which handles raw mode and alternate screen better
1126
+ height = TTY::Screen.height
1127
+ width = TTY::Screen.width
1128
+
1129
+ # Use cached value if TTY::Screen returns invalid size
1130
+ if height > 0 && width > 0
1131
+ @terminal_size = [height, width]
1132
+ elsif @terminal_size
1133
+ # Use previously cached size
1134
+ else
1135
+ # Fallback
1136
+ @terminal_size = [24, 80]
1137
+ end
1138
+
1139
+ @terminal_size
1140
+ end
1141
+
1142
+ def terminal_width
1143
+ terminal_size[1]
1144
+ end
1145
+
1146
+ def terminal_height
1147
+ terminal_size[0]
1148
+ end
1149
+ end
1150
+ end