sqdash 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/sqdash/cli.rb CHANGED
@@ -1,16 +1,55 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "io/console"
4
- require "json"
5
-
6
3
  module Sqdash
7
4
  class CLI
5
+ include Renderer
6
+ include InputHandler
7
+ include Autocomplete
8
+
8
9
  DEFAULT_DB_URL = "postgres://sqd:sqd@localhost:5432/sqd_web_development_queue"
9
10
 
11
+ HELP_TEXT = <<~HELP
12
+ Usage: sqdash [database-url] [options]
13
+
14
+ A terminal dashboard for Rails 8's Solid Queue.
15
+
16
+ Arguments:
17
+ database-url Database connection URL (optional)
18
+
19
+ Options:
20
+ -c, --config FILE Path to config file (default: .sqdash.yml or ~/.sqdash.yml)
21
+ -h, --help Show this help message
22
+ -v, --version Show version
23
+
24
+ Config file (~/.sqdash.yml or .sqdash.yml):
25
+ database_url: postgres://user:pass@host:5432/myapp
26
+
27
+ Connection priority: CLI arg > DATABASE_URL env > .sqdash.yml > ~/.sqdash.yml > default
28
+
29
+ Keybindings:
30
+ ↑/↓ Navigate job list
31
+ Enter View job details
32
+ / Filter jobs (by class, queue, or ID)
33
+ : Command mode (sort, view)
34
+ r Retry failed job
35
+ d Discard failed job
36
+ Space Refresh data
37
+ q Quit
38
+
39
+ Commands (in : mode):
40
+ sort created|id asc|desc Sort jobs
41
+ view all|failed|completed|pending Filter by status
42
+
43
+ Examples:
44
+ sqdash
45
+ sqdash postgres://user:pass@host:5432/myapp_production
46
+ DATABASE_URL=postgres://... sqdash
47
+ HELP
48
+
10
49
  COMMANDS = {
11
50
  "sort" => {
12
- "created" => ["asc", "desc"],
13
- "id" => ["asc", "desc"]
51
+ "created" => %w[asc desc],
52
+ "id" => %w[asc desc]
14
53
  },
15
54
  "view" => {
16
55
  "all" => [],
@@ -21,18 +60,45 @@ module Sqdash
21
60
  }.freeze
22
61
 
23
62
  def self.start
24
- new.run
63
+ args = ARGV.dup
64
+ config_path = nil
65
+
66
+ if (idx = args.index("-c") || args.index("--config"))
67
+ args.delete_at(idx)
68
+ config_path = args.delete_at(idx)
69
+ end
70
+
71
+ case args[0]
72
+ when "-h", "--help"
73
+ puts HELP_TEXT
74
+ exit
75
+ when "-v", "--version"
76
+ puts "sqdash #{Sqdash::VERSION}"
77
+ exit
78
+ end
79
+
80
+ new(db_url: args[0], config_path: config_path).run
25
81
  end
26
82
 
83
+ def initialize(db_url: nil, config_path: nil)
84
+ @db_url_arg = db_url
85
+ @config_path = config_path
86
+ end
87
+
88
+ PAGE_SIZE = 200
89
+
27
90
  def run
28
91
  Database.connect!(resolve_db_url)
29
92
  @selected = 0
30
93
  @scroll_offset = 0
31
94
  @filter_text = ""
32
95
  @filter_mode = false
33
- @view = :all # :all, :failed, :completed, :pending
96
+ @view = :all
34
97
  @jobs = []
35
98
  @failed_ids = []
99
+ @total_count = 0
100
+ @page = 0
101
+ @all_loaded = false
36
102
  @message = nil
37
103
  @sort_column = :created_at
38
104
  @sort_dir = :desc
@@ -52,13 +118,10 @@ module Sqdash
52
118
  private
53
119
 
54
120
  def resolve_db_url
55
- ARGV[0] || ENV["DATABASE_URL"] || DEFAULT_DB_URL
56
- end
57
-
58
- def cleanup
59
- print "\e[?25h"
60
- print "\e[2J\e[H"
61
- puts "Goodbye!"
121
+ @db_url_arg ||
122
+ ENV["DATABASE_URL"] ||
123
+ Config.load(@config_path).database_url ||
124
+ DEFAULT_DB_URL
62
125
  end
63
126
 
64
127
  def trap_resize
@@ -67,48 +130,32 @@ module Sqdash
67
130
  end
68
131
  end
69
132
 
70
- def truncate(str, max)
71
- return str if str.length <= max
72
-
73
- # Strip ANSI codes to measure visible length
74
- visible = str.gsub(/\e\[[0-9;]*m/, "")
75
- return str if visible.length <= max
76
-
77
- # Truncate by walking through the string, tracking visible chars
78
- result = +""
79
- visible_count = 0
80
- i = 0
81
- while i < str.length && visible_count < max
82
- if str[i] == "\e" && str[i..] =~ /\A(\e\[[0-9;]*m)/
83
- result << $1
84
- i += $1.length
85
- else
86
- result << str[i]
87
- visible_count += 1
88
- i += 1
89
- end
90
- end
91
- result << "\e[0m"
92
- end
133
+ def load_data
134
+ @failed_ids = Models::FailedExecution.pluck(:job_id)
135
+ @page = 0
136
+ @all_loaded = false
93
137
 
94
- def terminal_height
95
- $stdout.winsize[0]
96
- end
138
+ scope = build_scope
139
+ @total_count = scope.count
140
+ @jobs = scope.limit(PAGE_SIZE).offset(0).to_a
141
+ @all_loaded = @jobs.length < PAGE_SIZE
97
142
 
98
- def terminal_width
99
- $stdout.winsize[1]
143
+ @selected = @selected.clamp(0, [@jobs.length - 1, 0].max)
144
+ adjust_scroll
100
145
  end
101
146
 
102
- def visible_rows
103
- [terminal_height - 11, 5].max
104
- end
147
+ def load_more
148
+ return if @all_loaded
105
149
 
106
- def load_data
107
- @failed_ids = Models::FailedExecution.pluck(:job_id)
150
+ @page += 1
151
+ new_jobs = build_scope.limit(PAGE_SIZE).offset(@page * PAGE_SIZE).to_a
152
+ @jobs.concat(new_jobs)
153
+ @all_loaded = new_jobs.length < PAGE_SIZE
154
+ end
108
155
 
156
+ def build_scope
109
157
  scope = Models::Job.order(@sort_column => @sort_dir)
110
158
 
111
- # View filter
112
159
  case @view
113
160
  when :failed
114
161
  scope = @failed_ids.any? ? scope.where(id: @failed_ids) : scope.none
@@ -118,21 +165,15 @@ module Sqdash
118
165
  scope = scope.where(finished_at: nil).where.not(id: @failed_ids)
119
166
  end
120
167
 
121
- @jobs = scope.to_a
122
-
123
- # Text filter (k9s style — filters across all visible columns)
124
- if @filter_text.length > 0
125
- query = @filter_text.downcase
126
- @jobs = @jobs.select do |job|
127
- job.class_name.downcase.include?(query) ||
128
- job.queue_name.downcase.include?(query) ||
129
- job.id.to_s.include?(query)
130
- end
168
+ if @filter_text.length.positive?
169
+ query = "%#{@filter_text}%"
170
+ scope = scope.where(
171
+ "LOWER(class_name) LIKE LOWER(?) OR LOWER(queue_name) LIKE LOWER(?) OR CAST(id AS TEXT) LIKE ?",
172
+ query, query, query
173
+ )
131
174
  end
132
175
 
133
- # Clamp selection
134
- @selected = [[@selected, @jobs.length - 1].min, 0].max
135
- adjust_scroll
176
+ scope
136
177
  end
137
178
 
138
179
  def adjust_scroll
@@ -141,6 +182,8 @@ module Sqdash
141
182
  elsif @selected >= @scroll_offset + visible_rows
142
183
  @scroll_offset = @selected - visible_rows + 1
143
184
  end
185
+
186
+ load_more if !@all_loaded && @selected >= @jobs.length - (PAGE_SIZE / 4)
144
187
  end
145
188
 
146
189
  def job_status(job)
@@ -152,609 +195,5 @@ module Sqdash
152
195
  :pending
153
196
  end
154
197
  end
155
-
156
- def status_text(status)
157
- case status
158
- when :failed then "\e[31m● failed\e[0m "
159
- when :completed then "\e[32m● completed\e[0m"
160
- when :pending then "\e[33m● pending\e[0m "
161
- end
162
- end
163
-
164
- def view_label
165
- case @view
166
- when :all then "ALL"
167
- when :failed then "\e[31mFAILED\e[0m"
168
- when :completed then "\e[32mCOMPLETED\e[0m"
169
- when :pending then "\e[33mPENDING\e[0m"
170
- end
171
- end
172
-
173
- def full_draw
174
- print "\e[?25l"
175
- print "\e[2J\e[H"
176
- draw_screen
177
- end
178
-
179
- def column_widths
180
- w = terminal_width
181
- # Fixed columns: prefix(2) + ID(8) + Status(14) + Created(12) = 36
182
- remaining = [w - 36, 10].max
183
- # Job gets 65% of remaining, Queue gets 35%
184
- job_w = [remaining * 65 / 100, 6].max
185
- queue_w = [remaining - job_w, 4].max
186
- { id: 8, job: job_w, queue: queue_w, status: 14, created: 12 }
187
- end
188
-
189
- def draw_screen
190
- if @detail_job
191
- draw_detail_screen
192
- else
193
- draw_list_screen
194
- end
195
- end
196
-
197
- def draw_list_screen
198
- print "\e[H" # cursor home, no clear
199
- w = terminal_width
200
- rows = visible_rows
201
- cols = column_widths
202
-
203
- # Header
204
- puts truncate("\e[1;36m sqdash \e[0m\e[36m Solid Queue Dashboard v#{Sqdash::VERSION}\e[0m", w) + "\e[K"
205
- puts "\e[90m#{"─" * w}\e[0m"
206
-
207
- # Stats bar
208
- total = Models::Job.count
209
- completed = Models::Job.where.not(finished_at: nil).count
210
- failed = @failed_ids.length
211
- pending = Models::ReadyExecution.count
212
- sort_label = "#{@sort_column == :id ? "ID" : "Created"} #{@sort_dir == :asc ? "↑" : "↓"}"
213
- stats = " \e[1mTotal:\e[0m #{total} \e[32m✓ #{completed}\e[0m \e[31m✗ #{failed}\e[0m \e[33m◌ #{pending}\e[0m │ View: #{view_label} │ Sort: #{sort_label} │ Showing: #{@jobs.length}"
214
- puts truncate(stats, w) + "\e[K"
215
-
216
- # Filter / Command bar
217
- if @command_mode
218
- print "\e[?25h"
219
- hint = command_autocomplete_hint
220
- puts truncate(" \e[1;35m:\e[0m #{@command_text}\e[90m#{hint}\e[0m \e[90m<Tab> complete <Enter> run <Esc> cancel\e[0m", w) + "\e[K"
221
- elsif @filter_mode
222
- print "\e[?25h" # show cursor in filter mode
223
- hint = autocomplete_hint
224
- puts truncate(" \e[1;33m/\e[0m #{@filter_text}\e[90m#{hint}\e[0m \e[90m<Tab> complete <Esc> cancel\e[0m", w) + "\e[K"
225
- elsif @filter_text.length > 0
226
- puts truncate(" \e[33m/#{@filter_text}\e[0m \e[90m(/ to edit, Esc to clear)\e[0m", w) + "\e[K"
227
- else
228
- puts "\e[K"
229
- end
230
-
231
- puts "\e[90m#{"─" * w}\e[0m"
232
-
233
- # Column headers
234
- puts truncate("\e[1m #{"ID".ljust(cols[:id])}#{"Job".ljust(cols[:job])}#{"Queue".ljust(cols[:queue])}#{"Status".ljust(cols[:status])}Created\e[0m", w) + "\e[K"
235
-
236
- # Job list
237
- visible_jobs = @jobs[@scroll_offset, rows] || []
238
-
239
- visible_jobs.each_with_index do |job, i|
240
- actual_index = @scroll_offset + i
241
- status = job_status(job)
242
- is_selected = actual_index == @selected
243
- created = job.created_at&.strftime("%m/%d %H:%M") || "—"
244
-
245
- line = "#{job.id.to_s.ljust(cols[:id])}#{job.class_name[0, cols[:job] - 1].ljust(cols[:job])}#{job.queue_name[0, cols[:queue] - 1].ljust(cols[:queue])}#{status_text(status)} #{created}"
246
-
247
- if is_selected
248
- puts truncate("\e[7m▸ #{line}\e[0m", w) + "\e[K"
249
- else
250
- puts truncate(" #{line}", w) + "\e[K"
251
- end
252
- end
253
-
254
- # Clear remaining rows
255
- (rows - visible_jobs.length).times { puts "\e[K" }
256
-
257
- # Scrollbar hint
258
- puts "\e[90m#{"─" * w}\e[0m"
259
-
260
- # Message or footer
261
- if @message
262
- puts " \e[1;32m#{@message}\e[0m\e[K"
263
- @message = nil
264
- else
265
- puts truncate(" \e[90m↑↓ Navigate Enter Detail /Filter :Command r Retry d Discard q Quit\e[0m", w) + "\e[K"
266
- end
267
-
268
- # Position info
269
- if @jobs.length > 0
270
- pos = "#{@selected + 1}/#{@jobs.length}"
271
- print "\e[#{terminal_height};#{w - pos.length}H\e[90m#{pos}\e[0m"
272
- end
273
- end
274
-
275
- def handle_input
276
- @saved_stty = `stty -g`.chomp
277
- system("stty", "-echo", "-icanon", "min", "1")
278
- loop do
279
- if @needs_redraw
280
- @needs_redraw = false
281
- adjust_scroll
282
- full_draw
283
- end
284
-
285
- key = read_key
286
-
287
- unless key
288
- # No input — auto-refresh data on idle
289
- if @detail_job
290
- @detail_job.reload
291
- else
292
- load_data
293
- end
294
- draw_screen
295
- next
296
- end
297
-
298
- if @detail_job
299
- handle_detail_input(key)
300
- elsif @command_mode
301
- handle_command_input(key)
302
- elsif @filter_mode
303
- handle_filter_input(key)
304
- else
305
- handle_normal_input(key)
306
- end
307
-
308
- draw_screen
309
- end
310
- ensure
311
- system("stty", @saved_stty) if @saved_stty
312
- end
313
-
314
- def read_key
315
- ready = IO.select([$stdin], nil, nil, 1)
316
- return nil unless ready
317
-
318
- $stdin.getc
319
- end
320
-
321
- def handle_filter_input(key)
322
- case key
323
- when "\r", "\n" # Enter — confirm filter
324
- @filter_mode = false
325
- print "\e[?25l"
326
- load_data
327
- when "\e" # Escape — cancel filter (drain arrow key bytes)
328
- $stdin.read_nonblock(2) rescue nil
329
- @filter_mode = false
330
- @filter_text = ""
331
- print "\e[?25l"
332
- load_data
333
- when "\t" # Tab — autocomplete
334
- autocomplete_filter
335
- when "\u007F", "\b" # Backspace
336
- @filter_text = @filter_text[0..-2]
337
- load_data
338
- else
339
- if key.match?(/[[:print:]]/)
340
- @filter_text += key
341
- load_data
342
- end
343
- end
344
- end
345
-
346
- def autocomplete_filter
347
- return if @filter_text.empty?
348
-
349
- query = @filter_text.downcase
350
-
351
- # Collect all completable values
352
- candidates = (
353
- Models::Job.distinct.pluck(:class_name) +
354
- Models::Job.distinct.pluck(:queue_name)
355
- ).uniq
356
-
357
- matches = candidates.select { |c| c.downcase.start_with?(query) }
358
-
359
- if matches.length == 1
360
- # Exact single match — complete it
361
- @filter_text = matches.first
362
- elsif matches.length > 1
363
- # Multiple matches — complete to common prefix
364
- @filter_text = common_prefix(matches)
365
- end
366
-
367
- load_data
368
- end
369
-
370
- def autocomplete_hint
371
- return "" if @filter_text.empty?
372
-
373
- query = @filter_text.downcase
374
- candidates = (
375
- Models::Job.distinct.pluck(:class_name) +
376
- Models::Job.distinct.pluck(:queue_name)
377
- ).uniq
378
-
379
- matches = candidates.select { |c| c.downcase.start_with?(query) }
380
-
381
- if matches.length == 1
382
- matches.first[@filter_text.length..]
383
- elsif matches.length > 1
384
- prefix = common_prefix(matches)
385
- remaining = prefix[@filter_text.length..] || ""
386
- remaining + " (#{matches.length} matches)"
387
- else
388
- " (no matches)"
389
- end
390
- end
391
-
392
- def common_prefix(strings)
393
- return "" if strings.empty?
394
-
395
- prefix = strings.first
396
- strings.each do |s|
397
- prefix = prefix[0...prefix.length].chars.take_while.with_index { |c, i| s[i]&.downcase == c.downcase }.join
398
- end
399
- prefix
400
- end
401
-
402
- def handle_normal_input(key)
403
- case key
404
- when "\e"
405
- next_chars = $stdin.read_nonblock(2) rescue nil
406
- case next_chars
407
- when "[A" # up
408
- @selected = [0, @selected - 1].max
409
- adjust_scroll
410
- when "[B" # down
411
- @selected = [@jobs.length - 1, @selected + 1].min
412
- adjust_scroll
413
- when nil # bare Escape — clear active filter
414
- if @filter_text.length > 0
415
- @filter_text = ""
416
- load_data
417
- end
418
- end
419
- when "q"
420
- throw(:quit)
421
- when "/"
422
- @filter_mode = true
423
- @filter_text = ""
424
- when ":"
425
- @command_mode = true
426
- @command_text = ""
427
- when "r"
428
- retry_selected
429
- when "d"
430
- discard_selected
431
- when "\r", "\n"
432
- show_detail
433
- when " "
434
- load_data
435
- end
436
- end
437
-
438
- def switch_view(view)
439
- @view = view
440
- @selected = 0
441
- @scroll_offset = 0
442
- load_data
443
- end
444
-
445
- def handle_command_input(key)
446
- case key
447
- when "\r", "\n" # Enter — execute command
448
- execute_command
449
- @command_mode = false
450
- @command_text = ""
451
- print "\e[?25l"
452
- when "\e" # Escape — cancel (drain arrow key bytes)
453
- $stdin.read_nonblock(2) rescue nil
454
- @command_mode = false
455
- @command_text = ""
456
- print "\e[?25l"
457
- when "\t" # Tab — autocomplete
458
- autocomplete_command
459
- when "\u007F", "\b" # Backspace
460
- @command_text = @command_text[0..-2]
461
- else
462
- if key.match?(/[[:print:]]/)
463
- @command_text += key
464
- end
465
- end
466
- end
467
-
468
- def execute_command
469
- parts = @command_text.strip.split(/\s+/)
470
- return if parts.empty?
471
-
472
- case parts[0]
473
- when "sort"
474
- field = parts[1] || "created"
475
- direction = parts[2] || "desc"
476
- case field
477
- when "created"
478
- @sort_column = :created_at
479
- when "id"
480
- @sort_column = :id
481
- else
482
- @message = "Unknown sort field: #{field}"
483
- return
484
- end
485
- case direction
486
- when "asc" then @sort_dir = :asc
487
- when "desc" then @sort_dir = :desc
488
- else
489
- @message = "Unknown sort direction: #{direction}"
490
- return
491
- end
492
- @selected = 0
493
- @scroll_offset = 0
494
- load_data
495
- when "view"
496
- target = parts[1] || "all"
497
- case target
498
- when "all" then switch_view(:all)
499
- when "failed" then switch_view(:failed)
500
- when "completed" then switch_view(:completed)
501
- when "pending" then switch_view(:pending)
502
- else
503
- @message = "Unknown view: #{target}"
504
- end
505
- else
506
- @message = "Unknown command: #{parts[0]}"
507
- end
508
- end
509
-
510
- def autocomplete_command
511
- return if @command_text.empty?
512
-
513
- parts = @command_text.strip.split(/\s+/)
514
- # If text ends with space, we're starting a new word
515
- completing_new_word = @command_text.end_with?(" ")
516
-
517
- if completing_new_word
518
- case parts.length
519
- when 1
520
- # After first word + space, complete second word
521
- subtree = COMMANDS[parts[0]]
522
- return unless subtree.is_a?(Hash)
523
- completed = complete_word("", subtree.keys)
524
- @command_text = "#{parts[0]} #{completed}" if completed
525
- when 2
526
- # After second word + space, complete third word
527
- subtree = COMMANDS.dig(parts[0], parts[1])
528
- return unless subtree.is_a?(Array) && subtree.any?
529
- completed = complete_word("", subtree)
530
- @command_text = "#{parts[0]} #{parts[1]} #{completed}" if completed
531
- end
532
- else
533
- case parts.length
534
- when 1
535
- completed = complete_word(parts[0], COMMANDS.keys)
536
- @command_text = completed if completed
537
- when 2
538
- subtree = COMMANDS[parts[0]]
539
- return unless subtree.is_a?(Hash)
540
- completed = complete_word(parts[1], subtree.keys)
541
- @command_text = "#{parts[0]} #{completed}" if completed
542
- when 3
543
- subtree = COMMANDS.dig(parts[0], parts[1])
544
- return unless subtree.is_a?(Array) && subtree.any?
545
- completed = complete_word(parts[2], subtree)
546
- @command_text = "#{parts[0]} #{parts[1]} #{completed}" if completed
547
- end
548
- end
549
- end
550
-
551
- def complete_word(partial, candidates)
552
- matches = candidates.select { |c| c.downcase.start_with?(partial.downcase) }
553
- if matches.length == 1
554
- matches.first
555
- elsif matches.length > 1
556
- prefix = common_prefix(matches)
557
- # Only return if the prefix actually advances beyond what's typed
558
- prefix.length > partial.length ? prefix : nil
559
- end
560
- end
561
-
562
- def command_autocomplete_hint
563
- return "" if @command_text.empty?
564
-
565
- parts = @command_text.strip.split(/\s+/)
566
- completing_new_word = @command_text.end_with?(" ")
567
-
568
- if completing_new_word
569
- case parts.length
570
- when 1
571
- subtree = COMMANDS[parts[0]]
572
- return "" unless subtree.is_a?(Hash)
573
- hint_for_candidates("", subtree.keys)
574
- when 2
575
- subtree = COMMANDS.dig(parts[0], parts[1])
576
- return "" unless subtree.is_a?(Array) && subtree.any?
577
- hint_for_candidates("", subtree)
578
- else
579
- ""
580
- end
581
- else
582
- case parts.length
583
- when 1
584
- hint_for_candidates(parts[0], COMMANDS.keys)
585
- when 2
586
- subtree = COMMANDS[parts[0]]
587
- return "" unless subtree.is_a?(Hash)
588
- hint_for_candidates(parts[1], subtree.keys)
589
- when 3
590
- subtree = COMMANDS.dig(parts[0], parts[1])
591
- return "" unless subtree.is_a?(Array) && subtree.any?
592
- hint_for_candidates(parts[2], subtree)
593
- else
594
- ""
595
- end
596
- end
597
- end
598
-
599
- def hint_for_candidates(partial, candidates)
600
- matches = candidates.select { |c| c.downcase.start_with?(partial.downcase) }
601
- if matches.length == 1
602
- matches.first[partial.length..]
603
- elsif matches.length > 1
604
- prefix = common_prefix(matches)
605
- remaining = prefix[partial.length..] || ""
606
- remaining + " (#{matches.map { |m| m }.join("|")})"
607
- else
608
- " (no matches)"
609
- end
610
- end
611
-
612
- def show_detail
613
- return if @jobs.empty?
614
- @detail_job = @jobs[@selected]
615
- @detail_scroll = 0
616
- full_draw
617
- end
618
-
619
- def build_detail_lines(job)
620
- lines = []
621
-
622
- lines << "\e[1mClass:\e[0m #{job.class_name}"
623
- lines << "\e[1mQueue:\e[0m #{job.queue_name}"
624
- lines << "\e[1mPriority:\e[0m #{job.priority || "—"}"
625
- lines << "\e[1mActive Job:\e[0m #{job.active_job_id || "—"}"
626
- lines << ""
627
-
628
- status = job_status(job)
629
- lines << "\e[1mStatus:\e[0m #{status_text(status)}"
630
- lines << ""
631
-
632
- lines << "\e[1mCreated:\e[0m #{job.created_at || "—"}"
633
- lines << "\e[1mScheduled:\e[0m #{job.scheduled_at || "—"}"
634
- lines << "\e[1mFinished:\e[0m #{job.finished_at || "—"}"
635
- lines << ""
636
-
637
- lines << "\e[1mArguments:\e[0m"
638
- begin
639
- args = JSON.parse(job.arguments)
640
- JSON.pretty_generate(args).each_line { |l| lines << " #{l.chomp}" }
641
- rescue JSON::ParserError
642
- lines << " #{job.arguments}"
643
- end
644
-
645
- if status == :failed && job.failed_execution
646
- lines << ""
647
- lines << "\e[1;31mError:\e[0m"
648
- error_text = job.failed_execution.error || "No error message"
649
- error_text.each_line { |l| lines << " #{l.chomp}" }
650
- end
651
-
652
- lines
653
- end
654
-
655
- def draw_detail_screen
656
- print "\e[H"
657
- w = terminal_width
658
- rows = terminal_height
659
-
660
- # Header
661
- puts truncate("\e[1;36m sqdash \e[0m\e[36m Job ##{@detail_job.id}\e[0m", w) + "\e[K"
662
- puts "\e[90m#{"─" * w}\e[0m"
663
-
664
- # Content area: rows - 4 (header, separator, separator, footer)
665
- content_rows = rows - 4
666
- lines = build_detail_lines(@detail_job)
667
-
668
- # Clamp scroll
669
- max_scroll = [lines.length - content_rows, 0].max
670
- @detail_scroll = [[@detail_scroll, max_scroll].min, 0].max
671
-
672
- visible = lines[@detail_scroll, content_rows] || []
673
- visible.each { |line| puts truncate(" #{line}", w) + "\e[K" }
674
-
675
- # Clear remaining rows
676
- (content_rows - visible.length).times { puts "\e[K" }
677
-
678
- puts "\e[90m#{"─" * w}\e[0m"
679
-
680
- if @message
681
- puts " \e[1;32m#{@message}\e[0m\e[K"
682
- @message = nil
683
- else
684
- puts truncate(" \e[90mEsc Back ↑↓ Scroll r Retry d Discard q Quit\e[0m", w) + "\e[K"
685
- end
686
- end
687
-
688
- def handle_detail_input(key)
689
- case key
690
- when "\e"
691
- next_chars = $stdin.read_nonblock(2) rescue nil
692
- case next_chars
693
- when "[A" # up
694
- @detail_scroll = [@detail_scroll - 1, 0].max
695
- when "[B" # down
696
- @detail_scroll += 1
697
- when nil # bare Escape — back to list
698
- @detail_job = nil
699
- full_draw
700
- end
701
- when "\u007F", "\b" # Backspace — back to list
702
- @detail_job = nil
703
- full_draw
704
- when "r"
705
- failed = Models::FailedExecution.find_by(job_id: @detail_job.id)
706
- if failed
707
- failed.retry!
708
- @message = "Retried job #{@detail_job.id} (#{@detail_job.class_name})"
709
- @detail_job.reload
710
- load_data
711
- else
712
- @message = "Job #{@detail_job.id} is not failed"
713
- end
714
- when "d"
715
- failed = Models::FailedExecution.find_by(job_id: @detail_job.id)
716
- if failed
717
- failed.discard!
718
- @message = "Discarded job #{@detail_job.id} (#{@detail_job.class_name})"
719
- @detail_job = nil
720
- load_data
721
- full_draw
722
- else
723
- @message = "Job #{@detail_job.id} is not failed"
724
- end
725
- when "q"
726
- throw(:quit)
727
- end
728
- end
729
-
730
- def retry_selected
731
- job = @jobs[@selected]
732
- return unless job
733
-
734
- failed = Models::FailedExecution.find_by(job_id: job.id)
735
- unless failed
736
- @message = "Job #{job.id} is not failed"
737
- return
738
- end
739
-
740
- failed.retry!
741
- @message = "Retried job #{job.id} (#{job.class_name})"
742
- load_data
743
- end
744
-
745
- def discard_selected
746
- job = @jobs[@selected]
747
- return unless job
748
-
749
- failed = Models::FailedExecution.find_by(job_id: job.id)
750
- unless failed
751
- @message = "Job #{job.id} is not failed"
752
- return
753
- end
754
-
755
- failed.discard!
756
- @message = "Discarded job #{job.id} (#{job.class_name})"
757
- load_data
758
- end
759
198
  end
760
199
  end