solid_queue_tui 0.1.1 → 0.1.3

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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/exe/sqtui +37 -1
  3. data/lib/solid_queue_tui/actions/discard_job.rb +4 -21
  4. data/lib/solid_queue_tui/actions/discard_scheduled_job.rb +5 -21
  5. data/lib/solid_queue_tui/actions/dispatch_scheduled_job.rb +4 -23
  6. data/lib/solid_queue_tui/actions/enqueue_recurring_task.rb +12 -0
  7. data/lib/solid_queue_tui/actions/retry_job.rb +14 -59
  8. data/lib/solid_queue_tui/actions/toggle_queue_pause.rb +19 -0
  9. data/lib/solid_queue_tui/application.rb +109 -21
  10. data/lib/solid_queue_tui/cli.rb +15 -8
  11. data/lib/solid_queue_tui/components/header.rb +26 -27
  12. data/lib/solid_queue_tui/components/help_bar.rb +1 -0
  13. data/lib/solid_queue_tui/components/job_table.rb +13 -2
  14. data/lib/solid_queue_tui/data/failed_query.rb +37 -91
  15. data/lib/solid_queue_tui/data/jobs_query.rb +119 -121
  16. data/lib/solid_queue_tui/data/processes_query.rb +32 -33
  17. data/lib/solid_queue_tui/data/queues_query.rb +6 -15
  18. data/lib/solid_queue_tui/data/recurring_tasks_query.rb +36 -0
  19. data/lib/solid_queue_tui/data/stats.rb +9 -27
  20. data/lib/solid_queue_tui/formatting_helpers.rb +63 -0
  21. data/lib/solid_queue_tui/railtie.rb +9 -0
  22. data/lib/solid_queue_tui/version.rb +1 -1
  23. data/lib/solid_queue_tui/views/blocked_view.rb +85 -74
  24. data/lib/solid_queue_tui/views/concerns/confirmable.rb +53 -0
  25. data/lib/solid_queue_tui/views/concerns/filterable.rb +128 -0
  26. data/lib/solid_queue_tui/views/concerns/paginatable.rb +79 -0
  27. data/lib/solid_queue_tui/views/dashboard_view.rb +4 -5
  28. data/lib/solid_queue_tui/views/failed_view.rb +65 -179
  29. data/lib/solid_queue_tui/views/finished_view.rb +33 -114
  30. data/lib/solid_queue_tui/views/in_progress_view.rb +85 -69
  31. data/lib/solid_queue_tui/views/job_detail_view.rb +179 -31
  32. data/lib/solid_queue_tui/views/processes_view.rb +2 -24
  33. data/lib/solid_queue_tui/views/queues_view.rb +250 -30
  34. data/lib/solid_queue_tui/views/recurring_tasks_view.rb +155 -0
  35. data/lib/solid_queue_tui/views/scheduled_view.rb +69 -107
  36. data/lib/solid_queue_tui.rb +18 -4
  37. data/lib/tasks/solid_queue_tui.rake +8 -0
  38. metadata +20 -25
  39. data/lib/solid_queue_tui/connection.rb +0 -58
@@ -3,21 +3,95 @@
3
3
  module SolidQueueTui
4
4
  module Views
5
5
  class InProgressView
6
+ include Filterable
7
+ include Paginatable
8
+ include FormattingHelpers
9
+
6
10
  def initialize(tui)
7
11
  @tui = tui
8
- @table_state = RatatuiRuby::TableState.new(nil)
9
- @table_state.select(0)
10
- @selected_row = 0
11
- @jobs = []
12
+ init_pagination
13
+ init_filter
12
14
  end
13
15
 
14
16
  def update(jobs:)
15
- @jobs = jobs
16
- @selected_row = @selected_row.clamp(0, [@jobs.size - 1, 0].max)
17
- @table_state.select(@selected_row)
17
+ update_items(jobs)
18
+ end
19
+
20
+ def append(jobs:)
21
+ append_items(jobs)
18
22
  end
19
23
 
20
24
  def render(frame, area)
