solid_queue_tui 0.1.1 → 0.1.2

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/exe/sqtui +20 -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 +73 -17
  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 +14 -2
  14. data/lib/solid_queue_tui/data/failed_query.rb +37 -91
  15. data/lib/solid_queue_tui/data/jobs_query.rb +90 -123
  16. data/lib/solid_queue_tui/data/processes_query.rb +10 -34
  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/railtie.rb +9 -0
  21. data/lib/solid_queue_tui/version.rb +1 -1
  22. data/lib/solid_queue_tui/views/blocked_view.rb +126 -46
  23. data/lib/solid_queue_tui/views/concerns/filterable.rb +128 -0
  24. data/lib/solid_queue_tui/views/dashboard_view.rb +2 -1
  25. data/lib/solid_queue_tui/views/failed_view.rb +62 -72
  26. data/lib/solid_queue_tui/views/finished_view.rb +54 -67
  27. data/lib/solid_queue_tui/views/in_progress_view.rb +124 -44
  28. data/lib/solid_queue_tui/views/queues_view.rb +119 -35
  29. data/lib/solid_queue_tui/views/recurring_tasks_view.rb +202 -0
  30. data/lib/solid_queue_tui/views/scheduled_view.rb +74 -8
  31. data/lib/solid_queue_tui.rb +15 -4
  32. data/lib/tasks/solid_queue_tui.rake +8 -0
  33. metadata +16 -24
  34. data/lib/solid_queue_tui/connection.rb +0 -58
@@ -3,67 +3,72 @@
3
3
  module SolidQueueTui
4
4
  module Views
5
5
  class BlockedView
6
+ include Filterable
7
+
8
+
9
+ LOAD_THRESHOLD = 10
10
+
6
11
  def initialize(tui)
7
12
  @tui = tui
8
13
  @table_state = RatatuiRuby::TableState.new(nil)
9
14
  @table_state.select(0)
10
15
  @selected_row = 0
11
16
  @jobs = []
17
+ @total_count = nil
18
+ @all_loaded = false
19
+ init_filter
12
20
  end
13
21
 
14
22
  def update(jobs:)
15
23
  @jobs = jobs
24
+ @all_loaded = jobs.size < SolidQueueTui.page_size
16
25
  @selected_row = @selected_row.clamp(0, [@jobs.size - 1, 0].max)
17
26
  @table_state.select(@selected_row)
18
27
  end
19
28
 
20
- def render(frame, area)
21
- columns = [
22
- { key: :id, label: "ID", width: 8 },
23
- { key: :queue_name, label: "QUEUE", width: 14 },
24
- { key: :class_name, label: "CLASS", width: :fill },
25
- { key: :priority, label: "PRI", width: 5 },
26
- { key: :concurrency_key, label: "CONCURRENCY KEY", width: :fill },
27
- { key: :expires_at, label: "EXPIRES AT", width: 20 },
28
- { key: :blocked_since, label: "BLOCKED SINCE", width: 14 }
29
- ]
29
+ def append(jobs:)
30
+ @jobs.concat(jobs)
31
+ @all_loaded = jobs.size < SolidQueueTui.page_size
32
+ end
30
33
 
31
- rows = @jobs.map do |job|
32
- {
33
- id: job.id,
34
- queue_name: job.queue_name,
35
- class_name: job.class_name,
36
- priority: job.priority,
37
- concurrency_key: job.concurrency_key || "n/a",
38
- expires_at: format_time(job.expires_at),
39
- blocked_since: time_ago(job.created_at)
40
- }
41
- end
34
+ def total_count=(count)
35
+ @total_count = count
36
+ end
42
37
 
43
- table = Components::JobTable.new(
44
- @tui,
45
- title: "Blocked",
46
- columns: columns,
47
- rows: rows,
48
- selected_row: @selected_row,
49
- empty_message: "No blocked jobs"
50
- )
38
+ def current_offset
39
+ @jobs.size
40
+ end
51
41
 
