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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 362a8f6c4d4bd257ed5bdc4a7eaf93fb986b978313a5847bffd3dfe8e9f963ed
4
- data.tar.gz: 9aabeac6f1fa7404ff7a485b2a47669ed1313244348fc29dc4bc34a751ab9bd8
3
+ metadata.gz: edcec2bda0cc128879fda2b066fcbd560c7879c4a2554d53d3ff01c50c4d5040
4
+ data.tar.gz: 2457d9eb670fddb7a851f64dbe37c1f2b69fdcd9608d69e2feb5b0af4b406358
5
5
  SHA512:
6
- metadata.gz: 7de0862123508768172c46a17458c55651b6bda487d79e76f7e61933d223e29e848ba0614b4fc116acc8222dc5a245ac590fc92cff595b3c65c67367537589c1
7
- data.tar.gz: b8d145ba61da8f1de1fe3a4c1b3678e06ca36e19e6e4622acc67942adf194228eb6c96e09cfa114168cc8c3b3b28140ffd2d53b05d653081394c89406fdf6648
6
+ metadata.gz: a6f0174997d605c9e0d83c22c8a53973b017cd2e4cdd9cc260ace6f249d1a029c0c475a78124d5fd9537b9e5d1ca361c4b64adbd4a47c5842d0d05ede3ae153d
7
+ data.tar.gz: af07fa966c63b5f63b4f2e8d034e0d44b023294d443403b9130a631a5ecd3c52913dc0d71ef42ae85de8d81d37fc9e3c5e8def0f983cff6e372526bdf8e710bd
data/exe/qtop ADDED
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Boot the host Rails application's environment.
5
+ # This ensures ActiveRecord, Solid Queue models, and the database
6
+ # connection pool are fully loaded before the TUI starts.
7
+ env_file = File.join(Dir.pwd, "config", "environment.rb")
8
+
9
+ unless File.exist?(env_file)
10
+ $stderr.puts "Error: config/environment.rb not found in #{Dir.pwd}"
11
+ $stderr.puts ""
12
+ $stderr.puts "qtop must be run from your Rails application's root directory."
13
+ $stderr.puts "Make sure solid_queue_tui is in your Gemfile and Solid Queue is configured."
14
+ exit 1
15
+ end
16
+
17
+ require env_file
18
+
19
+ # Logs must never hit STDOUT/STDERR — that would corrupt the TUI.
20
+ # If a log/ directory exists (traditional Rails), write there.
21
+ # Otherwise (Rails 8 defaults), silence logging.
22
+ #TODO: figure out logging for rails 8, docker
23
+
24
+ log_dir = File.join(Dir.pwd, "log")
25
+ if Dir.exist?(log_dir)
26
+ log_file = File.join(log_dir, "#{Rails.env}.log")
27
+ tui_logger = ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(log_file))
28
+ tui_logger.push_tags("SQTUI")
29
+ else
30
+ tui_logger = ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(File::NULL))
31
+ end
32
+ ActiveRecord::Base.logger = tui_logger
33
+ Rails.logger = tui_logger
34
+
35
+ SolidQueueTui::CLI.run(ARGV)
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)
@@ -460,6 +492,7 @@ module SolidQueueTui
460
492
  help_section("Actions"),
461
493
  help_line("r", "Refresh data"),
462
494
  help_line("/", "Filter by class name"),
495
+ help_line("c", "Clear active filter"),
463
496
  help_line("R", "Retry failed job (in Failed view)"),
464
497
  help_line("D", "Discard failed job (in Failed view)"),
465
498
  help_line("A", "Retry all failed jobs"),
@@ -15,7 +15,7 @@ module SolidQueueTui
15
15
  options = {}
16
16
 
17
17
  OptionParser.new do |opts|
18
- opts.banner = "Usage: sqtui [options]"
18
+ opts.banner = "Usage: qtop [options]"
19
19
  opts.separator ""
20
20
  opts.separator "Options:"
21
21
 
@@ -36,7 +36,7 @@ module SolidQueueTui
36
36
  end
37
37
 
38
38
  opts.on("-v", "--version", "Show version") do
39
- puts "sqtui v#{SolidQueueTui::VERSION}"
39
+ puts "qtop v#{SolidQueueTui::VERSION}"
40
40
  exit
41
41
  end
42
42
 
