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,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module Data
5
+ class JobsQuery
6
+ Job = Struct.new(
7
+ :id, :queue_name, :class_name, :priority, :status,
8
+ :active_job_id, :concurrency_key, :arguments,
9
+ :scheduled_at, :finished_at, :created_at,
10
+ :started_at, :worker_id, :expires_at,
11
+ keyword_init: true
12
+ )
13
+
14
+ def self.fetch(status:, filter: nil, queue: nil, limit: 200)
15
+ case status
16
+ when "claimed" then fetch_claimed(filter: filter, queue: queue, limit: limit)
17
+ when "blocked" then fetch_blocked(filter: filter, queue: queue, limit: limit)
18
+ when "scheduled" then fetch_scheduled(filter: filter, queue: queue, limit: limit)
19
+ when "completed" then fetch_finished(filter: filter, queue: queue, limit: limit)
20
+ else []
21
+ end
22
+ rescue => e
23
+ []
24
+ end
25
+
26
+ def self.fetch_claimed(filter: nil, queue: nil, limit: 200)
27
+ conn = ActiveRecord::Base.connection
28
+
29
+ sql = <<~SQL
30
+ SELECT
31
+ j.id, j.queue_name, j.class_name, j.priority,
32
+ j.active_job_id, j.concurrency_key, j.created_at,
33
+ ce.process_id AS worker_id,
34
+ ce.created_at AS started_at
35
+ FROM solid_queue_claimed_executions ce
36
+ JOIN solid_queue_jobs j ON j.id = ce.job_id
37
+ SQL
38
+
39
+ conditions = []
40
+ conditions << "j.queue_name = #{conn.quote(queue)}" if queue
41
+ conditions << "j.class_name LIKE #{conn.quote("%#{filter}%")}" if filter && !filter.empty?
42
+
43
+ sql += " WHERE #{conditions.join(' AND ')}" unless conditions.empty?
44
+ sql += " ORDER BY ce.job_id ASC LIMIT #{limit.to_i}"
45
+
46
+ conn.select_all(sql).map do |row|
47
+ Job.new(
48
+ id: row["id"].to_i,
49
+ queue_name: row["queue_name"],
50
+ class_name: row["class_name"],
51
+ priority: row["priority"].to_i,
52
+ status: "claimed",
53
+ active_job_id: row["active_job_id"],
54
+ concurrency_key: row["concurrency_key"],
55
+ created_at: parse_time(row["created_at"]),
56
+ worker_id: row["worker_id"]&.to_i,
57
+ started_at: parse_time(row["started_at"])
58
+ )
59
+ end
60
+ end
61
+
62
+ def self.fetch_blocked(filter: nil, queue: nil, limit: 200)
63
+ conn = ActiveRecord::Base.connection
64
+
65
+ sql = <<~SQL
66
+ SELECT
67
+ j.id, j.queue_name, j.class_name, j.priority,
68
+ j.active_job_id, j.concurrency_key, j.created_at,
69
+ be.expires_at,
70
+ be.created_at AS blocked_since
71
+ FROM solid_queue_blocked_executions be
72
+ JOIN solid_queue_jobs j ON j.id = be.job_id
73
+ SQL
74
+
75
+ conditions = []
76
+ conditions << "j.queue_name = #{conn.quote(queue)}" if queue
77
+ conditions << "j.class_name LIKE #{conn.quote("%#{filter}%")}" if filter && !filter.empty?
78
+
79
+ sql += " WHERE #{conditions.join(' AND ')}" unless conditions.empty?
80
+ sql += " ORDER BY be.job_id ASC LIMIT #{limit.to_i}"
81
+
82
+ conn.select_all(sql).map do |row|
83
+ Job.new(
84
+ id: row["id"].to_i,
85
+ queue_name: row["queue_name"],
86
+ class_name: row["class_name"],
87
+ priority: row["priority"].to_i,
88
+ status: "blocked",
89
+ active_job_id: row["active_job_id"],
90
+ concurrency_key: row["concurrency_key"],
91
+ created_at: parse_time(row["blocked_since"]),
92
+ expires_at: parse_time(row["expires_at"])
93
+ )
94
+ end
95
+ end
96
+
97
+ # Scheduled: query from scheduled_executions JOIN jobs
98
+ def self.fetch_scheduled(filter: nil, queue: nil, limit: 200)
99
+ conn = ActiveRecord::Base.connection
100
+
101
+ sql = <<~SQL
102
+ SELECT
103
+ j.id, j.queue_name, j.class_name, j.priority,
104
+ j.active_job_id, j.arguments, j.created_at,
105
+ se.scheduled_at
106
+ FROM solid_queue_scheduled_executions se
107
+ JOIN solid_queue_jobs j ON j.id = se.job_id
108
+ SQL
109
+
110
+ conditions = []
111
+ conditions << "j.queue_name = #{conn.quote(queue)}" if queue
112
+ conditions << "j.class_name LIKE #{conn.quote("%#{filter}%")}" if filter && !filter.empty?
113
+
114
+ sql += " WHERE #{conditions.join(' AND ')}" unless conditions.empty?
115
+ sql += " ORDER BY se.scheduled_at ASC, se.priority ASC LIMIT #{limit.to_i}"
116
+
117
+ conn.select_all(sql).map do |row|
118
+ Job.new(
119
+ id: row["id"].to_i,
120
+ queue_name: row["queue_name"],
121
+ class_name: row["class_name"],
122
+ priority: row["priority"].to_i,
123
+ status: "scheduled",
124
+ active_job_id: row["active_job_id"],
125
+ arguments: parse_json(row["arguments"]),
126
+ scheduled_at: parse_time(row["scheduled_at"]),
127
+ created_at: parse_time(row["created_at"])
128
+ )
129
+ end
130
+ end
131
+
132
+ # Finished: query from jobs WHERE finished_at IS NOT NULL
133
+ def self.fetch_finished(filter: nil, queue: nil, limit: 200)
134
+ conn = ActiveRecord::Base.connection
135
+
136
+ sql = <<~SQL
137
+ SELECT
138
+ j.id, j.queue_name, j.class_name, j.priority,
139
+ j.active_job_id, j.arguments, j.finished_at, j.created_at
140
+ FROM solid_queue_jobs j
141
+ WHERE j.finished_at IS NOT NULL
142
+ SQL
143
+
144
+ sql += " AND j.queue_name = #{conn.quote(queue)}" if queue
145
+ sql += " AND j.class_name LIKE #{conn.quote("%#{filter}%")}" if filter && !filter.empty?
146
+ sql += " ORDER BY j.finished_at DESC LIMIT #{limit.to_i}"
147
+
148
+ conn.select_all(sql).map do |row|
149
+ Job.new(
150
+ id: row["id"].to_i,
151
+ queue_name: row["queue_name"],
152
+ class_name: row["class_name"],
153
+ priority: row["priority"].to_i,
154
+ status: "completed",
155
+ active_job_id: row["active_job_id"],
156
+ arguments: parse_json(row["arguments"]),
157
+ finished_at: parse_time(row["finished_at"]),
158
+ created_at: parse_time(row["created_at"])
159
+ )
160
+ end
161
+ end
162
+
163
+ private_class_method def self.parse_time(value)
164
+ return nil if value.nil?
165
+ value.is_a?(Time) ? value : Time.parse(value.to_s)
166
+ rescue
167
+ nil
168
+ end
169
+
170
+ private_class_method def self.parse_json(value)
171
+ return nil if value.nil?
172
+ value.is_a?(String) ? JSON.parse(value) : value
173
+ rescue
174
+ nil
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module Data
5
+ class ProcessesQuery
6
+ Process = Struct.new(
7
+ :id, :kind, :pid, :hostname, :name, :last_heartbeat_at,
8
+ :supervisor_id, :metadata, :created_at,
9
+ keyword_init: true
10
+ ) do
11
+ def alive?(threshold: 60)
12
+ return false unless last_heartbeat_at
13
+ (Time.now.utc - last_heartbeat_at) < threshold
14
+ end
15
+
16
+ def uptime
17
+ return nil unless created_at
18
+ Time.now.utc - created_at
19
+ end
20
+
21
+ def queues
22
+ return [] unless metadata.is_a?(Hash)
23
+ metadata["queues"] || []
24
+ end
25
+
26
+ def thread_count
27
+ return nil unless metadata.is_a?(Hash)
28
+ metadata["threads"] || metadata["polling_interval"]
29
+ end
30
+ end
31
+
32
+ def self.fetch
33
+ conn = ActiveRecord::Base.connection
34
+
35
+ rows = conn.select_all(
36
+ "SELECT id, kind, pid, hostname, name, last_heartbeat_at, " \
37
+ "supervisor_id, metadata, created_at " \
38
+ "FROM solid_queue_processes WHERE kind = 'Worker' ORDER BY id"
39
+ )
40
+
41
+ rows.map do |row|
42
+ metadata = parse_json(row["metadata"])
43
+
44
+ Process.new(
45
+ id: row["id"].to_i,
46
+ kind: row["kind"],
47
+ pid: row["pid"].to_i,
48
+ hostname: row["hostname"],
49
+ name: row["name"],
50
+ last_heartbeat_at: parse_time(row["last_heartbeat_at"]),
51
+ supervisor_id: row["supervisor_id"]&.to_i,
52
+ metadata: metadata,
53
+ created_at: parse_time(row["created_at"])
54
+ )
55
+ end
56
+ rescue => e
57
+ []
58
+ end
59
+
60
+ private_class_method def self.parse_json(value)
61
+ return {} if value.nil?
62
+ value.is_a?(Hash) ? value : JSON.parse(value.to_s)
63
+ rescue JSON::ParserError
64
+ {}
65
+ end
66
+
67
+ private_class_method def self.parse_time(value)
68
+ return nil if value.nil?
69
+ value.is_a?(Time) ? value : Time.parse(value.to_s)
70
+ rescue
71
+ nil
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module Data
5
+ class QueuesQuery
6
+ QueueInfo = Struct.new(
7
+ :name, :size, :paused,
8
+ keyword_init: true
9
+ )
10
+
11
+ def self.fetch
12
+ conn = ActiveRecord::Base.connection
13
+
14
+ queue_sizes = conn.select_rows(
15
+ "SELECT queue_name, COUNT(*) FROM solid_queue_ready_executions GROUP BY queue_name ORDER BY queue_name"
16
+ ).to_h { |name, count| [name, count.to_i] }
17
+
18
+ all_queues = conn.select_values(
19
+ "SELECT DISTINCT queue_name FROM solid_queue_jobs WHERE queue_name IS NOT NULL ORDER BY queue_name"
20
+ )
21
+
22
+ paused = conn.select_values("SELECT queue_name FROM solid_queue_pauses")
23
+
24
+ all_queues.map do |name|
25
+ QueueInfo.new(
26
+ name: name,
27
+ size: queue_sizes[name] || 0,
28
+ paused: paused.include?(name)
29
+ )
30
+ end
31
+ rescue => e
32
+ []
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module Data
5
+ class Stats
6
+ attr_reader :ready, :claimed, :failed, :scheduled, :blocked,
7
+ :total_jobs, :completed_jobs, :process_count,
8
+ :processes_by_kind
9
+
10
+ def initialize(data)
11
+ @ready = data[:ready]
12
+ @claimed = data[:claimed]
13
+ @failed = data[:failed]
14
+ @scheduled = data[:scheduled]
15
+ @blocked = data[:blocked]
16
+ @total_jobs = data[:total_jobs]
17
+ @completed_jobs = data[:completed_jobs]
18
+ @process_count = data[:process_count]
19
+ @processes_by_kind = data[:processes_by_kind]
20
+ end
21
+
22
+ def self.fetch
23
+ conn = ActiveRecord::Base.connection
24
+
25
+ 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)
35
+ )
36
+ rescue => e
37
+ empty(error: e.message)
38
+ end
39
+
40
+ def self.empty(error: nil)
41
+ new(
42
+ ready: 0, claimed: 0, failed: 0, scheduled: 0, blocked: 0,
43
+ total_jobs: 0, completed_jobs: 0, process_count: 0,
44
+ processes_by_kind: {}
45
+ )
46
+ 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
+ end
64
+ end
65
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ # Watches source files for changes and hot-reloads them using Kernel#load.
5
+ # Only active when initialized — zero overhead in production.
6
+ class DevReloader
7
+ def initialize(watch_dir)
8
+ @watch_dir = watch_dir
9
+ @file_mtimes = {}
10
+ snapshot_all!
11
+ end
12
+
13
+ # Returns true if any files changed and were reloaded.
14
+ def check!
15
+ changed = false
16
+
17
+ ruby_files.each do |path|
18
+ mtime = File.mtime(path)
19
+ if @file_mtimes[path] != mtime
20
+ @file_mtimes[path] = mtime
21
+ begin
22
+ suppress_warnings { load(path) }
23
+ changed = true
24
+ rescue SyntaxError, StandardError => e
25
+ # Don't crash the TUI on a syntax error mid-edit.
26
+ # The old code stays loaded — user just fixes and saves again.
27
+ $stderr.puts "[reload] Error loading #{File.basename(path)}: #{e.message}" if ENV["DEBUG"]
28
+ end
29
+ end
30
+ end
31
+
32
+ changed
33
+ end
34
+
35
+ private
36
+
37
+ def snapshot_all!
38
+ ruby_files.each { |path| @file_mtimes[path] = File.mtime(path) }
39
+ end
40
+
41
+ def ruby_files
42
+ Dir.glob(File.join(@watch_dir, "**", "*.rb"))
43
+ end
44
+
45
+ def suppress_warnings
46
+ old_verbose = $VERBOSE
47
+ $VERBOSE = nil
48
+ yield
49
+ ensure
50
+ $VERBOSE = old_verbose
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module Views
5
+ class BlockedView
6
+ def initialize(tui)
7
+ @tui = tui
8
+ @table_state = RatatuiRuby::TableState.new(nil)
9
+ @table_state.select(0)
10
+ @selected_row = 0
11
+ @jobs = []
12
+ end
13
+
14
+ def update(jobs:)
15
+ @jobs = jobs
16
+ @selected_row = @selected_row.clamp(0, [@jobs.size - 1, 0].max)
17
+ @table_state.select(@selected_row)
18
+ end
19
+
20
+ def render(frame, area)
21
+ columns = [
22
+ { key: :id, label: "ID", width: 8 },
23
+ { key: :queue_name, label: "QUEUE", width: 14 },
24
+ { key: :class_name, label: "CLASS", width: :fill },
25
+ { key: :priority, label: "PRI", width: 5 },
26
+ { key: :concurrency_key, label: "CONCURRENCY KEY", width: :fill },
27
+ { key: :expires_at, label: "EXPIRES AT", width: 20 },
28
+ { key: :blocked_since, label: "BLOCKED SINCE", width: 14 }
29
+ ]
30
+
31
+ rows = @jobs.map do |job|
32
+ {
33
+ id: job.id,
34
+ queue_name: job.queue_name,
35
+ class_name: job.class_name,
36
+ priority: job.priority,
37
+ concurrency_key: job.concurrency_key || "n/a",
38
+ expires_at: format_time(job.expires_at),
39
+ blocked_since: time_ago(job.created_at)
40
+ }
41
+ end
42
+
43
+ table = Components::JobTable.new(
44
+ @tui,
45
+ title: "Blocked",
46
+ columns: columns,
47
+ rows: rows,
48
+ selected_row: @selected_row,
49
+ empty_message: "No blocked jobs"
50
+ )
51
+
52
+ table.render(frame, area, @table_state)
53
+ end
54
+
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
+ end
120
+ end
121
+ end