solid_queue_tui 0.1.1 → 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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/exe/sqtui +37 -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 +109 -21
  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 +13 -2
  14. data/lib/solid_queue_tui/data/failed_query.rb +37 -91
  15. data/lib/solid_queue_tui/data/jobs_query.rb +119 -121
  16. data/lib/solid_queue_tui/data/processes_query.rb +32 -33
  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/formatting_helpers.rb +63 -0
  21. data/lib/solid_queue_tui/railtie.rb +9 -0
  22. data/lib/solid_queue_tui/version.rb +1 -1
  23. data/lib/solid_queue_tui/views/blocked_view.rb +85 -74
  24. data/lib/solid_queue_tui/views/concerns/confirmable.rb +53 -0
  25. data/lib/solid_queue_tui/views/concerns/filterable.rb +128 -0
  26. data/lib/solid_queue_tui/views/concerns/paginatable.rb +79 -0
  27. data/lib/solid_queue_tui/views/dashboard_view.rb +4 -5
  28. data/lib/solid_queue_tui/views/failed_view.rb +65 -179
  29. data/lib/solid_queue_tui/views/finished_view.rb +33 -114
  30. data/lib/solid_queue_tui/views/in_progress_view.rb +85 -69
  31. data/lib/solid_queue_tui/views/job_detail_view.rb +179 -31
  32. data/lib/solid_queue_tui/views/processes_view.rb +2 -24
  33. data/lib/solid_queue_tui/views/queues_view.rb +250 -30
  34. data/lib/solid_queue_tui/views/recurring_tasks_view.rb +155 -0
  35. data/lib/solid_queue_tui/views/scheduled_view.rb +69 -107
  36. data/lib/solid_queue_tui.rb +18 -4
  37. data/lib/tasks/solid_queue_tui.rake +8 -0
  38. metadata +20 -25
  39. data/lib/solid_queue_tui/connection.rb +0 -58
@@ -20,18 +20,16 @@ module SolidQueueTui
20
20
  end
21
21
 
22
22
  def self.fetch
23
- conn = ActiveRecord::Base.connection
24
-
25
23
  new(
26
- ready: count_table(conn, "solid_queue_ready_executions"),
27
- claimed: count_table(conn, "solid_queue_claimed_executions"),
28
- failed: count_table(conn, "solid_queue_failed_executions"),
29
- scheduled: count_table(conn, "solid_queue_scheduled_executions"),
30
- blocked: count_table(conn, "solid_queue_blocked_executions"),
31
- total_jobs: count_table(conn, "solid_queue_jobs"),
32
- completed_jobs: count_where(conn, "solid_queue_jobs", "finished_at IS NOT NULL"),
33
- process_count: count_table(conn, "solid_queue_processes"),
34
- processes_by_kind: processes_by_kind(conn)
24
+ ready: SolidQueue::ReadyExecution.count,
25
+ claimed: SolidQueue::ClaimedExecution.count,
26
+ failed: SolidQueue::FailedExecution.count,
27
+ scheduled: SolidQueue::ScheduledExecution.count,
28
+ blocked: SolidQueue::BlockedExecution.count,
29
+ total_jobs: SolidQueue::Job.count,
30
+ completed_jobs: SolidQueue::Job.finished.count,
31
+ process_count: SolidQueue::Process.count,
32
+ processes_by_kind: SolidQueue::Process.group(:kind).count
35
33
  )
36
34
  rescue => e
37
35
  empty(error: e.message)
@@ -44,22 +42,6 @@ module SolidQueueTui
44
42
  processes_by_kind: {}
45
43
  )
46
44
  end
47
-
48
- private_class_method def self.count_table(conn, table)
49
- conn.select_value("SELECT COUNT(*) FROM #{table}").to_i
50
- end
51
-
52
- private_class_method def self.count_where(conn, table, condition)
53
- conn.select_value("SELECT COUNT(*) FROM #{table} WHERE #{condition}").to_i
54
- end
55
-
56
- private_class_method def self.processes_by_kind(conn)
57
- rows = conn.select_rows(
58
- "SELECT kind, COUNT(*) FROM solid_queue_processes GROUP BY kind"
59
- )
60
- rows.to_h { |kind, count| [kind, count.to_i] }
61
- end
62
-
63
45
  end
64
46
  end
65
47
  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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ class Railtie < Rails::Railtie
5
+ rake_tasks do
6
+ load "tasks/solid_queue_tui.rake"
7
+ end
8
+ end
9
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidQueueTui
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.3"
5
5
  end
@@ -3,21 +3,95 @@
3
3
  module SolidQueueTui
4
4
  module Views
5
5
  class BlockedView
6
+ include Filterable
7
+ include Paginatable
8
+ include FormattingHelpers
9
+
6
10
  def initialize(tui)
7
11
  @tui = tui
8
- @table_state = RatatuiRuby::TableState.new(nil)
9
- @table_state.select(0)
10
- @selected_row = 0
11
- @jobs = []
12
+ init_pagination
13
+ init_filter
12
14
  end
13
15
 
14
16
  def update(jobs:)
15
- @jobs = jobs
16
- @selected_row = @selected_row.clamp(0, [@jobs.size - 1, 0].max)
17
- @table_state.select(@selected_row)
17
+ update_items(jobs)
18
+ end
19
+
20
+ def append(jobs:)
21
+ append_items(jobs)
18
22
  end
19
23
 
20
24
  def render(frame, area)
