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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/exe/sqtui +6 -0
- data/lib/solid_queue_tui/actions/discard_job.rb +34 -0
- data/lib/solid_queue_tui/actions/discard_scheduled_job.rb +33 -0
- data/lib/solid_queue_tui/actions/dispatch_scheduled_job.rb +35 -0
- data/lib/solid_queue_tui/actions/retry_job.rb +76 -0
- data/lib/solid_queue_tui/application.rb +468 -0
- data/lib/solid_queue_tui/cli.rb +48 -0
- data/lib/solid_queue_tui/components/header.rb +105 -0
- data/lib/solid_queue_tui/components/help_bar.rb +77 -0
- data/lib/solid_queue_tui/components/job_table.rb +122 -0
- data/lib/solid_queue_tui/connection.rb +58 -0
- data/lib/solid_queue_tui/data/failed_query.rb +118 -0
- data/lib/solid_queue_tui/data/jobs_query.rb +178 -0
- data/lib/solid_queue_tui/data/processes_query.rb +75 -0
- data/lib/solid_queue_tui/data/queues_query.rb +36 -0
- data/lib/solid_queue_tui/data/stats.rb +65 -0
- data/lib/solid_queue_tui/dev_reloader.rb +53 -0
- data/lib/solid_queue_tui/version.rb +5 -0
- data/lib/solid_queue_tui/views/blocked_view.rb +121 -0
- data/lib/solid_queue_tui/views/dashboard_view.rb +187 -0
- data/lib/solid_queue_tui/views/failed_view.rb +298 -0
- data/lib/solid_queue_tui/views/finished_view.rb +216 -0
- data/lib/solid_queue_tui/views/in_progress_view.rb +114 -0
- data/lib/solid_queue_tui/views/job_detail_view.rb +236 -0
- data/lib/solid_queue_tui/views/processes_view.rb +142 -0
- data/lib/solid_queue_tui/views/queues_view.rb +96 -0
- data/lib/solid_queue_tui/views/scheduled_view.rb +215 -0
- data/lib/solid_queue_tui.rb +46 -0
- 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,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
|