25
+ if filter_mode?
26
+ filter_area, content_area = @tui.layout_split(
27
+ area,
28
+ direction: :vertical,
29
+ constraints: [
30
+ @tui.constraint_length(3),
31
+ @tui.constraint_fill(1)
32
+ ]
33
+ )
34
+ render_filter_input(frame, filter_area)
35
+ render_table(frame, content_area)
36
+ else
37
+ render_table(frame, area)
38
+ end
39
+ end
40
+
41
+ def handle_input(event)
42
+ if filter_mode?
43
+ handle_filter_input(event)
44
+ else
45
+ handle_normal_input(event)
46
+ end
47
+ end
48
+
49
+ def bindings
50
+ if filter_mode?
51
+ filter_bindings
52
+ else
53
+ [
54
+ { key: "j/k", action: "Navigate" },
55
+ { key: "Enter", action: "Detail" },
56
+ { key: "/", action: "Filter" },
57
+ { key: "G/g", action: "Bottom/Top" }
58
+ ]
59
+ end
60
+ end
61
+
62
+ def capturing_input?
63
+ filter_mode?
64
+ end
65
+
66
+ def breadcrumb
67
+ @filters.empty? ? "in-progress" : "in-progress:filtered"
68
+ end
69
+
70
+ private
71
+
72
+ def handle_normal_input(event)
73
+ case event
74
+ in { type: :key, code: "j" } | { type: :key, code: "up" }
75
+ move_selection(-1)
76
+ in { type: :key, code: "k" } | { type: :key, code: "down" }
77
+ result = move_selection(1)
78
+ return :load_more if result == :load_more
79
+ nil
80
+ in { type: :key, code: "g" }
81
+ jump_to_top
82
+ in { type: :key, code: "G" }
83
+ jump_to_bottom
84
+ in { type: :key, code: "/" }
85
+ enter_filter_mode
86
+ nil
87
+ in { type: :key, code: "esc" }
88
+ clear_filter
89
+ else
90
+ nil
91
+ end
92
+ end
93
+
94
+ def render_table(frame, area)
21
95
  columns = [
22
96
  { key: :id, label: "ID", width: 8 },
23
97
  { key: :queue_name, label: "QUEUE", width: 14 },
@@ -27,7 +101,7 @@ module SolidQueueTui
27
101
  { key: :started_at, label: "STARTED", width: 12 }
28
102
  ]
29
103
 