52
- table.render(frame, area, @table_state)
42
+ def reset_pagination!
43
+ @jobs = []
44
+ @total_count = nil
45
+ @all_loaded = false
46
+ @selected_row = 0
47
+ @table_state.select(0)
48
+ end
49
+
50
+ def render(frame, area)
51
+ if filter_mode?
52
+ filter_area, content_area = @tui.layout_split(
53
+ area,
54
+ direction: :vertical,
55
+ constraints: [
56
+ @tui.constraint_length(3),
57
+ @tui.constraint_fill(1)
58
+ ]
59
+ )
60
+ render_filter_input(frame, filter_area)
61
+ render_table(frame, content_area)
62
+ else
63
+ render_table(frame, area)
64
+ end
53
65
  end
54
66
 
55
67
  def handle_input(event)
56
- case event
57
- in { type: :key, code: "j" } | { type: :key, code: "up" }
58
- move_selection(-1)
59
- in { type: :key, code: "k" } | { type: :key, code: "down" }
60
- move_selection(1)
61
- in { type: :key, code: "g" }
62
- jump_to_top
63
- in { type: :key, code: "G" }
64
- jump_to_bottom
68
+ if filter_mode?
69
+ handle_filter_input(event)
65
70
  else
66
- nil
71
+ handle_normal_input(event)
67
72
  end
68
73
  end
69
74
 
@@ -73,21 +78,59 @@ module SolidQueueTui
73
78
  end
74
79
 
75
80
  def bindings
76
- [
77
- { key: "j/k", action: "Navigate" },
78
- { key: "Enter", action: "Detail" },
79
- { key: "G/g", action: "Bottom/Top" }
80
- ]
81
+ if filter_mode?
82
+ filter_bindings
83
+ else
84
+ [
85
+ { key: "j/k", action: "Navigate" },
86
+ { key: "Enter", action: "Detail" },
87
+ { key: "/", action: "Filter" },
88
+ { key: "G/g", action: "Bottom/Top" }
89
+ ]
90
+ end
91
+ end
92
+
93
+ def capturing_input?
94
+ filter_mode?
81
95
  end
82
96
 
83
- def breadcrumb = "blocked"
97
+ def breadcrumb
98
+ @filters.empty? ? "blocked" : "blocked:filtered"
99
+ end
84
100
 
85
101
  private
86
102
 
103
+ def needs_more?
104
+ !@all_loaded && @selected_row >= @jobs.size - LOAD_THRESHOLD
105
+ end
106
+
107
+ def handle_normal_input(event)
108
+ case event
109
+ in { type: :key, code: "j" } | { type: :key, code: "up" }
110
+ move_selection(-1)
111
+ in { type: :key, code: "k" } | { type: :key, code: "down" }
112
+ result = move_selection(1)
113
+ return :load_more if result == :load_more
114
+ nil
115
+ in { type: :key, code: "g" }
116
+ jump_to_top
117
+ in { type: :key, code: "G" }
118
+ jump_to_bottom
119
+ in { type: :key, code: "/" }
120
+ enter_filter_mode
121
+ nil
122
+ in { type: :key, code: "esc" }
123
+ clear_filter
124
+ else
125
+ nil
126
+ end
127
+ end
128
+
87
129
  def move_selection(delta)
88
130
  return if @jobs.empty?
89
131
  @selected_row = (@selected_row + delta).clamp(0, @jobs.size - 1)
90
132
  @table_state.select(@selected_row)
133
+ :load_more if needs_more?
91
134
  end
92
135
 
93
136
  def jump_to_top
@@ -99,6 +142,43 @@ module SolidQueueTui
99
142
  return if @jobs.empty?
100
143
  @selected_row = @jobs.size - 1
101
144
  @table_state.select(@selected_row)
