solid_queue_tui 0.1.2 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 362a8f6c4d4bd257ed5bdc4a7eaf93fb986b978313a5847bffd3dfe8e9f963ed
4
- data.tar.gz: 9aabeac6f1fa7404ff7a485b2a47669ed1313244348fc29dc4bc34a751ab9bd8
3
+ metadata.gz: ea9a6cc3e42df6d7c5f50f91ed2bbf360bbd3e65c9fabb6f53bcf649b69826f1
4
+ data.tar.gz: 3cd1c06f2c4ea19f2c59e0c5dc79962211d8f18b8200b4f46955910c2fcd78e6
5
5
  SHA512:
6
- metadata.gz: 7de0862123508768172c46a17458c55651b6bda487d79e76f7e61933d223e29e848ba0614b4fc116acc8222dc5a245ac590fc92cff595b3c65c67367537589c1
7
- data.tar.gz: b8d145ba61da8f1de1fe3a4c1b3678e06ca36e19e6e4622acc67942adf194228eb6c96e09cfa114168cc8c3b3b28140ffd2d53b05d653081394c89406fdf6648
6
+ metadata.gz: 2364ad1bd990fca2ec78ad80a744c3d820e07b3add19dcbfcfeb88cebb5c07b47dc9b29acefafc27dac06a24b333cb11cd894ffba070435d4fbdcbb051d79941
7
+ data.tar.gz: af34ecfb3046c67d8aac92f227d37740130946ee5f3abb919b55c2d8edd86b40ba242970dc9bda05e2191c397e42548f200295f8259ca05653a7992fa5d3731a
data/exe/sqtui CHANGED
@@ -19,7 +19,24 @@ require env_file
19
19
  # Log ActiveRecord queries to the Rails log file so TUI operations
20
20
  # are visible in log/development.log (or whichever environment is active).
21
21
  # Tag all TUI logs with [SQTUI] so they're easy to distinguish from app logs.
22
- ActiveRecord::Base.logger = Rails.logger
23
- Rails.logger.push_tags("SQTUI") if Rails.logger.respond_to?(:push_tags)
22
+ # CORRUPTING TERMINAL
23
+ # ActiveRecord::Base.logger = Rails.logger
24
+ # Rails.logger.push_tags("SQTUI") if Rails.logger.respond_to?(:push_tags)
25
+
26
+ # Logs must never hit STDOUT/STDERR — that would corrupt the TUI.
27
+ # If a log/ directory exists (traditional Rails), write there.
28
+ # Otherwise (Rails 8 defaults), silence logging.
29
+ #TODO: figure out logging for rails 8, docker
30
+
31
+ log_dir = File.join(Dir.pwd, "log")
32
+ if Dir.exist?(log_dir)
33
+ log_file = File.join(log_dir, "#{Rails.env}.log")
34
+ tui_logger = ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(log_file))
35
+ tui_logger.push_tags("SQTUI")
36
+ else
37
+ tui_logger = ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(File::NULL))
38
+ end
39
+ ActiveRecord::Base.logger = tui_logger
40
+ Rails.logger = tui_logger
24
41
 
25
42
  SolidQueueTui::CLI.run(ARGV)
@@ -143,10 +143,17 @@ module SolidQueueTui
143
143
  return false
144
144
  end
145
145
 
146
- # If view is in a modal state (filter, confirm), it gets all input
146
+ # If view is in a modal state (filter, confirm, detail sub-view), it gets all input
147
147
  if current_view.respond_to?(:capturing_input?) && current_view.capturing_input?
148
148
  result = current_view.handle_input(event)
149
- refresh_data! if result == :refresh
149
+ case result
150
+ when :refresh, :enter_queue, :exit_queue
151
+ refresh_data!
152
+ when :load_more
153
+ load_more_data!
154
+ when :open_detail
155
+ open_detail
156
+ end
150
157
  return false
151
158
  end
152
159
 