25
+ if filter_mode?
26
+ filter_area, content_area = @tui.layout_split(
27
+ area,
28
+ direction: :vertical,
29
+ constraints: [
30
+ @tui.constraint_length(3),
31
+ @tui.constraint_fill(1)
32
+ ]
33
+ )
34
+ render_filter_input(frame, filter_area)
35
+ render_table(frame, content_area)
36
+ else
37
+ render_table(frame, area)
38
+ end
39
+ end
40
+
41
+ def handle_input(event)
42
+ if filter_mode?
43
+ handle_filter_input(event)
44
+ else
45
+ handle_normal_input(event)
46
+ end
47
+ end
48
+
49
+ def bindings
50
+ if filter_mode?
51
+ filter_bindings
52
+ else
53
+ [
54
+ { key: "j/k", action: "Navigate" },
55
+ { key: "Enter", action: "Detail" },
56
+ { key: "/", action: "Filter" },
57
+ { key: "G/g", action: "Bottom/Top" }
58
+ ]
59
+ end
60
+ end
61
+
62
+ def capturing_input?
63
+ filter_mode?
64
+ end
65
+
66
+ def breadcrumb
67
+ @filters.empty? ? "blocked" : "blocked:filtered"
68
+ end
69
+
70
+ private
71
+
72
+ def handle_normal_input(event)
73
+ case event
74
+ in { type: :key, code: "j" } | { type: :key, code: "up" }
75
+ move_selection(-1)
76
+ in { type: :key, code: "k" } | { type: :key, code: "down" }
77
+ result = move_selection(1)
78
+ return :load_more if result == :load_more
79
+ nil
80
+ in { type: :key, code: "g" }
81
+ jump_to_top
82
+ in { type: :key, code: "G" }
83
+ jump_to_bottom
84
+ in { type: :key, code: "/" }
85
+ enter_filter_mode
86
+ nil
87
+ in { type: :key, code: "esc" }
88
+ clear_filter
89
+ else
90
+ nil
91
+ end
92
+ end
93
+
94
+ def render_table(frame, area)
21
95
  columns = [
22
96
  { key: :id, label: "ID", width: 8 },
23
97
  { key: :queue_name, label: "QUEUE", width: 14 },
@@ -28,7 +102,7 @@ module SolidQueueTui
28
102
  { key: :blocked_since, label: "BLOCKED SINCE", width: 14 }
29
103
  ]
30
104
 
31
- rows = @jobs.map do |job|
105
+ rows = items.map do |job|
32
106
  {
33
107
  id: job.id,
34
108
  queue_name: job.queue_name,
@@ -42,80 +116,17 @@ module SolidQueueTui
42
116
 
43
117
  table = Components::JobTable.new(
44
118
  @tui,
45
- title: "Blocked",
119
+ title: filter_title("Blocked"),
46
120
  columns: columns,
47
121
  rows: rows,
48
122
  selected_row: @selected_row,
49
- empty_message: "No blocked jobs"
123
+ total_count: @total_count,
124
+ empty_message: @filters.empty? ? "No blocked jobs" : "No blocked jobs matching filters"
50
125
  )
51
126
 
52
127
  table.render(frame, area, @table_state)
53
128
  end
54
129
 
55
- 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
65
- else
66
- nil
67
- end
68
- end
69
-
70
- def selected_item
71
- return nil if @jobs.empty? || @selected_row >= @jobs.size
72
- @jobs[@selected_row]
73
- end
74
-
75
- def bindings
76
- [
77
- { key: "j/k", action: "Navigate" },
78
- { key: "Enter", action: "Detail" },
79
- { key: "G/g", action: "Bottom/Top" }
80
- ]
81
- end
82
-
83
- def breadcrumb = "blocked"
84
-
85
- private
86
-
87
- def move_selection(delta)
88
- return if @jobs.empty?
89
- @selected_row = (@selected_row + delta).clamp(0, @jobs.size - 1)
90
- @table_state.select(@selected_row)
91
- end
92
-
93
- def jump_to_top
94
- @selected_row = 0
95
- @table_state.select(0)
96
- end
97
-
98
- def jump_to_bottom
99
- return if @jobs.empty?
100
- @selected_row = @jobs.size - 1
101
- @table_state.select(@selected_row)
102
- end
103
-
104
- def format_time(time)
105
- return "n/a" unless time
106
- time.strftime("%Y-%m-%d %H:%M:%S")
107
- end
108
-
109
- def time_ago(time)
110
- return "n/a" unless time
111
- seconds = (Time.now.utc - time).to_i
112
- case seconds
113
- when 0..59 then "#{seconds}s ago"
114
- when 60..3599 then "#{seconds / 60}m ago"
115
- when 3600..86399 then "#{seconds / 3600}h ago"
116
- else "#{seconds / 86400}d ago"
117
- end
118
- end
119
130
  end
120
131
  end
121
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,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
@@ -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
@@ -36,7 +38,8 @@ module SolidQueueTui
36
38
 
37
39
  def bindings
38
40
  [
39
- { key: "Tab", action: "Next View" }
41
+ { key: "Tab", action: "Next View" },
42
+ { key: "Shift Tab", action: "Prev View" },
40
43
  ]
41
44
  end
42
45
 
@@ -178,10 +181,6 @@ module SolidQueueTui
178
181
  ])
179
182
  end
180
183
 
181
- def format_number(n)
182
- return "0" if n.nil? || n == 0
183
- n.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
184
- end
185
184
  end
186
185
  end
187
186
  end