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 +7 -0
- data/README.md +104 -0
- data/bin/sktop +6 -0
- data/lib/sktop/cli.rb +501 -0
- data/lib/sktop/display.rb +1150 -0
- data/lib/sktop/job_actions.rb +97 -0
- data/lib/sktop/stats_collector.rb +127 -0
- data/lib/sktop/version.rb +5 -0
- data/lib/sktop.rb +29 -0
- metadata +137 -0
|
@@ -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
|