@@ -241,11 +248,21 @@ module SolidQueueTui
241
248
  return unless item
242
249
 
243
250
  case @current_view
251
+ when VIEW_QUEUES
252
+ if current_view.detail_mode?
253
+ @job_detail.show(job: item) if item.respond_to?(:id)
254
+ else
255
+ result = current_view.handle_input({ type: :key, code: "enter" })
256
+ refresh_data! if result == :enter_queue
257
+ end
244
258
  when VIEW_FAILED
245
259
  failed_job = Data::FailedQuery.fetch_one(item.id) if item.respond_to?(:id)
246
260
  @job_detail.show(failed_job: failed_job || item)
247
261
  when VIEW_IN_PROGRESS, VIEW_BLOCKED, VIEW_SCHEDULED, VIEW_FINISHED
248
262
  @job_detail.show(job: item) if item.respond_to?(:id)
263
+ when VIEW_WORKERS
264
+ running_jobs = Data::ProcessesQuery.fetch_running_jobs(process_id: item.id)
265
+ @job_detail.show(process: item, running_jobs: running_jobs)
249
266
  end
250
267
  end
251
268
 
@@ -262,8 +279,16 @@ module SolidQueueTui
262
279
  @stats = Data::Stats.fetch
263
280
  current_view.update(stats: @stats)
264
281
  when VIEW_QUEUES
265
- queues = Data::QueuesQuery.fetch
266
- current_view.update(queues: queues)
282
+ if current_view.detail_mode?
283
+ q = current_view.selected_queue_name
284
+ f = current_view.filters
285
+ current_view.total_count = Data::JobsQuery.count_pending(queue: q, filter: f[:class_name])
286
+ jobs = Data::JobsQuery.fetch_pending(queue: q, filter: f[:class_name], limit: SolidQueueTui.page_size, offset: 0)
287
+ current_view.update_detail(jobs: jobs)
288
+ else
289
+ queues = Data::QueuesQuery.fetch
290
+ current_view.update(queues: queues)
291
+ end
267
292
  when VIEW_FAILED
268
293
  f = current_view.filters
269
294
  current_view.total_count = Data::FailedQuery.count(filter: f[:class_name], queue: f[:queue])
@@ -305,6 +330,13 @@ module SolidQueueTui
305
330
  offset = view.current_offset
306
331
 
307
332
  case @current_view
333
+ when VIEW_QUEUES
334
+ if view.detail_mode?
335
+ q = view.selected_queue_name
336
+ f = view.filters
337
+ more = Data::JobsQuery.fetch_pending(queue: q, filter: f[:class_name], limit: SolidQueueTui.page_size, offset: offset)
338
+ view.append(jobs: more)
339
+ end
308
340
  when VIEW_FAILED
309
341
  f = view.filters
310
342
  more = Data::FailedQuery.fetch(filter: f[:class_name], queue: f[:queue], limit: SolidQueueTui.page_size, offset: offset)
@@ -3,6 +3,8 @@
3
3
  module SolidQueueTui
4
4
  module Components
5
5
  class JobTable
