solid_queue_tui 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.
Files changed (31) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/exe/sqtui +6 -0
  4. data/lib/solid_queue_tui/actions/discard_job.rb +34 -0
  5. data/lib/solid_queue_tui/actions/discard_scheduled_job.rb +33 -0
  6. data/lib/solid_queue_tui/actions/dispatch_scheduled_job.rb +35 -0
  7. data/lib/solid_queue_tui/actions/retry_job.rb +76 -0
  8. data/lib/solid_queue_tui/application.rb +468 -0
  9. data/lib/solid_queue_tui/cli.rb +48 -0
  10. data/lib/solid_queue_tui/components/header.rb +105 -0
  11. data/lib/solid_queue_tui/components/help_bar.rb +77 -0
  12. data/lib/solid_queue_tui/components/job_table.rb +122 -0
  13. data/lib/solid_queue_tui/connection.rb +58 -0
  14. data/lib/solid_queue_tui/data/failed_query.rb +118 -0
  15. data/lib/solid_queue_tui/data/jobs_query.rb +178 -0
  16. data/lib/solid_queue_tui/data/processes_query.rb +75 -0
  17. data/lib/solid_queue_tui/data/queues_query.rb +36 -0
  18. data/lib/solid_queue_tui/data/stats.rb +65 -0
  19. data/lib/solid_queue_tui/dev_reloader.rb +53 -0
  20. data/lib/solid_queue_tui/version.rb +5 -0
  21. data/lib/solid_queue_tui/views/blocked_view.rb +121 -0
  22. data/lib/solid_queue_tui/views/dashboard_view.rb +187 -0
  23. data/lib/solid_queue_tui/views/failed_view.rb +298 -0
  24. data/lib/solid_queue_tui/views/finished_view.rb +216 -0
  25. data/lib/solid_queue_tui/views/in_progress_view.rb +114 -0
  26. data/lib/solid_queue_tui/views/job_detail_view.rb +236 -0
  27. data/lib/solid_queue_tui/views/processes_view.rb +142 -0
  28. data/lib/solid_queue_tui/views/queues_view.rb +96 -0
  29. data/lib/solid_queue_tui/views/scheduled_view.rb +215 -0
  30. data/lib/solid_queue_tui.rb +46 -0
  31. metadata +157 -0
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module Views
5
+ class InProgressView
6
+ def initialize(tui)
7
+ @tui = tui
8
+ @table_state = RatatuiRuby::TableState.new(nil)
9
+ @table_state.select(0)
10
+ @selected_row = 0
11
+ @jobs = []
12
+ end
13
+
14
+ def update(jobs:)
15
+ @jobs = jobs
16
+ @selected_row = @selected_row.clamp(0, [@jobs.size - 1, 0].max)
17
+ @table_state.select(@selected_row)
18
+ end
19
+
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: :worker_id, label: "WORKER", width: 8 },
27
+ { key: :started_at, label: "STARTED", width: 12 }
28
+ ]
29
+
30
+ rows = @jobs.map do |job|
31
+ {
32
+ id: job.id,
33
+ queue_name: job.queue_name,
34
+ class_name: job.class_name,
35
+ priority: job.priority,
36
+ worker_id: job.worker_id || "n/a",
37
+ started_at: time_ago(job.started_at)
38
+ }
39
+ end
40
+
41
+ table = Components::JobTable.new(
42
+ @tui,
43
+ title: "In Progress",
44
+ columns: columns,
45
+ rows: rows,
46
+ selected_row: @selected_row,
47
+ empty_message: "No jobs currently in progress"
48
+ )
49
+
50
+ table.render(frame, area, @table_state)
51
+ end
52
+
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
+ end
113
+ end
114
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module Views
5
+ class JobDetailView
6
+ def initialize(tui)
7
+ @tui = tui
8
+ @job = nil
9
+ @failed_job = nil
10
+ @scroll_offset = 0
11
+ end
12
+
13
+ def show(job: nil, failed_job: nil)
14
+ @job = job
15
+ @failed_job = failed_job
16
+ @scroll_offset = 0
17
+ @active = true
18
+ end
19
+
20
+ def hide
21
+ @active = false
22
+ @job = nil
23
+ @failed_job = nil
24
+ end
25
+
26
+ def active? = @active
27
+
28
+ def render(frame, area)
29
+ return unless @active
30
+
31
+ # Render an overlay with padding
32
+ inner = shrink_area(area, 4, 2)
33
+
34
+ frame.render_widget(@tui.paragraph(text: ""), inner) # clear background
35
+
36
+ if @failed_job
37
+ render_failed_detail(frame, inner)
38
+ elsif @job
39
+ render_job_detail(frame, inner)
40
+ end
41
+ end
42
+
43
+ def handle_input(event)
44
+ case event
45
+ in { type: :key, code: "esc" } | { type: :key, code: "q" }
46
+ hide
47
+ nil
48
+ in { type: :key, code: "j" } | { type: :key, code: "up" }
49
+ @scroll_offset = [@scroll_offset - 1, 0].max
50
+ nil
51
+ in { type: :key, code: "k" } | { type: :key, code: "down" }
52
+ @scroll_offset += 1
53
+ nil
54
+ in { type: :key, code: "R" }
55
+ if @failed_job
56
+ Actions::RetryJob.call(@failed_job.id)
57
+ hide
58
+ :refresh
59
+ end
60
+ in { type: :key, code: "D" }
61
+ if @failed_job
62
+ Actions::DiscardJob.call(@failed_job.id)
63
+ hide
64
+ :refresh
65
+ end
66
+ else
67
+ nil
68
+ end
69
+ end
70
+
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
+ ]
81
+ end
82
+ bindings
83
+ end
84
+
85
+ def breadcrumb
86
+ if @failed_job
87
+ "failed:#{@failed_job.job_id}"
88
+ elsif @job
89
+ "jobs:#{@job.id}"
90
+ else
91
+ "detail"
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ def render_failed_detail(frame, area)
98
+ lines = []
99
+
100
+ lines << section_header("Job Information")
101
+ lines << detail_line("Job ID", @failed_job.job_id.to_s)
102
+ lines << detail_line("Active Job ID", @failed_job.active_job_id || "n/a")
103
+ lines << detail_line("Class", @failed_job.class_name)
104
+ lines << detail_line("Queue", @failed_job.queue_name)
105
+ lines << detail_line("Priority", @failed_job.priority.to_s)
106
+ lines << detail_line("Created At", format_time(@failed_job.created_at))
107
+ lines << detail_line("Failed At", format_time(@failed_job.failed_at))
108
+ lines << empty_line
109
+
110
+ lines << section_header("Error Details")
111
+ lines << detail_line("Exception", @failed_job.error_class)
112
+ lines << detail_line("Message", @failed_job.error_message)
113
+ lines << empty_line
114
+
115
+ if @failed_job.backtrace.is_a?(Array) && !@failed_job.backtrace.empty?
116
+ lines << section_header("Backtrace")
117
+ @failed_job.backtrace.first(30).each do |bt_line|
118
+ lines << @tui.text_line(spans: [
119
+ @tui.text_span(content: " #{bt_line}", style: @tui.style(fg: :dark_gray))
120
+ ])
121
+ end
122
+ lines << empty_line
123
+ end
124
+
125
+ if @failed_job.arguments.is_a?(Hash) || @failed_job.arguments.is_a?(Array)
126
+ lines << section_header("Arguments")
127
+ args_str = JSON.pretty_generate(@failed_job.arguments) rescue @failed_job.arguments.to_s
128
+ args_str.split("\n").each do |arg_line|
129
+ lines << @tui.text_line(spans: [
130
+ @tui.text_span(content: " #{arg_line}", style: @tui.style(fg: :white))
131
+ ])
132
+ end
133
+ end
134
+
135
+ # Apply scroll offset
136
+ visible_lines = lines.drop(@scroll_offset)
137
+
138
+ frame.render_widget(
139
+ @tui.paragraph(
140
+ text: visible_lines,
141
+ block: @tui.block(
142
+ title: " Failed Job ##{@failed_job.job_id} — #{@failed_job.class_name} ",
143
+ title_style: @tui.style(fg: :red, modifiers: [:bold]),
144
+ titles: [
145
+ { content: " Esc:Close R:Retry D:Discard ",
146
+ position: :bottom, alignment: :right }
147
+ ],
148
+ borders: [:all],
149
+ border_type: :rounded,
150
+ border_style: @tui.style(fg: :red),
151
+ style: @tui.style(fg: :white)
152
+ )
153
+ ),
154
+ area
155
+ )
156
+ end
157
+
158
+ def render_job_detail(frame, area)
159
+ lines = []
160
+
161
+ lines << section_header("Job Information")
162
+ lines << detail_line("ID", @job.id.to_s)
163
+ lines << detail_line("Active Job ID", @job.active_job_id || "n/a")
164
+ lines << detail_line("Class", @job.class_name)
165
+ lines << detail_line("Queue", @job.queue_name)
166
+ lines << detail_line("Priority", @job.priority.to_s)
167
+ lines << detail_line("Status", @job.status)
168
+ lines << detail_line("Created At", format_time(@job.created_at))
169
+ lines << detail_line("Scheduled At", format_time(@job.scheduled_at))
170
+ lines << detail_line("Finished At", format_time(@job.finished_at))
171
+
172
+ if @job.arguments.is_a?(Hash) || @job.arguments.is_a?(Array)
173
+ lines << empty_line
174
+ lines << section_header("Arguments")
175
+ args_str = JSON.pretty_generate(@job.arguments) rescue @job.arguments.to_s
176
+ args_str.split("\n").each do |arg_line|
177
+ lines << @tui.text_line(spans: [
178
+ @tui.text_span(content: " #{arg_line}", style: @tui.style(fg: :white))
179
+ ])
180
+ end
181
+ end
182
+
183
+ visible_lines = lines.drop(@scroll_offset)
184
+
185
+ frame.render_widget(
186
+ @tui.paragraph(
187
+ text: visible_lines,
188
+ block: @tui.block(
189
+ title: " Job ##{@job.id} — #{@job.class_name} ",
190
+ title_style: @tui.style(fg: :cyan, modifiers: [:bold]),
191
+ titles: [
192
+ { content: " Esc:Close ",
193
+ position: :bottom, alignment: :right }
194
+ ],
195
+ borders: [:all],
196
+ border_type: :rounded,
197
+ border_style: @tui.style(fg: :cyan),
198
+ style: @tui.style(fg: :white)
199
+ )
200
+ ),
201
+ area
202
+ )
203
+ end
204
+
205
+ def section_header(title)
206
+ @tui.text_line(spans: [
207
+ @tui.text_span(content: " ── #{title} ", style: @tui.style(fg: :cyan, modifiers: [:bold]))
208
+ ])
209
+ end
210
+
211
+ def detail_line(label, value)
212
+ @tui.text_line(spans: [
213
+ @tui.text_span(content: " #{label.ljust(16)}", style: @tui.style(fg: :dark_gray)),
214
+ @tui.text_span(content: value.to_s, style: @tui.style(fg: :white))
215
+ ])
216
+ end
217
+
218
+ def empty_line
219
+ @tui.text_line(spans: [
220
+ @tui.text_span(content: "", style: @tui.style(fg: :white))
221
+ ])
222
+ end
223
+
224
+ def format_time(time)
225
+ return "n/a" unless time
226
+ time.strftime("%Y-%m-%d %H:%M:%S UTC")
227
+ end
228
+
229
+ def shrink_area(area, h_pad, v_pad)
230
+ # Create a smaller area centered in the given area
231
+ # This is an approximation - the actual implementation depends on ratatui_ruby's Rect API
232
+ area
233
+ end
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module Views
5
+ class ProcessesView
6
+ KIND_COLORS = {
7
+ "Worker" => :green,
8
+ "Dispatcher" => :yellow,
9
+ "Scheduler" => :blue
10
+ }.freeze
11
+
12
+ def initialize(tui)
13
+ @tui = tui
14
+ @table_state = RatatuiRuby::TableState.new(nil)
15
+ @table_state.select(0)
16
+ @selected_row = 0
17
+ @processes = []
18
+ end
19
+
20
+ def update(processes:)
21
+ @processes = processes
22
+ @selected_row = @selected_row.clamp(0, [@processes.size - 1, 0].max)
23
+ @table_state.select(@selected_row)
24
+ end
25
+
26
+ def render(frame, area)
27
+ columns = [
28
+ { key: :id, label: "ID", width: 6 },
29
+ { key: :kind, label: "KIND", width: 12 },
30
+ { key: :hostname, label: "HOSTNAME", width: :fill },
31
+ { key: :pid, label: "PID", width: 8 },
32
+ { key: :name, label: "NAME", width: :fill },
33
+ { key: :queues, label: "QUEUES", width: :fill },
34
+ { key: :heartbeat, label: "HEARTBEAT", width: 12 },
35
+ { key: :uptime, label: "UPTIME", width: 10 },
36
+ { key: :status, label: "STATUS", width: 8 }
37
+ ]
38
+
39
+ rows = @processes.map do |proc|
40
+ alive = proc.alive?
41
+
42
+ {
43
+ id: proc.id,
44
+ kind: proc.kind,
45
+ hostname: proc.hostname || "n/a",
46
+ pid: proc.pid,
47
+ name: proc.name || "n/a",
48
+ queues: Array(proc.queues).join(", "),
49
+ heartbeat: time_ago(proc.last_heartbeat_at),
50
+ uptime: format_duration(proc.uptime),
51
+ status: alive ? "alive" : "dead"
52
+ }
53
+ end
54
+
55
+ table = Components::JobTable.new(
56
+ @tui,
57
+ title: "Processes",
58
+ columns: columns,
59
+ rows: rows,
60
+ selected_row: @selected_row,
61
+ empty_message: "No active processes — is Solid Queue running?"
62
+ )
63
+
64
+ table.render(frame, area, @table_state)
65
+ end
66
+
67
+ def handle_input(event)
68
+ case event
69
+ in { type: :key, code: "j" } | { type: :key, code: "up" }
70
+ move_selection(-1)
71
+ in { type: :key, code: "k" } | { type: :key, code: "down" }
72
+ move_selection(1)
73
+ in { type: :key, code: "g" }
74
+ jump_to_top
75
+ in { type: :key, code: "G" }
76
+ jump_to_bottom
77
+ else
78
+ nil
79
+ end
80
+ end
81
+
82
+ def selected_item
83
+ return nil if @processes.empty? || @selected_row >= @processes.size
84
+ @processes[@selected_row]
85
+ end
86
+
87
+ def bindings
88
+ [
89
+ { key: "j/k", action: "Navigate" },
90
+ { key: "Enter", action: "Detail" },
91
+ { key: "G/g", action: "Bottom/Top" }
92
+ ]
93
+ end
94
+
95
+ def breadcrumb = "processes"
96
+
97
+ private
98
+
99
+ def move_selection(delta)
100
+ return if @processes.empty?
101
+ @selected_row = (@selected_row + delta).clamp(0, @processes.size - 1)
102
+ @table_state.select(@selected_row)
103
+ end
104
+
105
+ def jump_to_top
106
+ @selected_row = 0
107
+ @table_state.select(0)
108
+ end
109
+
110
+ def jump_to_bottom
111
+ return if @processes.empty?
112
+ @selected_row = @processes.size - 1
113
+ @table_state.select(@selected_row)
114
+ end
115
+
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
+ end
141
+ end
142
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module Views
5
+ class QueuesView
6
+ def initialize(tui)
7
+ @tui = tui
8
+ @table_state = RatatuiRuby::TableState.new(nil)
9
+ @table_state.select(0)
10
+ @selected_row = 0
11
+ @queues = []
12
+ end
13
+
14
+ def update(queues:)
15
+ @queues = queues
16
+ @selected_row = @selected_row.clamp(0, [@queues.size - 1, 0].max)
17
+ @table_state.select(@selected_row)
18
+ end
19
+
20
+ def render(frame, area)
21
+ columns = [
22
+ { key: :name, label: "QUEUE", width: :fill },
23
+ { key: :size, label: "SIZE", width: 10 },
24
+ { key: :status, label: "STATUS", width: 10, color_by: :status }
25
+ ]
26
+
27
+ rows = @queues.map do |q|
28
+ {
29
+ name: q.name,
30
+ size: q.size,
31
+ status: q.paused ? "paused" : "active"
32
+ }
33
+ end
34
+
35
+ table = Components::JobTable.new(
36
+ @tui,
37
+ title: "Queues",
38
+ columns: columns,
39
+ rows: rows,
40
+ selected_row: @selected_row,
41
+ empty_message: "No queues found"
42
+ )
43
+
44
+ table.render(frame, area, @table_state)
45
+ end
46
+
47
+ def handle_input(event)
48
+ case event
49
+ in { type: :key, code: "j" } | { type: :key, code: "up" }
50
+ move_selection(-1)
51
+ in { type: :key, code: "k" } | { type: :key, code: "down" }
52
+ move_selection(1)
53
+ in { type: :key, code: "g" }
54
+ jump_to_top
55
+ in { type: :key, code: "G" }
56
+ jump_to_bottom
57
+ else
58
+ nil
59
+ end
60
+ end
61
+
62
+ def selected_item
63
+ return nil if @queues.empty? || @selected_row >= @queues.size
64
+ @queues[@selected_row]
65
+ end
66
+
67
+ def bindings
68
+ [
69
+ { key: "j/k", action: "Navigate" },
70
+ { key: "G/g", action: "Bottom/Top" }
71
+ ]
72
+ end
73
+
74
+ def breadcrumb = "queues"
75
+
76
+ private
77
+
78
+ def move_selection(delta)
79
+ return if @queues.empty?
80
+ @selected_row = (@selected_row + delta).clamp(0, @queues.size - 1)
81
+ @table_state.select(@selected_row)
82
+ end
83
+
84
+ def jump_to_top
85
+ @selected_row = 0
86
+ @table_state.select(0)
87
+ end
88
+
89
+ def jump_to_bottom
90
+ return if @queues.empty?
91
+ @selected_row = @queues.size - 1
92
+ @table_state.select(@selected_row)
93
+ end
94
+ end
95
+ end
96
+ end