30
- rows = @jobs.map do |job|
104
+ rows = items.map do |job|
31
105
  {
32
106
  id: job.id,
33
107
  queue_name: job.queue_name,
@@ -40,75 +114,17 @@ module SolidQueueTui
40
114
 
41
115
  table = Components::JobTable.new(
42
116
  @tui,
43
- title: "In Progress",
117
+ title: filter_title("In Progress"),
44
118
  columns: columns,
45
119
  rows: rows,
46
120
  selected_row: @selected_row,
47
- empty_message: "No jobs currently in progress"
121
+ total_count: @total_count,
122
+ empty_message: @filters.empty? ? "No jobs currently in progress" : "No in-progress jobs matching filters"
48
123
  )
49
124
 
50
125
  table.render(frame, area, @table_state)
51
126
  end
52
127
 
53
- def handle_input(event)
54
- case event
55
- in { type: :key, code: "j" } | { type: :key, code: "up" }
56
- move_selection(-1)
57
- in { type: :key, code: "k" } | { type: :key, code: "down" }
58
- move_selection(1)
59
- in { type: :key, code: "g" }
60
- jump_to_top
61
- in { type: :key, code: "G" }
62
- jump_to_bottom
63
- else
64
- nil
65
- end
66
- end
67
-
68
- def selected_item
69
- return nil if @jobs.empty? || @selected_row >= @jobs.size
70
- @jobs[@selected_row]
71
- end
72
-
73
- def bindings
74
- [
75
- { key: "j/k", action: "Navigate" },
76
- { key: "Enter", action: "Detail" },
77
- { key: "G/g", action: "Bottom/Top" }
78
- ]
79
- end
80
-
81
- def breadcrumb = "in-progress"
82
-
83
- private
84
-
85
- def move_selection(delta)
86
- return if @jobs.empty?
87
- @selected_row = (@selected_row + delta).clamp(0, @jobs.size - 1)
88
- @table_state.select(@selected_row)
89
- end
90
-
91
- def jump_to_top
92
- @selected_row = 0
93
- @table_state.select(0)
94
- end
95
-
96
- def jump_to_bottom
97
- return if @jobs.empty?
98
- @selected_row = @jobs.size - 1
99
- @table_state.select(@selected_row)
100
- end
101
-
102
- def time_ago(time)
103
- return "n/a" unless time
104
- seconds = (Time.now.utc - time).to_i
105
- case seconds
106
- when 0..59 then "#{seconds}s ago"
107
- when 60..3599 then "#{seconds / 60}m ago"
108
- when 3600..86399 then "#{seconds / 3600}h ago"
109
- else "#{seconds / 86400}d ago"
110
- end
111
- end
112
128
  end
113
129
  end
114
130
  end
@@ -3,16 +3,24 @@
3
3
  module SolidQueueTui
4
4
  module Views
5
5
  class JobDetailView
6
+ include Confirmable
7
+ include FormattingHelpers
8
+
6
9
  def initialize(tui)
7
10
  @tui = tui
8
11
  @job = nil
9
12
  @failed_job = nil
13
+ @process = nil
14
+ @running_jobs = []
10
15
  @scroll_offset = 0
16
+ init_confirm
11
17
  end
12
18
 
13
- def show(job: nil, failed_job: nil)
19
+ def show(job: nil, failed_job: nil, process: nil, running_jobs: [])
14
20
  @job = job
15
21
  @failed_job = failed_job
22
+ @process = process
23
+ @running_jobs = running_jobs
16
24
  @scroll_offset = 0
17
25
  @active = true
18
26
  end
@@ -21,6 +29,9 @@ module SolidQueueTui
21
29
  @active = false
22
30
  @job = nil
23
31
  @failed_job = nil
32
+ @process = nil
33
+ @running_jobs = []
34
+ @confirm_action = nil
24
35
  end
25
36
 
26
37
  def active? = @active
@@ -35,12 +46,60 @@ module SolidQueueTui
35
46
 
36
47
  if @failed_job
37
48
  render_failed_detail(frame, inner)
49
+ elsif @process
50
+ render_process_detail(frame, inner)
38
51
  elsif @job
39
52
  render_job_detail(frame, inner)
40
53
  end
54
+
55
+ render_confirm_popup(frame, area) if confirm_mode?
41
56
  end
42
57
 
43
58
  def handle_input(event)
59
+ if confirm_mode?
60
+ handle_confirm_input(event)
61
+ else
62
+ handle_normal_input(event)
63
+ end
64
+ end
65
+
66
+ def bindings
67
+ if confirm_mode?
68
+ confirm_bindings
69
+ else
70
+ bindings = [
71
+ { key: "Esc", action: "Close" },
72
+ { key: "j/k", action: "Scroll" }
73
+ ]
74
+ if @failed_job
75
+ bindings += [
76
+ { key: "R", action: "Retry" },
77
+ { key: "D", action: "Discard" }
78
+ ]
79
+ end
80
+ bindings
81
+ end
82
+ end
83
+
84
+ def capturing_input?
85
+ confirm_mode?
86
+ end
87
+
88
+ def breadcrumb
89
+ if @failed_job
90
+ "failed:#{@failed_job.job_id}"
91
+ elsif @process
92
+ "process:#{@process.id}"
93
+ elsif @job
94
+ "jobs:#{@job.id}"
95
+ else
96
+ "detail"
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ def handle_normal_input(event)
44
103
  case event
45
104
  in { type: :key, code: "esc" } | { type: :key, code: "q" }
46
105
  hide
@@ -52,48 +111,38 @@ module SolidQueueTui
52
111
  @scroll_offset += 1
53
112
  nil
54
113
  in { type: :key, code: "R" }
55
- if @failed_job
56
- Actions::RetryJob.call(@failed_job.id)
57
- hide
58
- :refresh
59
- end
114
+ @confirm_action = :retry if @failed_job
115
+ nil
60
116
  in { type: :key, code: "D" }
61
- if @failed_job
62
- Actions::DiscardJob.call(@failed_job.id)
63
- hide
64
- :refresh
65
- end
117
+ @confirm_action = :discard if @failed_job
118
+ nil
66
119
  else
67
120
  nil
68
121
  end
69
122
  end
70
123
 
71
- def bindings
72
- bindings = [
73
- { key: "Esc", action: "Close" },
74
- { key: "j/k", action: "Scroll" }
75
- ]
76
- if @failed_job
77
- bindings += [
78
- { key: "R", action: "Retry" },
79
- { key: "D", action: "Discard" }
80
- ]
124
+ def confirm_message
125
+ case @confirm_action
126
+ when :retry
127
+ "Retry job ##{@failed_job&.job_id} (#{@failed_job&.class_name})? [y/n]"
128
+ when :discard
129
+ "Discard job ##{@failed_job&.job_id} (#{@failed_job&.class_name})? This cannot be undone. [y/n]"
81
130
  end
82
- bindings
83
131
  end
84
132
 
85
- def breadcrumb
86
- if @failed_job
87
- "failed:#{@failed_job.job_id}"
88
- elsif @job
89
- "jobs:#{@job.id}"
90
- else
91
- "detail"
133
+ def execute_confirm_action(action)
134
+ case action
135
+ when :retry
136
+ Actions::RetryJob.call(@failed_job.id)
137
+ hide
138
+ :refresh
139
+ when :discard
140
+ Actions::DiscardJob.call(@failed_job.id)
141
+ hide
142
+ :refresh
92
143
  end
93
144
  end
94
145
 
95
- private
96
-
97
146
  def render_failed_detail(frame, area)
98
147
  lines = []
99
148
 
@@ -202,6 +251,104 @@ module SolidQueueTui
202
251
  )