6
+ include FormattingHelpers
7
+
6
8
  STATUS_COLORS = {
7
9
  "ready" => :green,
8
10
  "claimed" => :yellow,
@@ -49,9 +51,6 @@ module SolidQueueTui
49
51
  text + " "
50
52
  end
51
53
 
52
- def format_number(n)
53
- n.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
54
- end
55
54
 
56
55
  def render_table(frame, area, table_state)
57
56
  widths = @columns.map do |col|
@@ -13,6 +13,7 @@ module SolidQueueTui
13
13
 
14
14
  def self.fetch(status:, filter: nil, queue: nil, limit: 100, offset: 0)
15
15
  case status
16
+ when "pending" then fetch_pending(queue: queue, filter: filter, limit: limit, offset: offset)
16
17
  when "claimed" then fetch_claimed(filter: filter, queue: queue, limit: limit, offset: offset)
17
18
  when "blocked" then fetch_blocked(filter: filter, queue: queue, limit: limit, offset: offset)
18
19
  when "scheduled" then fetch_scheduled(filter: filter, queue: queue, limit: limit, offset: offset)
@@ -25,6 +26,7 @@ module SolidQueueTui
25
26
 
26
27
  def self.count(status:, filter: nil, queue: nil)
27
28
  case status
29
+ when "pending" then count_pending(queue: queue, filter: filter)
28
30
  when "claimed" then count_scope(SolidQueue::ClaimedExecution.joins(:job), filter: filter, queue: queue)
29
31
  when "blocked" then count_scope(SolidQueue::BlockedExecution.joins(:job), filter: filter, queue: queue)
30
32
  when "scheduled" then count_scope(SolidQueue::ScheduledExecution.joins(:job), filter: filter, queue: queue)
@@ -35,6 +37,35 @@ module SolidQueueTui
35
37
  0
36
38
  end
37
39
 
40
+ def self.fetch_pending(queue:, filter: nil, limit: 100, offset: 0)
41
+ scope = SolidQueue::ReadyExecution.joins(:job)
42
+ scope = scope.where(queue_name: queue) if queue
43
+ scope = apply_class_name_filter(scope, filter)
44
+ scope = scope.order(priority: :asc, job_id: :asc).offset(offset).limit(limit)
45
+
46
+ scope.includes(:job).map do |re|
47
+ job = re.job
48
+ Job.new(
49
+ id: job.id,
50
+ queue_name: job.queue_name,
51
+ class_name: job.class_name,
52
+ priority: job.priority,
53
+ status: "pending",
54
+ active_job_id: job.active_job_id,
55
+ arguments: job.arguments,
56
+ scheduled_at: job.scheduled_at,
57
+ created_at: job.created_at
58
+ )
59
+ end
60
+ end
61
+
62
+ def self.count_pending(queue:, filter: nil)
63
+ scope = SolidQueue::ReadyExecution.joins(:job)
64
+ scope = scope.where(queue_name: queue) if queue
65
+ scope = apply_class_name_filter(scope, filter)
66
+ scope.count
67
+ end
68
+
38
69
  def self.fetch_claimed(filter: nil, queue: nil, limit: 100, offset: 0)
39
70
  scope = SolidQueue::ClaimedExecution.joins(:job)
40
71
  scope = scope.merge(SolidQueue::Job.where(queue_name: queue)) if queue
@@ -29,6 +29,29 @@ module SolidQueueTui
29
29
  end
30
30
  end
31
31
 
32
+ RunningJob = Struct.new(
33
+ :job_id, :class_name, :queue_name, :started_at,
34
+ keyword_init: true
35
+ )
36
+
37
+ def self.fetch_running_jobs(process_id:)
38
+ SolidQueue::ClaimedExecution
39
+ .where(process_id: process_id)
40
+ .joins(:job).includes(:job)
41
+ .order(:created_at)
42
+ .map do |ce|
43
+ job = ce.job
44
+ RunningJob.new(
45
+ job_id: job.id,
46
+ class_name: job.class_name,
47
+ queue_name: job.queue_name,
48
+ started_at: ce.created_at
49
+ )
50
+ end
51
+ rescue => e
52
+ []
53
+ end
54
+
32
55
  def self.fetch
33
56
  SolidQueue::Process.where(kind: "Worker").order(:id).map do |proc|
34
57
  Process.new(
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module FormattingHelpers
5
+ def time_ago(time)
6
+ return "n/a" unless time
7
+ seconds = (Time.now.utc - time).to_i
8
+ case seconds
9
+ when 0..59 then "#{seconds}s ago"
10
+ when 60..3599 then "#{seconds / 60}m ago"
11
+ when 3600..86399 then "#{seconds / 3600}h ago"
12
+ else "#{seconds / 86400}d ago"
13
+ end
14
+ end
15
+
16
+ def format_time(time)
17
+ return "n/a" unless time
18
+ time.strftime("%Y-%m-%d %H:%M:%S")
19
+ end
20
+
21
+ def format_duration(seconds)
22
+ return "n/a" unless seconds
23
+ seconds = seconds.to_i
24
+ if seconds < 1
25
+ "<1s"
26
+ elsif seconds < 60
27
+ "#{seconds}s"
28
+ elsif seconds < 3600
29
+ "#{seconds / 60}m #{seconds % 60}s"
30
+ elsif seconds < 86400
31
+ "#{seconds / 3600}h #{(seconds % 3600) / 60}m"
32
+ else
33
+ "#{seconds / 86400}d #{(seconds % 86400) / 3600}h"
34
+ end
35
+ end
36
+
37
+ def format_number(n)
38
+ return "0" if n.nil? || n == 0
39
+ n.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
40
+ end
41
+
42
+ def truncate(str, max)
43
+ return "" unless str
44
+ str.length > max ? "#{str[0...max - 3]}..." : str
45
+ end
46
+
47
+ def humanize_duration(seconds)
48
+ case seconds.abs
49
+ when 0..59 then "#{seconds.abs}s"
50
+ when 60..3599 then "#{seconds.abs / 60}m"
51
+ when 3600..86399 then "#{seconds.abs / 3600}h"
52
+ else "#{seconds.abs / 86400}d"
53
+ end
54
+ end
55
+
56
+ def time_until(time)
57
+ return "n/a" unless time
58
+ seconds = (time - Time.now.utc).to_i
59
+ return "now" if seconds <= 0
60
+ "in #{humanize_duration(seconds)}"
61
+ end
62
+ end
63
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidQueueTui
4
- VERSION = "0.1.2"
4
+ VERSION = "0.1.3"
5
5
  end
@@ -4,47 +4,21 @@ module SolidQueueTui
4
4
  module Views
5
5
  class BlockedView
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
@@ -100,10 +69,6 @@ module SolidQueueTui
100
69
 
101
70
  private
102
71
 
103
- def needs_more?
104
- !@all_loaded && @selected_row >= @jobs.size - LOAD_THRESHOLD
105
- end
106
-
107
72
  def handle_normal_input(event)
108
73
  case event
109
74
  in { type: :key, code: "j" } | { type: :key, code: "up" }
@@ -126,25 +91,6 @@ module SolidQueueTui
126
91
  end
127
92
  end
128
93
 
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
94
  def render_table(frame, area)
149
95
  columns = [
150
96
  { key: :id, label: "ID", width: 8 },
@@ -156,7 +102,7 @@ module SolidQueueTui
156
102
  { key: :blocked_since, label: "BLOCKED SINCE", width: 14 }
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,
@@ -181,21 +127,6 @@ module SolidQueueTui
181
127
  table.render(frame, area, @table_state)
182
128
  end
183
129
 
184
- def format_time(time)
185
- return "n/a" unless time
186
- time.strftime("%Y-%m-%d %H:%M:%S")
187
- end
188
-
189
- def time_ago(time)
190
- return "n/a" unless time
191
- seconds = (Time.now.utc - time).to_i
192
- case seconds
193
- when 0..59 then "#{seconds}s ago"
194
- when 60..3599 then "#{seconds / 60}m ago"
195
- when 3600..86399 then "#{seconds / 3600}h ago"
196
- else "#{seconds / 86400}d ago"
197
- end
198
- end
199
130
  end
200
131
  end
201
132
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module Views
5
+ module Confirmable
6
+ def init_confirm
7
+ @confirm_action = nil
8
+ end
9
+
10
+ def confirm_mode? = !!@confirm_action
11
+
12
+ def confirm_bindings
13
+ [{ key: "y", action: "Confirm" }, { key: "n/Esc", action: "Cancel" }]
14
+ end
15
+
16
+ def handle_confirm_input(event)
17
+ case event
18
+ in { type: :key, code: "y" }
19
+ action = @confirm_action
20
+ @confirm_action = nil
21
+ execute_confirm_action(action)
22
+ in { type: :key, code: "n" } | { type: :key, code: "esc" }
23
+ @confirm_action = nil
24
+ nil
25
+ else
26
+ nil
27
+ end
28
+ end
29
+
30
+ def render_confirm_popup(frame, area)
31
+ popup_area = area.centered(
32
+ @tui.constraint_percentage(50),
33
+ @tui.constraint_length(5)
34
+ )
35
+ frame.render_widget(@tui.clear(), popup_area)
36
+ frame.render_widget(
37
+ @tui.paragraph(
38
+ text: " #{confirm_message}",
39
+ style: @tui.style(fg: :yellow, modifiers: [:bold]),
40
+ block: @tui.block(
41
+ title: " Confirm ",
42
+ title_style: @tui.style(fg: :red, modifiers: [:bold]),
43
+ borders: [:all],
44
+ border_type: :rounded,
45
+ border_style: @tui.style(fg: :red)
46
+ )
47
+ ),
48
+ popup_area
49
+ )
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module Views
5
+ module Paginatable
6
+ LOAD_THRESHOLD = 10
7
+
8
+ def init_pagination
9
+ @table_state = RatatuiRuby::TableState.new(nil)
10
+ @table_state.select(0)
11
+ @selected_row = 0
12
+ @items = []
13
+ @total_count = nil
14
+ @all_loaded = false
15
+ end
16
+
17
+ def items = @items
18
+
19
+ def selected_item
20
+ return nil if @items.empty? || @selected_row >= @items.size
21
+ @items[@selected_row]
22
+ end
23
+
24
+ def total_count=(count)
25
+ @total_count = count
26
+ end
27
+
28
+ def current_offset
29
+ @items.size
30
+ end
31
+
32
+ def reset_pagination!
33
+ @items = []
34
+ @total_count = nil
35
+ @all_loaded = false
36
+ @selected_row = 0
37
+ @table_state.select(0)
38
+ end
39
+
40
+ private
41
+
42
+ def update_items(new_items)
43
+ @selected_row = 0 if @selected_row >= new_items.size
44
+ @items = new_items
45
+ @all_loaded = new_items.size < SolidQueueTui.page_size
46
+ @selected_row = @selected_row.clamp(0, [@items.size - 1, 0].max)
47
+ @table_state.select(@selected_row)
48
+ end
49
+
50
+ def append_items(more_items)
51
+ @items.concat(more_items)
52
+ @all_loaded = more_items.size < SolidQueueTui.page_size
53
+ end
54
+
55
+ def needs_more?
56
+ !@all_loaded && @selected_row >= @items.size - LOAD_THRESHOLD
57
+ end
58
+
59
+ def move_selection(delta)
60
+ return if @items.empty?
61
+ @selected_row = (@selected_row + delta).clamp(0, @items.size - 1)
62
+ @table_state.select(@selected_row)
63
+ :load_more if needs_more?
64
+ end
65
+
66
+ def jump_to_top
67
+ @selected_row = 0
68
+ @table_state.select(0)
69
+ end
70
+
71
+ def jump_to_bottom
72
+ return if @items.empty?
73
+ @selected_row = @items.size - 1
74
+ @table_state.select(@selected_row)
75
+ :load_more if needs_more?
76
+ end
77
+ end
78
+ end
79
+ end
@@ -3,6 +3,8 @@
3
3
  module SolidQueueTui
4
4
  module Views
5
5
  class DashboardView
6
+ include FormattingHelpers
7
+
6
8
  def initialize(tui)
7
9
  @tui = tui
8
10
  @selected_row = 0
@@ -179,10 +181,6 @@ module SolidQueueTui
179
181
  ])
180
182
  end
181
183
 
182
- def format_number(n)
183
- return "0" if n.nil? || n == 0
184
- n.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
185
- end
186
184
  end
187
185
  end
188
186
  end