145
+ return :load_more if needs_more?
146
+ end
147
+
148
+ def render_table(frame, area)
149
+ columns = [
150
+ { key: :id, label: "ID", width: 8 },
151
+ { key: :queue_name, label: "QUEUE", width: 14 },
152
+ { key: :class_name, label: "CLASS", width: :fill },
153
+ { key: :priority, label: "PRI", width: 5 },
154
+ { key: :concurrency_key, label: "CONCURRENCY KEY", width: :fill },
155
+ { key: :expires_at, label: "EXPIRES AT", width: 20 },
156
+ { key: :blocked_since, label: "BLOCKED SINCE", width: 14 }
157
+ ]
158
+
159
+ rows = @jobs.map do |job|
160
+ {
161
+ id: job.id,
162
+ queue_name: job.queue_name,
163
+ class_name: job.class_name,
164
+ priority: job.priority,
165
+ concurrency_key: job.concurrency_key || "n/a",
166
+ expires_at: format_time(job.expires_at),
167
+ blocked_since: time_ago(job.created_at)
168
+ }
169
+ end
170
+
171
+ table = Components::JobTable.new(
172
+ @tui,
173
+ title: filter_title("Blocked"),
174
+ columns: columns,
175
+ rows: rows,
176
+ selected_row: @selected_row,
177
+ total_count: @total_count,
178
+ empty_message: @filters.empty? ? "No blocked jobs" : "No blocked jobs matching filters"
179
+ )
180
+
181
+ table.render(frame, area, @table_state)
102
182
  end
103
183
 
104
184
  def format_time(time)
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module Views
5
+ module Filterable
6
+ FILTER_FIELDS = [
7
+ { key: :class_name, label: "Class" },
8
+ { key: :queue, label: "Queue" }
9
+ ].freeze
10
+
11
+ def init_filter
12
+ @filters = {}
13
+ @filter_mode = false
14
+ @filter_inputs = {}
15
+ @active_field = 0
16
+ end
17
+
18
+ def filters = @filters
19
+ def filter_mode? = @filter_mode
20
+
21
+ def handle_filter_input(event)
22
+ case event
23
+ in { type: :key, code: "enter" }
24
+ @filters = @filter_inputs.reject { |_, v| v.nil? || v.empty? }
25
+ @filter_mode = false
26
+ @selected_row = 0
27
+ @table_state.select(0)
28
+ :refresh
29
+ in { type: :key, code: "esc" }
30
+ @filter_mode = false
31
+ @filter_inputs = @filters.dup
32
+ nil
33
+ in { type: :key, code: "tab" }
34
+ @active_field = (@active_field + 1) % FILTER_FIELDS.size
35
+ nil
36
+ in { type: :key, code: "back_tab" }
37
+ @active_field = (@active_field - 1) % FILTER_FIELDS.size
38
+ nil
39
+ in { type: :key, code: "backspace" }
40
+ field_key = FILTER_FIELDS[@active_field][:key]
41
+ current = @filter_inputs[field_key] || ""
42
+ @filter_inputs[field_key] = current[0...-1]
43
+ nil
44
+ in { type: :key, code: /\A.\z/ => char }
45
+ field_key = FILTER_FIELDS[@active_field][:key]
46
+ @filter_inputs[field_key] = (@filter_inputs[field_key] || "") + char
47
+ nil
48
+ else
49
+ nil
50
+ end
51
+ end
52
+
53
+ def enter_filter_mode
54
+ @filter_mode = true
55
+ @filter_inputs = @filters.dup
56
+ @active_field = 0
57
+ end
58
+
59
+ def clear_filter
60
+ @filters = {}
61
+ @filter_inputs = {}
62
+ :refresh
63
+ end
64
+
65
+ def render_filter_input(frame, area)
66
+ spans = []
67
+
68
+ FILTER_FIELDS.each_with_index do |field, idx|
69
+ active = idx == @active_field
70
+ value = @filter_inputs[field[:key]] || ""
71
+
72
+ spans << @tui.text_span(
73
+ content: " #{field[:label]}: ",
74
+ style: @tui.style(fg: active ? :yellow : :dark_gray, modifiers: active ? [:bold] : [])
75
+ )
76
+
77
+ if active
78
+ spans << @tui.text_span(content: value + "\u2588", style: @tui.style(fg: :white))
79
+ else
80
+ spans << @tui.text_span(
81
+ content: value.empty? ? "\u2014" : value,
82
+ style: @tui.style(fg: :dark_gray)
83
+ )
84
+ end
85
+
86
+ spans << @tui.text_span(content: " ", style: @tui.style(fg: :white))
87
+ end
88
+
89
+ frame.render_widget(
90
+ @tui.paragraph(
91
+ text: @tui.text_line(spans: spans),
92
+ block: @tui.block(
93
+ title: " Filters ",
94
+ title_style: @tui.style(fg: :yellow),
95
+ titles: [
96
+ { content: " Tab: next field \u2502 Enter: apply \u2502 Esc: cancel ",
97
+ position: :top, alignment: :right }
98
+ ],
99
+ borders: [:all],
100
+ border_type: :rounded,
101
+ border_style: @tui.style(fg: :cyan)
102
+ )
103
+ ),
104
+ area
105
+ )
106
+ end
107
+
108
+ def filter_title(base_title)
109
+ return base_title if @filters.empty?
110
+
111
+ parts = FILTER_FIELDS.filter_map do |field|
112
+ value = @filters[field[:key]]
113
+ "#{field[:label].downcase}: #{value}" if value && !value.empty?
114
+ end
115
+
116
+ "#{base_title} (#{parts.join(', ')})"
117
+ end
118
+
119
+ def filter_bindings
120
+ [
121
+ { key: "Tab", action: "Next Field" },
122
+ { key: "Enter", action: "Apply" },
123
+ { key: "Esc", action: "Cancel" }
124
+ ]
125
+ end
126
+ end
127
+ end
128
+ end
@@ -36,7 +36,8 @@ module SolidQueueTui
36
36
 