@@ -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|
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module Data
5
+ class HourlyStatsQuery
6
+ Result = Struct.new(:data, :total, :peak, :avg, keyword_init: true)
7
+
8
+ def self.enqueued_per_hour
9
+ raw = SolidQueue::Job
10
+ .where(created_at: 24.hours.ago..)
11
+ .group(hour_sql(:created_at))
12
+ .count
13
+ build_result(raw)
14
+ rescue => e
15
+ empty_result
16
+ end
17
+
18
+ def self.processed_per_hour
19
+ raw = SolidQueue::Job
20
+ .where.not(finished_at: nil)
21
+ .where(finished_at: 24.hours.ago..)
22
+ .group(hour_sql(:finished_at))
23
+ .count
24
+ build_result(raw)
25
+ rescue => e
26
+ empty_result
27
+ end
28
+
29
+ def self.failed_per_hour
30
+ raw = SolidQueue::FailedExecution
31
+ .where(created_at: 24.hours.ago..)
32
+ .group(hour_sql(:created_at))
33
+ .count
34
+ build_result(raw)
35
+ rescue => e
36
+ empty_result
37
+ end
38
+
39
+ def self.empty_result
40
+ now = Time.now.utc
41
+ data = (0..23).map { |i| (now - (23 - i) * 3600).strftime("%H").to_i }
42
+ Result.new(data: data.map { 0 }, total: 0, peak: 0, avg: 0)
43
+ end
44
+
45
+ class << self
46
+ private
47
+
48
+ def hour_sql(column)
49
+ if sqlite?
50
+ Arel.sql("strftime('%Y-%m-%d %H:00:00', #{column})")
51
+ else
52
+ Arel.sql("DATE_TRUNC('hour', #{column})")
53
+ end
54
+ end
55
+
56
+ def build_result(raw_hash)
57
+ now = Time.now.utc
58
+
59
+ lookup = {}
60
+ raw_hash.each do |key, count|
61
+ time = key.is_a?(String) ? Time.parse("#{key} UTC") : key
62
+ lookup[time.strftime("%Y-%m-%d %H")] = count
63
+ end
64
+
65
+ # 24-slot array, oldest to newest — values only (for sparkline)
66
+ data = (0..23).map do |i|
67
+ hour_time = now - (23 - i) * 3600
68
+ key = hour_time.strftime("%Y-%m-%d %H")
69
+ lookup[key] || 0
70
+ end
71
+
72
+ total = data.sum
73
+ peak = data.max || 0
74
+ avg = total / 24
75
+
76
+ Result.new(data: data, total: total, peak: peak, avg: avg)
77
+ end
78
+
79
+ def sqlite?
80
+ SolidQueue::Job.connection.adapter_name.downcase.include?("sqlite")
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -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(
@@ -5,7 +5,9 @@ module SolidQueueTui
5
5
  class Stats
6
6
  attr_reader :ready, :claimed, :failed, :scheduled, :blocked,
7
7
  :total_jobs, :completed_jobs, :process_count,
8
- :processes_by_kind
8
+ :processes_by_kind,
9
+ :enqueued_per_hour, :processed_per_hour, :failed_per_hour,
10
+ :queue_depths
9
11
 
10
12
  def initialize(data)
11
13
  @ready = data[:ready]
@@ -17,6 +19,10 @@ module SolidQueueTui
17
19
  @completed_jobs = data[:completed_jobs]
18
20
  @process_count = data[:process_count]
19
21
  @processes_by_kind = data[:processes_by_kind]
22
+ @enqueued_per_hour = data[:enqueued_per_hour]
23
+ @processed_per_hour = data[:processed_per_hour]
24
+ @failed_per_hour = data[:failed_per_hour]
25
+ @queue_depths = data[:queue_depths]
20
26
  end
21
27
 
22
28
  def self.fetch
@@ -29,7 +35,11 @@ module SolidQueueTui
29
35
  total_jobs: SolidQueue::Job.count,
30
36
  completed_jobs: SolidQueue::Job.finished.count,
31
37
  process_count: SolidQueue::Process.count,
32
- processes_by_kind: SolidQueue::Process.group(:kind).count
38
+ processes_by_kind: SolidQueue::Process.group(:kind).count,
39
+ enqueued_per_hour: HourlyStatsQuery.enqueued_per_hour,
40
+ processed_per_hour: HourlyStatsQuery.processed_per_hour,
41
+ failed_per_hour: HourlyStatsQuery.failed_per_hour,
42
+ queue_depths: SolidQueue::ReadyExecution.group(:queue_name).count
33
43
  )
34
44
  rescue => e
35
45
  empty(error: e.message)
@@ -39,7 +49,9 @@ module SolidQueueTui
39
49
  new(
40
50
  ready: 0, claimed: 0, failed: 0, scheduled: 0, blocked: 0,
41
51
  total_jobs: 0, completed_jobs: 0, process_count: 0,
42
- processes_by_kind: {}
52
+ processes_by_kind: {},
53
+ enqueued_per_hour: nil, processed_per_hour: nil, failed_per_hour: nil,
54
+ queue_depths: {}
43
55
  )
44
56
  end
45
57
  end
@@ -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.2.0"
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
@@ -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 },
@@ -156,7 +103,7 @@ module SolidQueueTui
156
103
  { key: :blocked_since, label: "BLOCKED SINCE", width: 14 }
157
104
  ]
158
105
 
159
- rows = @jobs.map do |job|
106
+ rows = items.map do |job|
160
107
  {
161
108
  id: job.id,
162
109
  queue_name: job.queue_name,
@@ -181,21 +128,6 @@ module SolidQueueTui
181
128
  table.render(frame, area, @table_state)
182
129
  end
183
130
 
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
131
  end
200
132
  end
201
133
  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