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,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module SolidQueueTui
6
+ class CLI
7
+ def self.run(args)
8
+ options = parse_options(args)
9
+ Application.new(**options).run
10
+ rescue Connection::ConnectionError => e
11
+ $stderr.puts "Connection error: #{e.message}"
12
+ exit 1
13
+ rescue Interrupt
14
+ exit 0
15
+ end
16
+
17
+ def self.parse_options(args)
18
+ options = {}
19
+
20
+ OptionParser.new do |opts|
21
+ opts.banner = "Usage: sqtui [options]"
22
+ opts.separator ""
23
+ opts.separator "Options:"
24
+
25
+ opts.on("--dev", "Enable hot-reload (watches lib/ for changes)") do
26
+ options[:dev] = true
27
+ end
28
+
29
+ opts.on("-v", "--version", "Show version") do
30
+ puts "sqtui v#{SolidQueueTui::VERSION}"
31
+ exit
32
+ end
33
+
34
+ opts.on("-h", "--help", "Show this help") do
35
+ puts opts
36
+ puts ""
37
+ puts "Configuration:"
38
+ puts " Create config/solid_tui.yml with:"
39
+ puts " database_url: sqlite3:storage/queue.sqlite3"
40
+ puts " refresh: 2"
41
+ exit
42
+ end
43
+ end.parse!(args)
44
+
45
+ options
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module Components
5
+ class Header
6
+ LOGO = [
7
+ " ____ _ _ _ ___ ",
8
+ "/ ___| ___ | (_) __| | / _ \\ _ _ ___ _ _ ___ ",
9
+ "\\___ \\ / _ \\| | |/ _` | | | | | | | |/ _ \\ | | |/ _ \\",
10
+ " ___) | (_) | | | (_| | | |_| | |_| | __/ |_| | __/",
11
+ "|____/ \\___/|_|_|\\__,_| \\___/\\__,_|\\___|\\__,_|\\___|"
12
+ ].freeze
13
+
14
+ VIEWS = [
15
+ { key: "1", label: "Dashboard" },
16
+ { key: "2", label: "Queues" },
17
+ { key: "3", label: "Failed" },
18
+ { key: "4", label: "In Progress" },
19
+ { key: "5", label: "Blocked" },
20
+ { key: "6", label: "Scheduled" },
21
+ { key: "7", label: "Finished" },
22
+ { key: "8", label: "Workers" }
23
+ ].freeze
24
+
25
+ def initialize(tui, current_view:)
26
+ @tui = tui
27
+ @current_view = current_view
28
+ end
29
+
30
+ def render(frame, area)
31
+ left_area, right_area = @tui.layout_split(
32
+ area,
33
+ direction: :horizontal,
34
+ constraints: [
35
+ @tui.constraint_percentage(50),
36
+ @tui.constraint_percentage(50)
37
+ ]
38
+ )
39
+
40
+ render_info(frame, left_area)
41
+ render_nav(frame, right_area)
42
+ end
43
+
44
+ private
45
+
46
+ def render_info(frame, area)
47
+ lines = LOGO.map do |logo_line|
48
+ @tui.text_line(spans: [
49
+ @tui.text_span(content: " #{logo_line}", style: @tui.style(fg: :red, modifiers: [:bold]))
50
+ ])
51
+ end
52
+
53
+ lines << @tui.text_line(spans: [
54
+ @tui.text_span(content: " v#{VERSION}", style: @tui.style(fg: :dark_gray))
55
+ ])
56
+
57
+ frame.render_widget(
58
+ @tui.paragraph(text: lines),
59
+ area
60
+ )
61
+ end
62
+
63
+ def render_nav(frame, area)
64
+ spans = [
65
+ @tui.text_span(content: " ", style: @tui.style(fg: :white))
66
+ ]
67
+
68
+ VIEWS.each_with_index do |view, idx|
69
+ active = idx == @current_view
70
+
71
+ spans << @tui.text_span(
72
+ content: "<#{view[:key]}>",
73
+ style: @tui.style(fg: :cyan, modifiers: active ? [:bold] : [])
74
+ )
75
+ spans << @tui.text_span(
76
+ content: " #{view[:label]}",
77
+ style: @tui.style(
78
+ fg: active ? :yellow : :dark_gray,
79
+ modifiers: active ? [:bold, :underlined] : []
80
+ )
81
+ )
82
+ spans << @tui.text_span(content: " ", style: @tui.style(fg: :white))
83
+ end
84
+
85
+ lines = [
86
+ @tui.text_line(spans: spans, alignment: :right),
87
+ @tui.text_line(spans: [
88
+ @tui.text_span(content: "<?>", style: @tui.style(fg: :cyan)),
89
+ @tui.text_span(content: " Help ", style: @tui.style(fg: :dark_gray)),
90
+ @tui.text_span(content: "<q>", style: @tui.style(fg: :cyan)),
91
+ @tui.text_span(content: " Quit ", style: @tui.style(fg: :dark_gray)),
92
+ @tui.text_span(content: "<r>", style: @tui.style(fg: :cyan)),
93
+ @tui.text_span(content: " Refresh", style: @tui.style(fg: :dark_gray))
94
+ ], alignment: :right)
95
+ ]
96
+
97
+ frame.render_widget(
98
+ @tui.paragraph(text: lines),
99
+ area
100
+ )
101
+ end
102
+
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module Components
5
+ class HelpBar
6
+ DEFAULT_BINDINGS = [
7
+ { key: "q", action: "Quit" },
8
+ { key: "r", action: "Refresh" },
9
+ { key: "Tab", action: "Next View" },
10
+ { key: "j/k", action: "Navigate" },
11
+ { key: "/", action: "Filter" },
12
+ { key: "Esc", action: "Clear" }
13
+ ].freeze
14
+
15
+ def initialize(tui, breadcrumb:, bindings: nil, status: nil)
16
+ @tui = tui
17
+ @breadcrumb = breadcrumb
18
+ @bindings = bindings || DEFAULT_BINDINGS
19
+ @status = status
20
+ end
21
+
22
+ def render(frame, area)
23
+ left, right = @tui.layout_split(
24
+ area,
25
+ direction: :horizontal,
26
+ constraints: [
27
+ @tui.constraint_percentage(30),
28
+ @tui.constraint_percentage(70)
29
+ ]
30
+ )
31
+
32
+ render_breadcrumb(frame, left)
33
+ render_bindings(frame, right)
34
+ end
35
+
36
+ private
37
+
38
+ def render_breadcrumb(frame, area)
39
+ spans = [
40
+ @tui.text_span(content: " <", style: @tui.style(fg: :dark_gray)),
41
+ @tui.text_span(content: @breadcrumb, style: @tui.style(fg: :yellow, modifiers: [:bold])),
42
+ @tui.text_span(content: ">", style: @tui.style(fg: :dark_gray))
43
+ ]
44
+
45
+ if @status
46
+ spans << @tui.text_span(content: " #{@status}", style: @tui.style(fg: :dark_gray))
47
+ end
48
+
49
+ frame.render_widget(
50
+ @tui.paragraph(text: [@tui.text_line(spans: spans)]),
51
+ area
52
+ )
53
+ end
54
+
55
+ def render_bindings(frame, area)
56
+ spans = []
57
+
58
+ @bindings.each_with_index do |binding, idx|
59
+ spans << @tui.text_span(
60
+ content: binding[:key],
61
+ style: @tui.style(fg: :cyan, modifiers: [:bold])
62
+ )
63
+ spans << @tui.text_span(
64
+ content: ":#{binding[:action]}",
65
+ style: @tui.style(fg: :dark_gray)
66
+ )
67
+ spans << @tui.text_span(content: " ", style: @tui.style(fg: :white)) if idx < @bindings.size - 1
68
+ end
69
+
70
+ frame.render_widget(
71
+ @tui.paragraph(text: [@tui.text_line(spans: spans, alignment: :right)]),
72
+ area
73
+ )
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module Components
5
+ class JobTable
6
+ STATUS_COLORS = {
7
+ "ready" => :green,
8
+ "claimed" => :yellow,
9
+ "scheduled" => :blue,
10
+ "failed" => :red,
11
+ "blocked" => :magenta,
12
+ "completed" => :dark_gray,
13
+ "active" => :green,
14
+ "paused" => :red,
15
+ "unknown" => :white
16
+ }.freeze
17
+
18
+ def initialize(tui, title:, columns:, rows:, selected_row: nil, empty_message: "No data")
19
+ @tui = tui
20
+ @title = title
21
+ @columns = columns
22
+ @rows = rows
23
+ @selected_row = selected_row
24
+ @empty_message = empty_message
25
+ end
26
+
27
+ def render(frame, area, table_state)
28
+ if @rows.empty?
29
+ render_empty(frame, area)
30
+ return
31
+ end
32
+
33
+ render_table(frame, area, table_state)
34
+ end
35
+
36
+ private
37
+
38
+ def title_text
39
+ text = " #{@title} [#{@rows.size}]"
40
+ text += " #{@selected_row + 1}/#{@rows.size}" if @selected_row && @rows.size > 0
41
+ text + " "
42
+ end
43
+
44
+ def render_table(frame, area, table_state)
45
+ widths = @columns.map do |col|
46
+ case col[:width]
47
+ when :fill then @tui.constraint_fill(1)
48
+ when Integer then @tui.constraint_length(col[:width])
49
+ else @tui.constraint_length(12)
50
+ end
51
+ end
52
+
53
+ header = @columns.map { |col| col[:label] }
54
+
55
+ table = @tui.table(
56
+ rows: build_rows,
57
+ header: header,
58
+ widths: widths,
59
+ selected_row: @selected_row,
60
+ row_highlight_style: @tui.style(fg: :black, bg: :cyan, modifiers: [:bold]),
61
+ highlight_symbol: " > ",
62
+ highlight_spacing: :always,
63
+ column_spacing: 1,
64
+ style: @tui.style(fg: :white),
65
+ block: @tui.block(
66
+ title: title_text,
67
+ title_style: @tui.style(fg: :yellow, modifiers: [:bold]),
68
+ borders: [:all],
69
+ border_type: :rounded,
70
+ border_style: @tui.style(fg: :dark_gray)
71
+ )
72
+ )
73
+
74
+ frame.render_stateful_widget(table, area, table_state)
75
+ end
76
+
77
+ def build_rows
78
+ @rows.map do |row|
79
+ cells = @columns.map do |col|
80
+ value = row[col[:key]]
81
+ style = cell_style(col, value)
82
+
83
+ if style
84
+ @tui.table_cell(content: value.to_s, style: style)
85
+ else
86
+ value.to_s
87
+ end
88
+ end
89
+
90
+ @tui.table_row(cells: cells)
91
+ end
92
+ end
93
+
94
+ def cell_style(col, value)
95
+ if col[:color_by] == :status
96
+ color = STATUS_COLORS[value.to_s.downcase] || :white
97
+ @tui.style(fg: color, modifiers: [:bold])
98
+ elsif col[:color]
99
+ @tui.style(fg: col[:color])
100
+ end
101
+ end
102
+
103
+ def render_empty(frame, area)
104
+ frame.render_widget(
105
+ @tui.paragraph(
106
+ text: @empty_message,
107
+ alignment: :center,
108
+ style: @tui.style(fg: :dark_gray),
109
+ block: @tui.block(
110
+ title: " #{@title} ",
111
+ title_style: @tui.style(fg: :yellow, modifiers: [:bold]),
112
+ borders: [:all],
113
+ border_type: :rounded,
114
+ border_style: @tui.style(fg: :dark_gray)
115
+ )
116
+ ),
117
+ area
118
+ )
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "yaml"
5
+
6
+ module SolidQueueTui
7
+ class Connection
8
+ CONFIG_FILE = "config/solid_tui.yml"
9
+
10
+ # Loads config, establishes the DB connection, and returns the parsed config hash.
11
+ def self.establish!
12
+ config = load_config
13
+
14
+ url = config["database_url"]
15
+ raise ConnectionError, <<~MSG unless url
16
+ No database_url found in #{CONFIG_FILE}.
17
+
18
+ Create #{CONFIG_FILE} with:
19
+ database_url: postgres://user:pass@localhost/myapp_queue
20
+ refresh: 2
21
+ MSG
22
+
23
+ ActiveRecord::Base.establish_connection(url)
24
+ verify_solid_queue_tables!
25
+
26
+ config
27
+ end
28
+
29
+ def self.load_config
30
+ path = File.join(Dir.pwd, CONFIG_FILE)
31
+
32
+ unless File.exist?(path)
33
+ raise ConnectionError, <<~MSG
34
+ Config file not found: #{CONFIG_FILE}
35
+
36
+ Create #{CONFIG_FILE} with:
37
+ database_url: postgres://user:pass@localhost/myapp_queue
38
+ refresh: 2
39
+ MSG
40
+ end
41
+
42
+ YAML.safe_load(File.read(path), permitted_classes: [Symbol]) || {}
43
+ end
44
+
45
+ def self.verify_solid_queue_tables!
46
+ conn = ActiveRecord::Base.connection
47
+ required = %w[solid_queue_jobs solid_queue_ready_executions]
48
+ missing = required.reject { |t| conn.table_exists?(t) }
49
+
50
+ unless missing.empty?
51
+ raise ConnectionError, "Missing Solid Queue tables: #{missing.join(', ')}. " \
52
+ "Is this a Solid Queue database?"
53
+ end
54
+ end
55
+
56
+ class ConnectionError < StandardError; end
57
+ end
58
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module Data
5
+ class FailedQuery
6
+ FailedJob = Struct.new(
7
+ :id, :job_id, :queue_name, :class_name, :priority,
8
+ :error_class, :error_message, :backtrace,
9
+ :active_job_id, :arguments, :failed_at, :created_at,
10
+ keyword_init: true
11
+ )
12
+
13
+ def self.fetch(filter: nil, limit: 200)
14
+ conn = ActiveRecord::Base.connection
15
+
16
+ sql = <<~SQL
17
+ SELECT
18
+ fe.id,
19
+ fe.job_id,
20
+ j.queue_name,
21
+ j.class_name,
22
+ j.priority,
23
+ j.active_job_id,
24
+ j.arguments,
25
+ j.created_at AS job_created_at,
26
+ fe.error,
27
+ fe.created_at AS failed_at
28
+ FROM solid_queue_failed_executions fe
29
+ JOIN solid_queue_jobs j ON j.id = fe.job_id
30
+ SQL
31
+
32
+ if filter && !filter.empty?
33
+ sql += " WHERE j.class_name LIKE #{conn.quote("%#{filter}%")}"
34
+ end
35
+
36
+ sql += " ORDER BY fe.created_at DESC LIMIT #{limit.to_i}"
37
+
38
+ rows = conn.select_all(sql)
39
+ rows.map do |row|
40
+ error = parse_json(row["error"])
41
+
42
+ FailedJob.new(
43
+ id: row["id"].to_i,
44
+ job_id: row["job_id"].to_i,
45
+ queue_name: row["queue_name"],
46
+ class_name: row["class_name"],
47
+ priority: row["priority"].to_i,
48
+ error_class: error["exception_class"] || error["class"] || "Unknown",
49
+ error_message: error["message"] || "No message",
50
+ backtrace: error["backtrace"] || [],
51
+ active_job_id: row["active_job_id"],
52
+ arguments: parse_json(row["arguments"]),
53
+ failed_at: parse_time(row["failed_at"]),
54
+ created_at: parse_time(row["job_created_at"])
55
+ )
56
+ end
57
+ rescue => e
58
+ []
59
+ end
60
+
61
+ def self.fetch_one(id)
62
+ conn = ActiveRecord::Base.connection
63
+
64
+ row = conn.select_one(<<~SQL)
65
+ SELECT
66
+ fe.id,
67
+ fe.job_id,
68
+ j.queue_name,
69
+ j.class_name,
70
+ j.priority,
71
+ j.active_job_id,
72
+ j.arguments,
73
+ j.created_at AS job_created_at,
74
+ fe.error,
75
+ fe.created_at AS failed_at
76
+ FROM solid_queue_failed_executions fe
77
+ JOIN solid_queue_jobs j ON j.id = fe.job_id
78
+ WHERE fe.id = #{conn.quote(id.to_i)}
79
+ SQL
80
+
81
+ return nil unless row
82
+
83
+ error = parse_json(row["error"])
84
+
85
+ FailedJob.new(
86
+ id: row["id"].to_i,
87
+ job_id: row["job_id"].to_i,
88
+ queue_name: row["queue_name"],
89
+ class_name: row["class_name"],
90
+ priority: row["priority"].to_i,
91
+ error_class: error["exception_class"] || error["class"] || "Unknown",
92
+ error_message: error["message"] || "No message",
93
+ backtrace: error["backtrace"] || [],
94
+ active_job_id: row["active_job_id"],
95
+ arguments: parse_json(row["arguments"]),
96
+ failed_at: parse_time(row["failed_at"]),
97
+ created_at: parse_time(row["job_created_at"])
98
+ )
99
+ rescue => e
100
+ nil
101
+ end
102
+
103
+ private_class_method def self.parse_json(value)
104
+ return {} if value.nil?
105
+ value.is_a?(Hash) || value.is_a?(Array) ? value : JSON.parse(value.to_s)
106
+ rescue JSON::ParserError
107
+ {}
108
+ end
109
+
110
+ private_class_method def self.parse_time(value)
111
+ return nil if value.nil?
112
+ value.is_a?(Time) ? value : Time.parse(value.to_s)
113
+ rescue
114
+ nil
115
+ end
116
+ end
117
+ end
118
+ end