37
37
  def bindings
38
38
  [
39
- { key: "Tab", action: "Next View" }
39
+ { key: "Tab", action: "Next View" },
40
+ { key: "Shift Tab", action: "Prev View" },
40
41
  ]
41
42
  end
42
43
 
@@ -3,47 +3,74 @@
3
3
  module SolidQueueTui
4
4
  module Views
5
5
  class FailedView
6
+ include Filterable
7
+
8
+
9
+ LOAD_THRESHOLD = 10
10
+
6
11
  def initialize(tui)
7
12
  @tui = tui
8
13
  @table_state = RatatuiRuby::TableState.new(nil)
9
14
  @table_state.select(0)
10
15
  @selected_row = 0
11
16
  @failed_jobs = []
12
- @filter = nil
13
- @filter_mode = false
14
- @filter_input = ""
17
+ @total_count = nil
18
+ @all_loaded = false
15
19
  @confirm_action = nil
20
+ init_filter
16
21
  end
17
22
 
18
23
  def update(failed_jobs:)
19
24
  @failed_jobs = failed_jobs
25
+ @all_loaded = failed_jobs.size < SolidQueueTui.page_size
20
26
  @selected_row = @selected_row.clamp(0, [@failed_jobs.size - 1, 0].max)
21
27
  @table_state.select(@selected_row)
22
28
  end
23
29
 
30
+ def append(failed_jobs:)
31
+ @failed_jobs.concat(failed_jobs)
32
+ @all_loaded = failed_jobs.size < SolidQueueTui.page_size
33
+ end
34
+
35
+ def total_count=(count)
36
+ @total_count = count
37
+ end
38
+
39
+ def current_offset
40
+ @failed_jobs.size
41
+ end
42
+
43
+ def reset_pagination!
44
+ @failed_jobs = []
45
+ @total_count = nil
46
+ @all_loaded = false
47
+ @selected_row = 0
48
+ @table_state.select(0)
49
+ end
50
+
24
51
  def render(frame, area)
25
52
  if @confirm_action
