solid_queue_tui 0.1.2 → 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.
@@ -4,47 +4,21 @@ module SolidQueueTui
4
4
  module Views
5
5
  class FinishedView
6
6
  include Filterable
7
-
8
-
9
- LOAD_THRESHOLD = 10
7
+ include Paginatable
8
+ include FormattingHelpers
10
9
 
11
10
  def initialize(tui)
12
11
  @tui = tui
13
- @table_state = RatatuiRuby::TableState.new(nil)
14
- @table_state.select(0)
15
- @selected_row = 0
16
- @jobs = []
17
- @total_count = nil
18
- @all_loaded = false
12
+ init_pagination
19
13
  init_filter
20
14
  end
21
15
 
22
16
  def update(jobs:)
23
- @jobs = jobs
24
- @all_loaded = jobs.size < SolidQueueTui.page_size
25
- @selected_row = @selected_row.clamp(0, [@jobs.size - 1, 0].max)
26
- @table_state.select(@selected_row)
17
+ update_items(jobs)
27
18
  end
28
19
 
29
20
  def append(jobs:)
30
- @jobs.concat(jobs)
31
- @all_loaded = jobs.size < SolidQueueTui.page_size
32
- end
33
-
34
- def total_count=(count)
35
- @total_count = count
36
- end
37
-
38
- def current_offset
39
- @jobs.size
40
- end
41
-
42
- def reset_pagination!
43
- @jobs = []
44
- @total_count = nil
45
- @all_loaded = false
46
- @selected_row = 0
47
- @table_state.select(0)
21
+ append_items(jobs)
48
22
  end
49
23
 
50
24
  def render(frame, area)
@@ -72,11 +46,6 @@ module SolidQueueTui
72
46
  end
73
47
  end
74
48
 
75
- def selected_item
76
- return nil if @jobs.empty? || @selected_row >= @jobs.size
77
- @jobs[@selected_row]
78
- end
79
-
80
49
  def bindings
81
50
  if filter_mode?
82
51
  filter_bindings
@@ -85,9 +54,9 @@ module SolidQueueTui
85
54
  { key: "j/k", action: "Navigate" },
86
55
  { key: "Enter", action: "Detail" },
87
56
  { key: "/", action: "Filter" },
88
- { key: "Esc", action: "Clear Filter" },
57
+ clear_filter_binding,
89
58
  { key: "G/g", action: "Bottom/Top" }
90
- ]
59
+ ].compact
91
60
  end
92
61
  end
93
62
 
@@ -101,10 +70,6 @@ module SolidQueueTui
101
70
 
102
71
  private
103
72
 
104
- def needs_more?
105
- !@all_loaded && @selected_row >= @jobs.size - LOAD_THRESHOLD
106
- end
107
-
108
73
  def handle_normal_input(event)
109
74
  case event
110
75
  in { type: :key, code: "j" } | { type: :key, code: "up" }
@@ -120,32 +85,13 @@ module SolidQueueTui
120
85
  in { type: :key, code: "/" }
121
86
  enter_filter_mode
122
87
  nil
123
- in { type: :key, code: "esc" }
88
+ in { type: :key, code: "c" }
124
89
  clear_filter
125
90
  else
126
91
  nil
127
92
  end
128
93
  end
129
94
 
130
- def move_selection(delta)
131
- return if @jobs.empty?
132
- @selected_row = (@selected_row + delta).clamp(0, @jobs.size - 1)
133
- @table_state.select(@selected_row)
134
- :load_more if needs_more?
135
- end
136
-
137
- def jump_to_top
138
- @selected_row = 0
139
- @table_state.select(0)
140
- end
141
-
142
- def jump_to_bottom
143
- return if @jobs.empty?
144
- @selected_row = @jobs.size - 1
145
- @table_state.select(@selected_row)
146
- return :load_more if needs_more?
147
- end
148
-
149
95
  def render_table(frame, area)
150
96
  columns = [
151
97
  { key: :id, label: "ID", width: 8 },
@@ -156,14 +102,14 @@ module SolidQueueTui
156
102
  { key: :duration, label: "DURATION", width: 12 }
157
103
  ]
158
104
 
159
- rows = @jobs.map do |job|
105
+ rows = items.map do |job|
160
106
  {
161
107
  id: job.id,
162
108
  queue_name: job.queue_name,
163
109
  class_name: job.class_name,
164
110
  priority: job.priority,
165
111
  finished_at: format_time(job.finished_at),
166
- duration: format_duration(job.created_at, job.finished_at)
112
+ duration: job_duration(job.created_at, job.finished_at)
167
113
  }
168
114
  end
169
115
 
@@ -180,23 +126,9 @@ module SolidQueueTui
180
126
  table.render(frame, area, @table_state)
181
127
  end
182
128
 
183
- def format_time(time)
184
- return "n/a" unless time
185
- time.strftime("%Y-%m-%d %H:%M:%S")
186
- end
187
-
188
- def format_duration(created, finished)
129
+ def job_duration(created, finished)
189
130
  return "n/a" unless created && finished