203
252
  end
204
253
 
254
+ def render_process_detail(frame, area)
255
+ lines = []
256
+
257
+ # Section 1: Process Info
258
+ lines << section_header("Process Info")
259
+ lines << detail_line("ID", @process.id.to_s)
260
+ lines << detail_line("Kind", @process.kind)
261
+ lines << detail_line("PID", @process.pid.to_s)
262
+ lines << detail_line("Hostname", @process.hostname || "n/a")
263
+ lines << detail_line("Name", @process.name || "n/a")
264
+ lines << empty_line
265
+
266
+ # Section 2: Status
267
+ lines << section_header("Status")
268
+ alive = @process.alive?
269
+ lines << @tui.text_line(spans: [
270
+ @tui.text_span(content: " #{"Status".ljust(16)}", style: @tui.style(fg: :dark_gray)),
271
+ @tui.text_span(content: alive ? "alive" : "dead",
272
+ style: @tui.style(fg: alive ? :green : :red, modifiers: [:bold]))
273
+ ])
274
+ lines << detail_line("Last Heartbeat", time_ago(@process.last_heartbeat_at))
275
+ lines << detail_line("Uptime", format_duration(@process.uptime))
276
+ lines << detail_line("Created At", format_time(@process.created_at))
277
+ lines << empty_line
278
+
279
+ # Section 3: Configuration
280
+ lines << section_header("Configuration")
281
+ queues_str = Array(@process.queues).join(", ")
282
+ lines << detail_line("Queues", queues_str.empty? ? "n/a" : queues_str)
283
+ lines << detail_line("Threads", (@process.thread_count || "n/a").to_s)
284
+ if @process.metadata.is_a?(Hash) && @process.metadata["polling_interval"]
285
+ lines << detail_line("Poll Interval", "#{@process.metadata["polling_interval"]}s")
286
+ end
287
+ lines << detail_line("Supervisor ID", (@process.supervisor_id || "n/a").to_s)
288
+ lines << empty_line
289
+
290
+ # Section 4: Running Jobs (Workers only)
291
+ if @process.kind == "Worker"
292
+ lines << section_header("Running Jobs (#{@running_jobs.size})")
293
+ if @running_jobs.empty?
294
+ lines << @tui.text_line(spans: [
295
+ @tui.text_span(content: " Idle — no running jobs", style: @tui.style(fg: :dark_gray))
296
+ ])
297
+ else
298
+ lines << @tui.text_line(spans: [
299
+ @tui.text_span(
300
+ content: " #{"ID".ljust(8)}#{"CLASS".ljust(30)}#{"QUEUE".ljust(16)}STARTED",
301
+ style: @tui.style(fg: :cyan, modifiers: [:bold])
302
+ )
303
+ ])
304
+ @running_jobs.each do |rj|
305
+ lines << @tui.text_line(spans: [
306
+ @tui.text_span(content: " #{rj.job_id.to_s.ljust(8)}", style: @tui.style(fg: :white)),
307
+ @tui.text_span(content: rj.class_name.to_s.ljust(30), style: @tui.style(fg: :yellow)),
308
+ @tui.text_span(content: rj.queue_name.to_s.ljust(16), style: @tui.style(fg: :white)),
309
+ @tui.text_span(content: time_ago(rj.started_at), style: @tui.style(fg: :dark_gray))
310
+ ])
311
+ end
312
+ end
313
+ lines << empty_line
314
+ end
315
+
316
+ # Section 5: Raw Metadata
317
+ if @process.metadata.is_a?(Hash) && !@process.metadata.empty?
318
+ lines << section_header("Raw Metadata")
319
+ meta_str = JSON.pretty_generate(@process.metadata) rescue @process.metadata.to_s
320
+ meta_str.split("\n").each do |meta_line|
321
+ lines << @tui.text_line(spans: [
322
+ @tui.text_span(content: " #{meta_line}", style: @tui.style(fg: :white))
323
+ ])
324
+ end
325
+ end
326
+
327
+ visible_lines = lines.drop(@scroll_offset)
328
+
329
+ border_color = @process.alive? ? :green : :red
330
+ title_text = " #{@process.kind} ##{@process.id} — PID: #{@process.pid} "
331
+
332
+ frame.render_widget(
333
+ @tui.paragraph(
334
+ text: visible_lines,
335
+ block: @tui.block(
336
+ title: title_text,
337
+ title_style: @tui.style(fg: border_color, modifiers: [:bold]),
338
+ titles: [
339
+ { content: " Esc:Close j/k:Scroll ",
340
+ position: :bottom, alignment: :right }
341
+ ],
342
+ borders: [:all],
343
+ border_type: :rounded,
344
+ border_style: @tui.style(fg: border_color),
345
+ style: @tui.style(fg: :white)
346
+ )
347
+ ),
348
+ area
349
+ )
350
+ end
351
+
205
352
  def section_header(title)