26
- content_area, confirm_area = @tui.layout_split(
53
+ confirm_area, content_area = @tui.layout_split(
27
54
  area,
28
55
  direction: :vertical,
29
56
  constraints: [
30
- @tui.constraint_fill(1),
31
- @tui.constraint_length(3)
57
+ @tui.constraint_length(3),
58
+ @tui.constraint_fill(1)
32
59
  ]
33
60
  )
34
- render_failed_table(frame, content_area)
35
61
  render_confirm(frame, confirm_area)
36
- elsif @filter_mode
37
- content_area, filter_area = @tui.layout_split(
62
+ render_failed_table(frame, content_area)
63
+ elsif filter_mode?
64
+ filter_area, content_area = @tui.layout_split(
38
65
  area,
39
66
  direction: :vertical,
40
67
  constraints: [
41
- @tui.constraint_fill(1),
42
- @tui.constraint_length(3)
68
+ @tui.constraint_length(3),
69
+ @tui.constraint_fill(1)
43
70
  ]
44
71
  )
45
- render_failed_table(frame, content_area)
46
72
  render_filter_input(frame, filter_area)
73
+ render_failed_table(frame, content_area)
47
74
  else
48
75
  render_failed_table(frame, area)
49
76
  end
@@ -52,7 +79,7 @@ module SolidQueueTui
52
79
  def handle_input(event)
53
80
  if @confirm_action
54
81
  handle_confirm_input(event)
55
- elsif @filter_mode
82
+ elsif filter_mode?
56
83
  handle_filter_input(event)
57
84
  else
58
85
  handle_normal_input(event)
@@ -64,19 +91,14 @@ module SolidQueueTui
64
91
  @failed_jobs[@selected_row]
65
92
  end
66
93
 
67
- def filter = @filter
68
-
69
94
  def bindings
70
95
  if @confirm_action
71
96
  [
72
97
  { key: "y", action: "Confirm" },
73
98
  { key: "n/Esc", action: "Cancel" }
74
99
  ]
75
- elsif @filter_mode
76
- [
77
- { key: "Enter", action: "Apply" },
78
- { key: "Esc", action: "Cancel" }
79
- ]
100
+ elsif filter_mode?
101
+ filter_bindings
80
102
  else
81
103
  [
82
104
  { key: "j/k", action: "Navigate" },
@@ -90,21 +112,27 @@ module SolidQueueTui
90
112
  end
91
113
 
92
114
  def capturing_input?
93
- @filter_mode || @confirm_action
115
+ filter_mode? || @confirm_action
94
116
  end
95
117
 
96
118
  def breadcrumb
97
- @filter ? "failed:#{@filter}" : "failed"
119
+ @filters.empty? ? "failed" : "failed:filtered"
98
120
  end
99
121
 
100
122
  private
101
123
 
124
+ def needs_more?
125
+ !@all_loaded && @selected_row >= @failed_jobs.size - LOAD_THRESHOLD
126
+ end
127
+
102
128
  def handle_normal_input(event)
103
129
  case event
104
130
  in { type: :key, code: "j" } | { type: :key, code: "up" }
105
131
  move_selection(-1)
106
132
  in { type: :key, code: "k" } | { type: :key, code: "down" }
107
- move_selection(1)
133
+ result = move_selection(1)
134
+ return :load_more if result == :load_more
135
+ nil
108
136
  in { type: :key, code: "g" }
109
137
  jump_to_top
110
138
  in { type: :key, code: "G" }
@@ -119,13 +147,10 @@ module SolidQueueTui
119
147
  @confirm_action = :retry_all unless @failed_jobs.empty?
120
148
  nil
121
149
  in { type: :key, code: "/" }
122
- @filter_mode = true
123
- @filter_input = @filter || ""
150
+ enter_filter_mode
124
151
  nil
125
152
  in { type: :key, code: "esc" }
126
- @filter = nil
127
- @filter_input = ""
128
- :refresh
153
+ clear_filter
129
154
  else
130
155
  nil
131
156
  end
@@ -148,7 +173,8 @@ module SolidQueueTui
148
173
  Actions::DiscardJob.call(item.id)
149
174
  :refresh
150
175
  when :retry_all
151
- Actions::RetryJob.retry_all
176
+ f = filters
177
+ Actions::RetryJob.retry_all(filter: f[:class_name], queue: f[:queue])
152
178
  :refresh
153
179
  end
154
180
  in { type: :key, code: "n" } | { type: :key, code: "esc" }
@@ -159,32 +185,11 @@ module SolidQueueTui
159
185
  end
160
186
  end
161
187
 
162
- def handle_filter_input(event)
163
- case event
164
- in { type: :key, code: "enter" }
165
- @filter = @filter_input.empty? ? nil : @filter_input
166
- @filter_mode = false
167
- @selected_row = 0
168
- @table_state.select(0)
169
- :refresh
170
- in { type: :key, code: "esc" }
171
- @filter_mode = false
172
- nil
173
- in { type: :key, code: "backspace" }
174
- @filter_input = @filter_input[0...-1]
175
- nil
176
- in { type: :key, code: /\A.\z/ => char }
177
- @filter_input += char
178
- nil
179
- else
180
- nil
181
- end
182
- end
183
-
184
188
  def move_selection(delta)
185
189
  return if @failed_jobs.empty?
186
190
  @selected_row = (@selected_row + delta).clamp(0, @failed_jobs.size - 1)
187
191
  @table_state.select(@selected_row)
192
+ :load_more if needs_more?
188
193
  end
189
194
 
190
195
  def jump_to_top
@@ -196,6 +201,7 @@ module SolidQueueTui
196
201
  return if @failed_jobs.empty?
197
202
  @selected_row = @failed_jobs.size - 1
198
203
  @table_state.select(@selected_row)
204
+ return :load_more if needs_more?
199
205
  end
200
206
 
201
207
  def render_failed_table(frame, area)
@@ -219,14 +225,13 @@ module SolidQueueTui
219
225
  }
220
226
  end
221
227
 
222
- title = @filter ? "Failed Jobs (filter: #{@filter})" : "Failed Jobs"
223
-
224
228
  table = Components::JobTable.new(
225
229
  @tui,
226
- title: title,
230
+ title: filter_title("Failed Jobs"),
227
231
  columns: columns,
228
232
  rows: rows,
229
233
  selected_row: @selected_row,
234
+ total_count: @total_count,
230
235
  empty_message: "No failed jobs — everything is running smoothly!"
231
236
  )
232
237
 
@@ -242,7 +247,9 @@ module SolidQueueTui
242
247
  job = selected_item
243
248
  "Discard job ##{job&.job_id} (#{job&.class_name})? This cannot be undone. [y/n]"
244
249
  when :retry_all
245
- "Retry ALL #{@failed_jobs.size} failed jobs? [y/n]"
250
+ count = @total_count || @failed_jobs.size
251
+ label = @filters.empty? ? "failed jobs" : "filtered failed jobs"
252
+ "Retry ALL #{count} #{label}? [y/n]"
246
253
  end
247
254
 
248
255
  frame.render_widget(
@@ -261,23 +268,6 @@ module SolidQueueTui
261
268
  )
262
269
  end
263
270
 
264
- def render_filter_input(frame, area)
265
- frame.render_widget(
266
- @tui.paragraph(
267
- text: @filter_input + "█",
268
- style: @tui.style(fg: :white),
269
- block: @tui.block(
270
- title: " Filter by class name ",
271
- title_style: @tui.style(fg: :yellow),
272
- borders: [:all],
273
- border_type: :rounded,
274
- border_style: @tui.style(fg: :cyan)
275
- )
276
- ),
277
- area
278
- )
279
- end
280
-
281
271
  def truncate(str, max)
282
272
  return "" unless str
283
273
  str.length > max ? "#{str[0...max - 3]}..." : str