190
- seconds = (finished - created).to_i
191
- if seconds < 1
192
- "<1s"
193
- elsif seconds < 60
194
- "#{seconds}s"
195
- elsif seconds < 3600
196
- "#{seconds / 60}m #{seconds % 60}s"
197
- else
198
- "#{seconds / 3600}h #{(seconds % 3600) / 60}m"
199
- end
131
+ format_duration((finished - created).to_i)
200
132
  end
201
133
  end
202
134
  end
@@ -4,47 +4,21 @@ module SolidQueueTui
4
4
  module Views
5
5
  class InProgressView
6
6
  include Filterable
7
-
8
-
9
- LOAD_THRESHOLD = 10
7
+ include Paginatable
8
+ include FormattingHelpers
10
9
 
11
10
  def initialize(tui)
12
11
  @tui = tui
13
- @table_state = RatatuiRuby::TableState.new(nil)
14
- @table_state.select(0)
15
- @selected_row = 0
16
- @jobs = []
17
- @total_count = nil
18
- @all_loaded = false
12
+ init_pagination
19
13
  init_filter
20
14
  end
21
15
 
22
16
  def update(jobs:)
23
- @jobs = jobs
24
- @all_loaded = jobs.size < SolidQueueTui.page_size
25
- @selected_row = @selected_row.clamp(0, [@jobs.size - 1, 0].max)
26
- @table_state.select(@selected_row)
17
+ update_items(jobs)
27
18
  end
28
19
 
29
20
  def append(jobs:)
30
- @jobs.concat(jobs)
31
- @all_loaded = jobs.size < SolidQueueTui.page_size
32
- end
33
-
34
- def total_count=(count)
35
- @total_count = count
36
- end
37
-
38
- def current_offset
39
- @jobs.size
40
- end
41
-
42
- def reset_pagination!
43
- @jobs = []
44
- @total_count = nil
45
- @all_loaded = false
46
- @selected_row = 0
47
- @table_state.select(0)
21
+ append_items(jobs)
48
22
  end
49
23
 
50
24
  def render(frame, area)
@@ -72,11 +46,6 @@ module SolidQueueTui
72
46
  end
73
47
  end
74
48
 
75
- def selected_item
76
- return nil if @jobs.empty? || @selected_row >= @jobs.size
77
- @jobs[@selected_row]
78
- end
79
-
80
49
  def bindings
81
50
  if filter_mode?
82
51
  filter_bindings
@@ -85,8 +54,9 @@ module SolidQueueTui
85
54
  { key: "j/k", action: "Navigate" },
86
55
  { key: "Enter", action: "Detail" },
87
56
  { key: "/", action: "Filter" },
57
+ clear_filter_binding,
88
58
  { key: "G/g", action: "Bottom/Top" }
89
- ]
59
+ ].compact
90
60
  end
91
61
  end
92
62
 
@@ -100,10 +70,6 @@ module SolidQueueTui
100
70
 
101
71
  private
102
72
 
103
- def needs_more?
104
- !@all_loaded && @selected_row >= @jobs.size - LOAD_THRESHOLD
105
- end
106
-
107
73
  def handle_normal_input(event)
108
74
  case event
109
75
  in { type: :key, code: "j" } | { type: :key, code: "up" }
@@ -119,32 +85,13 @@ module SolidQueueTui
119
85
  in { type: :key, code: "/" }
120
86
  enter_filter_mode
121
87
  nil
122
- in { type: :key, code: "esc" }
88
+ in { type: :key, code: "c" }
123
89
  clear_filter
124
90
  else
125
91
  nil
126
92
  end
127
93
  end
128
94
 
129
- def move_selection(delta)
130
- return if @jobs.empty?
131
- @selected_row = (@selected_row + delta).clamp(0, @jobs.size - 1)
132
- @table_state.select(@selected_row)
133
- :load_more if needs_more?
134
- end
135
-
136
- def jump_to_top
137
- @selected_row = 0
138
- @table_state.select(0)
139
- end
140
-
141
- def jump_to_bottom
142
- return if @jobs.empty?
143
- @selected_row = @jobs.size - 1
144
- @table_state.select(@selected_row)
145
- return :load_more if needs_more?
146
- end
147
-
148
95
  def render_table(frame, area)
149
96
  columns = [
150
97
  { key: :id, label: "ID", width: 8 },
@@ -155,7 +102,7 @@ module SolidQueueTui
155
102
  { key: :started_at, label: "STARTED", width: 12 }
156
103
  ]
157
104
 
158
- rows = @jobs.map do |job|
105
+ rows = items.map do |job|
159
106
  {
160
107
  id: job.id,
161
108
  queue_name: job.queue_name,
@@ -179,16 +126,6 @@ module SolidQueueTui
179
126
  table.render(frame, area, @table_state)
180
127
  end
181
128
 
182
- def time_ago(time)
183
- return "n/a" unless time
184
- seconds = (Time.now.utc - time).to_i
185
- case seconds
186
- when 0..59 then "#{seconds}s ago"
187
- when 60..3599 then "#{seconds / 60}m ago"
188
- when 3600..86399 then "#{seconds / 3600}h ago"
189
- else "#{seconds / 86400}d ago"
190
- end
191
- end
192
129
  end
193
130
  end
194
131
  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