206
353
  @tui.text_line(spans: [
207
354
  @tui.text_span(content: " ── #{title} ", style: @tui.style(fg: :cyan, modifiers: [:bold]))
@@ -221,6 +368,7 @@ module SolidQueueTui
221
368
  ])
222
369
  end
223
370
 
371
+ # Override: includes UTC suffix for detail view precision
224
372
  def format_time(time)
225
373
  return "n/a" unless time
226
374
  time.strftime("%Y-%m-%d %H:%M:%S UTC")
@@ -3,6 +3,8 @@
3
3
  module SolidQueueTui
4
4
  module Views
5
5
  class ProcessesView
6
+ include FormattingHelpers
7
+
6
8
  KIND_COLORS = {
7
9
  "Worker" => :green,
8
10
  "Dispatcher" => :yellow,
@@ -113,30 +115,6 @@ module SolidQueueTui
113
115
  @table_state.select(@selected_row)
114
116
  end
115
117
 
116
- def time_ago(time)
117
- return "n/a" unless time
118
- seconds = (Time.now.utc - time).to_i
119
- case seconds
120
- when 0..59 then "#{seconds}s ago"
121
- when 60..3599 then "#{seconds / 60}m ago"
122
- when 3600..86399 then "#{seconds / 3600}h ago"
123
- else "#{seconds / 86400}d ago"
124
- end
125
- end
126
-
127
- def format_duration(seconds)
128
- return "n/a" unless seconds
129
- seconds = seconds.to_i
130
- if seconds < 60
131
- "#{seconds}s"
132
- elsif seconds < 3600
133
- "#{seconds / 60}m #{seconds % 60}s"
134
- elsif seconds < 86400
135
- "#{seconds / 3600}h #{(seconds % 3600) / 60}m"
136
- else
137
- "#{seconds / 86400}d #{(seconds % 86400) / 3600}h"
138
- end
139
- end
140
118
  end
141
119
  end
142
120
  end