sqdash 0.1.0 → 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 +4 -4
- data/.rubocop.yml +71 -0
- data/README.md +67 -3
- data/Rakefile +8 -1
- data/exe/sqdash +2 -0
- data/lib/sqdash/autocomplete.rb +164 -0
- data/lib/sqdash/cli.rb +104 -665
- data/lib/sqdash/config.rb +39 -0
- data/lib/sqdash/database.rb +6 -4
- data/lib/sqdash/input_handler.rb +281 -0
- data/lib/sqdash/renderer.rb +256 -0
- data/lib/sqdash/version.rb +1 -1
- data/lib/sqdash.rb +4 -0
- metadata +9 -4
data/lib/sqdash/cli.rb
CHANGED
|
@@ -1,16 +1,55 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "io/console"
|
|
4
|
-
require "json"
|
|
5
|
-
|
|
6
3
|
module Sqdash
|
|
7
4
|
class CLI
|
|
5
|
+
include Renderer
|
|
6
|
+
include InputHandler
|
|
7
|
+
include Autocomplete
|
|
8
|
+
|
|
8
9
|
DEFAULT_DB_URL = "postgres://sqd:sqd@localhost:5432/sqd_web_development_queue"
|
|
9
10
|
|
|
11
|
+
HELP_TEXT = <<~HELP
|
|
12
|
+
Usage: sqdash [database-url] [options]
|
|
13
|
+
|
|
14
|
+
A terminal dashboard for Rails 8's Solid Queue.
|
|
15
|
+
|
|
16
|
+
Arguments:
|
|
17
|
+
database-url Database connection URL (optional)
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
-c, --config FILE Path to config file (default: .sqdash.yml or ~/.sqdash.yml)
|
|
21
|
+
-h, --help Show this help message
|
|
22
|
+
-v, --version Show version
|
|
23
|
+
|
|
24
|
+
Config file (~/.sqdash.yml or .sqdash.yml):
|
|
25
|
+
database_url: postgres://user:pass@host:5432/myapp
|
|
26
|
+
|
|
27
|
+
Connection priority: CLI arg > DATABASE_URL env > .sqdash.yml > ~/.sqdash.yml > default
|
|
28
|
+
|
|
29
|
+
Keybindings:
|
|
30
|
+
↑/↓ Navigate job list
|
|
31
|
+
Enter View job details
|
|
32
|
+
/ Filter jobs (by class, queue, or ID)
|
|
33
|
+
: Command mode (sort, view)
|
|
34
|
+
r Retry failed job
|
|
35
|
+
d Discard failed job
|
|
36
|
+
Space Refresh data
|
|
37
|
+
q Quit
|
|
38
|
+
|
|
39
|
+
Commands (in : mode):
|
|
40
|
+
sort created|id asc|desc Sort jobs
|
|
41
|
+
view all|failed|completed|pending Filter by status
|
|
42
|
+
|
|
43
|
+
Examples:
|
|
44
|
+
sqdash
|
|
45
|
+
sqdash postgres://user:pass@host:5432/myapp_production
|
|
46
|
+
DATABASE_URL=postgres://... sqdash
|
|
47
|
+
HELP
|
|
48
|
+
|
|
10
49
|
COMMANDS = {
|
|
11
50
|
"sort" => {
|
|
12
|
-
"created" => [
|
|
13
|
-
"id" => [
|
|
51
|
+
"created" => %w[asc desc],
|
|
52
|
+
"id" => %w[asc desc]
|
|
14
53
|
},
|
|
15
54
|
"view" => {
|
|
16
55
|
"all" => [],
|
|
@@ -21,18 +60,45 @@ module Sqdash
|
|
|
21
60
|
}.freeze
|
|
22
61
|
|
|
23
62
|
def self.start
|
|
24
|
-
|
|
63
|
+
args = ARGV.dup
|
|
64
|
+
config_path = nil
|
|
65
|
+
|
|
66
|
+
if (idx = args.index("-c") || args.index("--config"))
|
|
67
|
+
args.delete_at(idx)
|
|
68
|
+
config_path = args.delete_at(idx)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
case args[0]
|
|
72
|
+
when "-h", "--help"
|
|
73
|
+
puts HELP_TEXT
|
|
74
|
+
exit
|
|
75
|
+
when "-v", "--version"
|
|
76
|
+
puts "sqdash #{Sqdash::VERSION}"
|
|
77
|
+
exit
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
new(db_url: args[0], config_path: config_path).run
|
|
25
81
|
end
|
|
26
82
|
|
|
83
|
+
def initialize(db_url: nil, config_path: nil)
|
|
84
|
+
@db_url_arg = db_url
|
|
85
|
+
@config_path = config_path
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
PAGE_SIZE = 200
|
|
89
|
+
|
|
27
90
|
def run
|
|
28
91
|
Database.connect!(resolve_db_url)
|
|
29
92
|
@selected = 0
|
|
30
93
|
@scroll_offset = 0
|
|
31
94
|
@filter_text = ""
|
|
32
95
|
@filter_mode = false
|
|
33
|
-
@view = :all
|
|
96
|
+
@view = :all
|
|
34
97
|
@jobs = []
|
|
35
98
|
@failed_ids = []
|
|
99
|
+
@total_count = 0
|
|
100
|
+
@page = 0
|
|
101
|
+
@all_loaded = false
|
|
36
102
|
@message = nil
|
|
37
103
|
@sort_column = :created_at
|
|
38
104
|
@sort_dir = :desc
|
|
@@ -52,13 +118,10 @@ module Sqdash
|
|
|
52
118
|
private
|
|
53
119
|
|
|
54
120
|
def resolve_db_url
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
print "\e[?25h"
|
|
60
|
-
print "\e[2J\e[H"
|
|
61
|
-
puts "Goodbye!"
|
|
121
|
+
@db_url_arg ||
|
|
122
|
+
ENV["DATABASE_URL"] ||
|
|
123
|
+
Config.load(@config_path).database_url ||
|
|
124
|
+
DEFAULT_DB_URL
|
|
62
125
|
end
|
|
63
126
|
|
|
64
127
|
def trap_resize
|
|
@@ -67,48 +130,32 @@ module Sqdash
|
|
|
67
130
|
end
|
|
68
131
|
end
|
|
69
132
|
|
|
70
|
-
def
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
visible = str.gsub(/\e\[[0-9;]*m/, "")
|
|
75
|
-
return str if visible.length <= max
|
|
76
|
-
|
|
77
|
-
# Truncate by walking through the string, tracking visible chars
|
|
78
|
-
result = +""
|
|
79
|
-
visible_count = 0
|
|
80
|
-
i = 0
|
|
81
|
-
while i < str.length && visible_count < max
|
|
82
|
-
if str[i] == "\e" && str[i..] =~ /\A(\e\[[0-9;]*m)/
|
|
83
|
-
result << $1
|
|
84
|
-
i += $1.length
|
|
85
|
-
else
|
|
86
|
-
result << str[i]
|
|
87
|
-
visible_count += 1
|
|
88
|
-
i += 1
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
result << "\e[0m"
|
|
92
|
-
end
|
|
133
|
+
def load_data
|
|
134
|
+
@failed_ids = Models::FailedExecution.pluck(:job_id)
|
|
135
|
+
@page = 0
|
|
136
|
+
@all_loaded = false
|
|
93
137
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
138
|
+
scope = build_scope
|
|
139
|
+
@total_count = scope.count
|
|
140
|
+
@jobs = scope.limit(PAGE_SIZE).offset(0).to_a
|
|
141
|
+
@all_loaded = @jobs.length < PAGE_SIZE
|
|
97
142
|
|
|
98
|
-
|
|
99
|
-
|
|
143
|
+
@selected = @selected.clamp(0, [@jobs.length - 1, 0].max)
|
|
144
|
+
adjust_scroll
|
|
100
145
|
end
|
|
101
146
|
|
|
102
|
-
def
|
|
103
|
-
|
|
104
|
-
end
|
|
147
|
+
def load_more
|
|
148
|
+
return if @all_loaded
|
|
105
149
|
|
|
106
|
-
|
|
107
|
-
|
|
150
|
+
@page += 1
|
|
151
|
+
new_jobs = build_scope.limit(PAGE_SIZE).offset(@page * PAGE_SIZE).to_a
|
|
152
|
+
@jobs.concat(new_jobs)
|
|
153
|
+
@all_loaded = new_jobs.length < PAGE_SIZE
|
|
154
|
+
end
|
|
108
155
|
|
|
156
|
+
def build_scope
|
|
109
157
|
scope = Models::Job.order(@sort_column => @sort_dir)
|
|
110
158
|
|
|
111
|
-
# View filter
|
|
112
159
|
case @view
|
|
113
160
|
when :failed
|
|
114
161
|
scope = @failed_ids.any? ? scope.where(id: @failed_ids) : scope.none
|
|
@@ -118,21 +165,15 @@ module Sqdash
|
|
|
118
165
|
scope = scope.where(finished_at: nil).where.not(id: @failed_ids)
|
|
119
166
|
end
|
|
120
167
|
|
|
121
|
-
@
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
job.class_name.downcase.include?(query) ||
|
|
128
|
-
job.queue_name.downcase.include?(query) ||
|
|
129
|
-
job.id.to_s.include?(query)
|
|
130
|
-
end
|
|
168
|
+
if @filter_text.length.positive?
|
|
169
|
+
query = "%#{@filter_text}%"
|
|
170
|
+
scope = scope.where(
|
|
171
|
+
"LOWER(class_name) LIKE LOWER(?) OR LOWER(queue_name) LIKE LOWER(?) OR CAST(id AS TEXT) LIKE ?",
|
|
172
|
+
query, query, query
|
|
173
|
+
)
|
|
131
174
|
end
|
|
132
175
|
|
|
133
|
-
|
|
134
|
-
@selected = [[@selected, @jobs.length - 1].min, 0].max
|
|
135
|
-
adjust_scroll
|
|
176
|
+
scope
|
|
136
177
|
end
|
|
137
178
|
|
|
138
179
|
def adjust_scroll
|
|
@@ -141,6 +182,8 @@ module Sqdash
|
|
|
141
182
|
elsif @selected >= @scroll_offset + visible_rows
|
|
142
183
|
@scroll_offset = @selected - visible_rows + 1
|
|
143
184
|
end
|
|
185
|
+
|
|
186
|
+
load_more if !@all_loaded && @selected >= @jobs.length - (PAGE_SIZE / 4)
|
|
144
187
|
end
|
|
145
188
|
|
|
146
189
|
def job_status(job)
|
|
@@ -152,609 +195,5 @@ module Sqdash
|
|
|
152
195
|
:pending
|
|
153
196
|
end
|
|
154
197
|
end
|
|
155
|
-
|
|
156
|
-
def status_text(status)
|
|
157
|
-
case status
|
|
158
|
-
when :failed then "\e[31m● failed\e[0m "
|
|
159
|
-
when :completed then "\e[32m● completed\e[0m"
|
|
160
|
-
when :pending then "\e[33m● pending\e[0m "
|
|
161
|
-
end
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
def view_label
|
|
165
|
-
case @view
|
|
166
|
-
when :all then "ALL"
|
|
167
|
-
when :failed then "\e[31mFAILED\e[0m"
|
|
168
|
-
when :completed then "\e[32mCOMPLETED\e[0m"
|
|
169
|
-
when :pending then "\e[33mPENDING\e[0m"
|
|
170
|
-
end
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
def full_draw
|
|
174
|
-
print "\e[?25l"
|
|
175
|
-
print "\e[2J\e[H"
|
|
176
|
-
draw_screen
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
def column_widths
|
|
180
|
-
w = terminal_width
|
|
181
|
-
# Fixed columns: prefix(2) + ID(8) + Status(14) + Created(12) = 36
|
|
182
|
-
remaining = [w - 36, 10].max
|
|
183
|
-
# Job gets 65% of remaining, Queue gets 35%
|
|
184
|
-
job_w = [remaining * 65 / 100, 6].max
|
|
185
|
-
queue_w = [remaining - job_w, 4].max
|
|
186
|
-
{ id: 8, job: job_w, queue: queue_w, status: 14, created: 12 }
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
def draw_screen
|
|
190
|
-
if @detail_job
|
|
191
|
-
draw_detail_screen
|
|
192
|
-
else
|
|
193
|
-
draw_list_screen
|
|
194
|
-
end
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
def draw_list_screen
|
|
198
|
-
print "\e[H" # cursor home, no clear
|
|
199
|
-
w = terminal_width
|
|
200
|
-
rows = visible_rows
|
|
201
|
-
cols = column_widths
|
|
202
|
-
|
|
203
|
-
# Header
|
|
204
|
-
puts truncate("\e[1;36m sqdash \e[0m\e[36m Solid Queue Dashboard v#{Sqdash::VERSION}\e[0m", w) + "\e[K"
|
|
205
|
-
puts "\e[90m#{"─" * w}\e[0m"
|
|
206
|
-
|
|
207
|
-
# Stats bar
|
|
208
|
-
total = Models::Job.count
|
|
209
|
-
completed = Models::Job.where.not(finished_at: nil).count
|
|
210
|
-
failed = @failed_ids.length
|
|
211
|
-
pending = Models::ReadyExecution.count
|
|
212
|
-
sort_label = "#{@sort_column == :id ? "ID" : "Created"} #{@sort_dir == :asc ? "↑" : "↓"}"
|
|
213
|
-
stats = " \e[1mTotal:\e[0m #{total} \e[32m✓ #{completed}\e[0m \e[31m✗ #{failed}\e[0m \e[33m◌ #{pending}\e[0m │ View: #{view_label} │ Sort: #{sort_label} │ Showing: #{@jobs.length}"
|
|
214
|
-
puts truncate(stats, w) + "\e[K"
|
|
215
|
-
|
|
216
|
-
# Filter / Command bar
|
|
217
|
-
if @command_mode
|
|
218
|
-
print "\e[?25h"
|
|
219
|
-
hint = command_autocomplete_hint
|
|
220
|
-
puts truncate(" \e[1;35m:\e[0m #{@command_text}\e[90m#{hint}\e[0m \e[90m<Tab> complete <Enter> run <Esc> cancel\e[0m", w) + "\e[K"
|
|
221
|
-
elsif @filter_mode
|
|
222
|
-
print "\e[?25h" # show cursor in filter mode
|
|
223
|
-
hint = autocomplete_hint
|
|
224
|
-
puts truncate(" \e[1;33m/\e[0m #{@filter_text}\e[90m#{hint}\e[0m \e[90m<Tab> complete <Esc> cancel\e[0m", w) + "\e[K"
|
|
225
|
-
elsif @filter_text.length > 0
|
|
226
|
-
puts truncate(" \e[33m/#{@filter_text}\e[0m \e[90m(/ to edit, Esc to clear)\e[0m", w) + "\e[K"
|
|
227
|
-
else
|
|
228
|
-
puts "\e[K"
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
puts "\e[90m#{"─" * w}\e[0m"
|
|
232
|
-
|
|
233
|
-
# Column headers
|
|
234
|
-
puts truncate("\e[1m #{"ID".ljust(cols[:id])}#{"Job".ljust(cols[:job])}#{"Queue".ljust(cols[:queue])}#{"Status".ljust(cols[:status])}Created\e[0m", w) + "\e[K"
|
|
235
|
-
|
|
236
|
-
# Job list
|
|
237
|
-
visible_jobs = @jobs[@scroll_offset, rows] || []
|
|
238
|
-
|
|
239
|
-
visible_jobs.each_with_index do |job, i|
|
|
240
|
-
actual_index = @scroll_offset + i
|
|
241
|
-
status = job_status(job)
|
|
242
|
-
is_selected = actual_index == @selected
|
|
243
|
-
created = job.created_at&.strftime("%m/%d %H:%M") || "—"
|
|
244
|
-
|
|
245
|
-
line = "#{job.id.to_s.ljust(cols[:id])}#{job.class_name[0, cols[:job] - 1].ljust(cols[:job])}#{job.queue_name[0, cols[:queue] - 1].ljust(cols[:queue])}#{status_text(status)} #{created}"
|
|
246
|
-
|
|
247
|
-
if is_selected
|
|
248
|
-
puts truncate("\e[7m▸ #{line}\e[0m", w) + "\e[K"
|
|
249
|
-
else
|
|
250
|
-
puts truncate(" #{line}", w) + "\e[K"
|
|
251
|
-
end
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
# Clear remaining rows
|
|
255
|
-
(rows - visible_jobs.length).times { puts "\e[K" }
|
|
256
|
-
|
|
257
|
-
# Scrollbar hint
|
|
258
|
-
puts "\e[90m#{"─" * w}\e[0m"
|
|
259
|
-
|
|
260
|
-
# Message or footer
|
|
261
|
-
if @message
|
|
262
|
-
puts " \e[1;32m#{@message}\e[0m\e[K"
|
|
263
|
-
@message = nil
|
|
264
|
-
else
|
|
265
|
-
puts truncate(" \e[90m↑↓ Navigate Enter Detail /Filter :Command r Retry d Discard q Quit\e[0m", w) + "\e[K"
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
# Position info
|
|
269
|
-
if @jobs.length > 0
|
|
270
|
-
pos = "#{@selected + 1}/#{@jobs.length}"
|
|
271
|
-
print "\e[#{terminal_height};#{w - pos.length}H\e[90m#{pos}\e[0m"
|
|
272
|
-
end
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
def handle_input
|
|
276
|
-
@saved_stty = `stty -g`.chomp
|
|
277
|
-
system("stty", "-echo", "-icanon", "min", "1")
|
|
278
|
-
loop do
|
|
279
|
-
if @needs_redraw
|
|
280
|
-
@needs_redraw = false
|
|
281
|
-
adjust_scroll
|
|
282
|
-
full_draw
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
key = read_key
|
|
286
|
-
|
|
287
|
-
unless key
|
|
288
|
-
# No input — auto-refresh data on idle
|
|
289
|
-
if @detail_job
|
|
290
|
-
@detail_job.reload
|
|
291
|
-
else
|
|
292
|
-
load_data
|
|
293
|
-
end
|
|
294
|
-
draw_screen
|
|
295
|
-
next
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
if @detail_job
|
|
299
|
-
handle_detail_input(key)
|
|
300
|
-
elsif @command_mode
|
|
301
|
-
handle_command_input(key)
|
|
302
|
-
elsif @filter_mode
|
|
303
|
-
handle_filter_input(key)
|
|
304
|
-
else
|
|
305
|
-
handle_normal_input(key)
|
|
306
|
-
end
|
|
307
|
-
|
|
308
|
-
draw_screen
|
|
309
|
-
end
|
|
310
|
-
ensure
|
|
311
|
-
system("stty", @saved_stty) if @saved_stty
|
|
312
|
-
end
|
|
313
|
-
|
|
314
|
-
def read_key
|
|
315
|
-
ready = IO.select([$stdin], nil, nil, 1)
|
|
316
|
-
return nil unless ready
|
|
317
|
-
|
|
318
|
-
$stdin.getc
|
|
319
|
-
end
|
|
320
|
-
|
|
321
|
-
def handle_filter_input(key)
|
|
322
|
-
case key
|
|
323
|
-
when "\r", "\n" # Enter — confirm filter
|
|
324
|
-
@filter_mode = false
|
|
325
|
-
print "\e[?25l"
|
|
326
|
-
load_data
|
|
327
|
-
when "\e" # Escape — cancel filter (drain arrow key bytes)
|
|
328
|
-
$stdin.read_nonblock(2) rescue nil
|
|
329
|
-
@filter_mode = false
|
|
330
|
-
@filter_text = ""
|
|
331
|
-
print "\e[?25l"
|
|
332
|
-
load_data
|
|
333
|
-
when "\t" # Tab — autocomplete
|
|
334
|
-
autocomplete_filter
|
|
335
|
-
when "\u007F", "\b" # Backspace
|
|
336
|
-
@filter_text = @filter_text[0..-2]
|
|
337
|
-
load_data
|
|
338
|
-
else
|
|
339
|
-
if key.match?(/[[:print:]]/)
|
|
340
|
-
@filter_text += key
|
|
341
|
-
load_data
|
|
342
|
-
end
|
|
343
|
-
end
|
|
344
|
-
end
|
|
345
|
-
|
|
346
|
-
def autocomplete_filter
|
|
347
|
-
return if @filter_text.empty?
|
|
348
|
-
|
|
349
|
-
query = @filter_text.downcase
|
|
350
|
-
|
|
351
|
-
# Collect all completable values
|
|
352
|
-
candidates = (
|
|
353
|
-
Models::Job.distinct.pluck(:class_name) +
|
|
354
|
-
Models::Job.distinct.pluck(:queue_name)
|
|
355
|
-
).uniq
|
|
356
|
-
|
|
357
|
-
matches = candidates.select { |c| c.downcase.start_with?(query) }
|
|
358
|
-
|
|
359
|
-
if matches.length == 1
|
|
360
|
-
# Exact single match — complete it
|
|
361
|
-
@filter_text = matches.first
|
|
362
|
-
elsif matches.length > 1
|
|
363
|
-
# Multiple matches — complete to common prefix
|
|
364
|
-
@filter_text = common_prefix(matches)
|
|
365
|
-
end
|
|
366
|
-
|
|
367
|
-
load_data
|
|
368
|
-
end
|
|
369
|
-
|
|
370
|
-
def autocomplete_hint
|
|
371
|
-
return "" if @filter_text.empty?
|
|
372
|
-
|
|
373
|
-
query = @filter_text.downcase
|
|
374
|
-
candidates = (
|
|
375
|
-
Models::Job.distinct.pluck(:class_name) +
|
|
376
|
-
Models::Job.distinct.pluck(:queue_name)
|
|
377
|
-
).uniq
|
|
378
|
-
|
|
379
|
-
matches = candidates.select { |c| c.downcase.start_with?(query) }
|
|
380
|
-
|
|
381
|
-
if matches.length == 1
|
|
382
|
-
matches.first[@filter_text.length..]
|
|
383
|
-
elsif matches.length > 1
|
|
384
|
-
prefix = common_prefix(matches)
|
|
385
|
-
remaining = prefix[@filter_text.length..] || ""
|
|
386
|
-
remaining + " (#{matches.length} matches)"
|
|
387
|
-
else
|
|
388
|
-
" (no matches)"
|
|
389
|
-
end
|
|
390
|
-
end
|
|
391
|
-
|
|
392
|
-
def common_prefix(strings)
|
|
393
|
-
return "" if strings.empty?
|
|
394
|
-
|
|
395
|
-
prefix = strings.first
|
|
396
|
-
strings.each do |s|
|
|
397
|
-
prefix = prefix[0...prefix.length].chars.take_while.with_index { |c, i| s[i]&.downcase == c.downcase }.join
|
|
398
|
-
end
|
|
399
|
-
prefix
|
|
400
|
-
end
|
|
401
|
-
|
|
402
|
-
def handle_normal_input(key)
|
|
403
|
-
case key
|
|
404
|
-
when "\e"
|
|
405
|
-
next_chars = $stdin.read_nonblock(2) rescue nil
|
|
406
|
-
case next_chars
|
|
407
|
-
when "[A" # up
|
|
408
|
-
@selected = [0, @selected - 1].max
|
|
409
|
-
adjust_scroll
|
|
410
|
-
when "[B" # down
|
|
411
|
-
@selected = [@jobs.length - 1, @selected + 1].min
|
|
412
|
-
adjust_scroll
|
|
413
|
-
when nil # bare Escape — clear active filter
|
|
414
|
-
if @filter_text.length > 0
|
|
415
|
-
@filter_text = ""
|
|
416
|
-
load_data
|
|
417
|
-
end
|
|
418
|
-
end
|
|
419
|
-
when "q"
|
|
420
|
-
throw(:quit)
|
|
421
|
-
when "/"
|
|
422
|
-
@filter_mode = true
|
|
423
|
-
@filter_text = ""
|
|
424
|
-
when ":"
|
|
425
|
-
@command_mode = true
|
|
426
|
-
@command_text = ""
|
|
427
|
-
when "r"
|
|
428
|
-
retry_selected
|
|
429
|
-
when "d"
|
|
430
|
-
discard_selected
|
|
431
|
-
when "\r", "\n"
|
|
432
|
-
show_detail
|
|
433
|
-
when " "
|
|
434
|
-
load_data
|
|
435
|
-
end
|
|
436
|
-
end
|
|
437
|
-
|
|
438
|
-
def switch_view(view)
|
|
439
|
-
@view = view
|
|
440
|
-
@selected = 0
|
|
441
|
-
@scroll_offset = 0
|
|
442
|
-
load_data
|
|
443
|
-
end
|
|
444
|
-
|
|
445
|
-
def handle_command_input(key)
|
|
446
|
-
case key
|
|
447
|
-
when "\r", "\n" # Enter — execute command
|
|
448
|
-
execute_command
|
|
449
|
-
@command_mode = false
|
|
450
|
-
@command_text = ""
|
|
451
|
-
print "\e[?25l"
|
|
452
|
-
when "\e" # Escape — cancel (drain arrow key bytes)
|
|
453
|
-
$stdin.read_nonblock(2) rescue nil
|
|
454
|
-
@command_mode = false
|
|
455
|
-
@command_text = ""
|
|
456
|
-
print "\e[?25l"
|
|
457
|
-
when "\t" # Tab — autocomplete
|
|
458
|
-
autocomplete_command
|
|
459
|
-
when "\u007F", "\b" # Backspace
|
|
460
|
-
@command_text = @command_text[0..-2]
|
|
461
|
-
else
|
|
462
|
-
if key.match?(/[[:print:]]/)
|
|
463
|
-
@command_text += key
|
|
464
|
-
end
|
|
465
|
-
end
|
|
466
|
-
end
|
|
467
|
-
|
|
468
|
-
def execute_command
|
|
469
|
-
parts = @command_text.strip.split(/\s+/)
|
|
470
|
-
return if parts.empty?
|
|
471
|
-
|
|
472
|
-
case parts[0]
|
|
473
|
-
when "sort"
|
|
474
|
-
field = parts[1] || "created"
|
|
475
|
-
direction = parts[2] || "desc"
|
|
476
|
-
case field
|
|
477
|
-
when "created"
|
|
478
|
-
@sort_column = :created_at
|
|
479
|
-
when "id"
|
|
480
|
-
@sort_column = :id
|
|
481
|
-
else
|
|
482
|
-
@message = "Unknown sort field: #{field}"
|
|
483
|
-
return
|
|
484
|
-
end
|
|
485
|
-
case direction
|
|
486
|
-
when "asc" then @sort_dir = :asc
|
|
487
|
-
when "desc" then @sort_dir = :desc
|
|
488
|
-
else
|
|
489
|
-
@message = "Unknown sort direction: #{direction}"
|
|
490
|
-
return
|
|
491
|
-
end
|
|
492
|
-
@selected = 0
|
|
493
|
-
@scroll_offset = 0
|
|
494
|
-
load_data
|
|
495
|
-
when "view"
|
|
496
|
-
target = parts[1] || "all"
|
|
497
|
-
case target
|
|
498
|
-
when "all" then switch_view(:all)
|
|
499
|
-
when "failed" then switch_view(:failed)
|
|
500
|
-
when "completed" then switch_view(:completed)
|
|
501
|
-
when "pending" then switch_view(:pending)
|
|
502
|
-
else
|
|
503
|
-
@message = "Unknown view: #{target}"
|
|
504
|
-
end
|
|
505
|
-
else
|
|
506
|
-
@message = "Unknown command: #{parts[0]}"
|
|
507
|
-
end
|
|
508
|
-
end
|
|
509
|
-
|
|
510
|
-
def autocomplete_command
|
|
511
|
-
return if @command_text.empty?
|
|
512
|
-
|
|
513
|
-
parts = @command_text.strip.split(/\s+/)
|
|
514
|
-
# If text ends with space, we're starting a new word
|
|
515
|
-
completing_new_word = @command_text.end_with?(" ")
|
|
516
|
-
|
|
517
|
-
if completing_new_word
|
|
518
|
-
case parts.length
|
|
519
|
-
when 1
|
|
520
|
-
# After first word + space, complete second word
|
|
521
|
-
subtree = COMMANDS[parts[0]]
|
|
522
|
-
return unless subtree.is_a?(Hash)
|
|
523
|
-
completed = complete_word("", subtree.keys)
|
|
524
|
-
@command_text = "#{parts[0]} #{completed}" if completed
|
|
525
|
-
when 2
|
|
526
|
-
# After second word + space, complete third word
|
|
527
|
-
subtree = COMMANDS.dig(parts[0], parts[1])
|
|
528
|
-
return unless subtree.is_a?(Array) && subtree.any?
|
|
529
|
-
completed = complete_word("", subtree)
|
|
530
|
-
@command_text = "#{parts[0]} #{parts[1]} #{completed}" if completed
|
|
531
|
-
end
|
|
532
|
-
else
|
|
533
|
-
case parts.length
|
|
534
|
-
when 1
|
|
535
|
-
completed = complete_word(parts[0], COMMANDS.keys)
|
|
536
|
-
@command_text = completed if completed
|
|
537
|
-
when 2
|
|
538
|
-
subtree = COMMANDS[parts[0]]
|
|
539
|
-
return unless subtree.is_a?(Hash)
|
|
540
|
-
completed = complete_word(parts[1], subtree.keys)
|
|
541
|
-
@command_text = "#{parts[0]} #{completed}" if completed
|
|
542
|
-
when 3
|
|
543
|
-
subtree = COMMANDS.dig(parts[0], parts[1])
|
|
544
|
-
return unless subtree.is_a?(Array) && subtree.any?
|
|
545
|
-
completed = complete_word(parts[2], subtree)
|
|
546
|
-
@command_text = "#{parts[0]} #{parts[1]} #{completed}" if completed
|
|
547
|
-
end
|
|
548
|
-
end
|
|
549
|
-
end
|
|
550
|
-
|
|
551
|
-
def complete_word(partial, candidates)
|
|
552
|
-
matches = candidates.select { |c| c.downcase.start_with?(partial.downcase) }
|
|
553
|
-
if matches.length == 1
|
|
554
|
-
matches.first
|
|
555
|
-
elsif matches.length > 1
|
|
556
|
-
prefix = common_prefix(matches)
|
|
557
|
-
# Only return if the prefix actually advances beyond what's typed
|
|
558
|
-
prefix.length > partial.length ? prefix : nil
|
|
559
|
-
end
|
|
560
|
-
end
|
|
561
|
-
|
|
562
|
-
def command_autocomplete_hint
|
|
563
|
-
return "" if @command_text.empty?
|
|
564
|
-
|
|
565
|
-
parts = @command_text.strip.split(/\s+/)
|
|
566
|
-
completing_new_word = @command_text.end_with?(" ")
|
|
567
|
-
|
|
568
|
-
if completing_new_word
|
|
569
|
-
case parts.length
|
|
570
|
-
when 1
|
|
571
|
-
subtree = COMMANDS[parts[0]]
|
|
572
|
-
return "" unless subtree.is_a?(Hash)
|
|
573
|
-
hint_for_candidates("", subtree.keys)
|
|
574
|
-
when 2
|
|
575
|
-
subtree = COMMANDS.dig(parts[0], parts[1])
|
|
576
|
-
return "" unless subtree.is_a?(Array) && subtree.any?
|
|
577
|
-
hint_for_candidates("", subtree)
|
|
578
|
-
else
|
|
579
|
-
""
|
|
580
|
-
end
|
|
581
|
-
else
|
|
582
|
-
case parts.length
|
|
583
|
-
when 1
|
|
584
|
-
hint_for_candidates(parts[0], COMMANDS.keys)
|
|
585
|
-
when 2
|
|
586
|
-
subtree = COMMANDS[parts[0]]
|
|
587
|
-
return "" unless subtree.is_a?(Hash)
|
|
588
|
-
hint_for_candidates(parts[1], subtree.keys)
|
|
589
|
-
when 3
|
|
590
|
-
subtree = COMMANDS.dig(parts[0], parts[1])
|
|
591
|
-
return "" unless subtree.is_a?(Array) && subtree.any?
|
|
592
|
-
hint_for_candidates(parts[2], subtree)
|
|
593
|
-
else
|
|
594
|
-
""
|
|
595
|
-
end
|
|
596
|
-
end
|
|
597
|
-
end
|
|
598
|
-
|
|
599
|
-
def hint_for_candidates(partial, candidates)
|
|
600
|
-
matches = candidates.select { |c| c.downcase.start_with?(partial.downcase) }
|
|
601
|
-
if matches.length == 1
|
|
602
|
-
matches.first[partial.length..]
|
|
603
|
-
elsif matches.length > 1
|
|
604
|
-
prefix = common_prefix(matches)
|
|
605
|
-
remaining = prefix[partial.length..] || ""
|
|
606
|
-
remaining + " (#{matches.map { |m| m }.join("|")})"
|
|
607
|
-
else
|
|
608
|
-
" (no matches)"
|
|
609
|
-
end
|
|
610
|
-
end
|
|
611
|
-
|
|
612
|
-
def show_detail
|
|
613
|
-
return if @jobs.empty?
|
|
614
|
-
@detail_job = @jobs[@selected]
|
|
615
|
-
@detail_scroll = 0
|
|
616
|
-
full_draw
|
|
617
|
-
end
|
|
618
|
-
|
|
619
|
-
def build_detail_lines(job)
|
|
620
|
-
lines = []
|
|
621
|
-
|
|
622
|
-
lines << "\e[1mClass:\e[0m #{job.class_name}"
|
|
623
|
-
lines << "\e[1mQueue:\e[0m #{job.queue_name}"
|
|
624
|
-
lines << "\e[1mPriority:\e[0m #{job.priority || "—"}"
|
|
625
|
-
lines << "\e[1mActive Job:\e[0m #{job.active_job_id || "—"}"
|
|
626
|
-
lines << ""
|
|
627
|
-
|
|
628
|
-
status = job_status(job)
|
|
629
|
-
lines << "\e[1mStatus:\e[0m #{status_text(status)}"
|
|
630
|
-
lines << ""
|
|
631
|
-
|
|
632
|
-
lines << "\e[1mCreated:\e[0m #{job.created_at || "—"}"
|
|
633
|
-
lines << "\e[1mScheduled:\e[0m #{job.scheduled_at || "—"}"
|
|
634
|
-
lines << "\e[1mFinished:\e[0m #{job.finished_at || "—"}"
|
|
635
|
-
lines << ""
|
|
636
|
-
|
|
637
|
-
lines << "\e[1mArguments:\e[0m"
|
|
638
|
-
begin
|
|
639
|
-
args = JSON.parse(job.arguments)
|
|
640
|
-
JSON.pretty_generate(args).each_line { |l| lines << " #{l.chomp}" }
|
|
641
|
-
rescue JSON::ParserError
|
|
642
|
-
lines << " #{job.arguments}"
|
|
643
|
-
end
|
|
644
|
-
|
|
645
|
-
if status == :failed && job.failed_execution
|
|
646
|
-
lines << ""
|
|
647
|
-
lines << "\e[1;31mError:\e[0m"
|
|
648
|
-
error_text = job.failed_execution.error || "No error message"
|
|
649
|
-
error_text.each_line { |l| lines << " #{l.chomp}" }
|
|
650
|
-
end
|
|
651
|
-
|
|
652
|
-
lines
|
|
653
|
-
end
|
|
654
|
-
|
|
655
|
-
def draw_detail_screen
|
|
656
|
-
print "\e[H"
|
|
657
|
-
w = terminal_width
|
|
658
|
-
rows = terminal_height
|
|
659
|
-
|
|
660
|
-
# Header
|
|
661
|
-
puts truncate("\e[1;36m sqdash \e[0m\e[36m Job ##{@detail_job.id}\e[0m", w) + "\e[K"
|
|
662
|
-
puts "\e[90m#{"─" * w}\e[0m"
|
|
663
|
-
|
|
664
|
-
# Content area: rows - 4 (header, separator, separator, footer)
|
|
665
|
-
content_rows = rows - 4
|
|
666
|
-
lines = build_detail_lines(@detail_job)
|
|
667
|
-
|
|
668
|
-
# Clamp scroll
|
|
669
|
-
max_scroll = [lines.length - content_rows, 0].max
|
|
670
|
-
@detail_scroll = [[@detail_scroll, max_scroll].min, 0].max
|
|
671
|
-
|
|
672
|
-
visible = lines[@detail_scroll, content_rows] || []
|
|
673
|
-
visible.each { |line| puts truncate(" #{line}", w) + "\e[K" }
|
|
674
|
-
|
|
675
|
-
# Clear remaining rows
|
|
676
|
-
(content_rows - visible.length).times { puts "\e[K" }
|
|
677
|
-
|
|
678
|
-
puts "\e[90m#{"─" * w}\e[0m"
|
|
679
|
-
|
|
680
|
-
if @message
|
|
681
|
-
puts " \e[1;32m#{@message}\e[0m\e[K"
|
|
682
|
-
@message = nil
|
|
683
|
-
else
|
|
684
|
-
puts truncate(" \e[90mEsc Back ↑↓ Scroll r Retry d Discard q Quit\e[0m", w) + "\e[K"
|
|
685
|
-
end
|
|
686
|
-
end
|
|
687
|
-
|
|
688
|
-
def handle_detail_input(key)
|
|
689
|
-
case key
|
|
690
|
-
when "\e"
|
|
691
|
-
next_chars = $stdin.read_nonblock(2) rescue nil
|
|
692
|
-
case next_chars
|
|
693
|
-
when "[A" # up
|
|
694
|
-
@detail_scroll = [@detail_scroll - 1, 0].max
|
|
695
|
-
when "[B" # down
|
|
696
|
-
@detail_scroll += 1
|
|
697
|
-
when nil # bare Escape — back to list
|
|
698
|
-
@detail_job = nil
|
|
699
|
-
full_draw
|
|
700
|
-
end
|
|
701
|
-
when "\u007F", "\b" # Backspace — back to list
|
|
702
|
-
@detail_job = nil
|
|
703
|
-
full_draw
|
|
704
|
-
when "r"
|
|
705
|
-
failed = Models::FailedExecution.find_by(job_id: @detail_job.id)
|
|
706
|
-
if failed
|
|
707
|
-
failed.retry!
|
|
708
|
-
@message = "Retried job #{@detail_job.id} (#{@detail_job.class_name})"
|
|
709
|
-
@detail_job.reload
|
|
710
|
-
load_data
|
|
711
|
-
else
|
|
712
|
-
@message = "Job #{@detail_job.id} is not failed"
|
|
713
|
-
end
|
|
714
|
-
when "d"
|
|
715
|
-
failed = Models::FailedExecution.find_by(job_id: @detail_job.id)
|
|
716
|
-
if failed
|
|
717
|
-
failed.discard!
|
|
718
|
-
@message = "Discarded job #{@detail_job.id} (#{@detail_job.class_name})"
|
|
719
|
-
@detail_job = nil
|
|
720
|
-
load_data
|
|
721
|
-
full_draw
|
|
722
|
-
else
|
|
723
|
-
@message = "Job #{@detail_job.id} is not failed"
|
|
724
|
-
end
|
|
725
|
-
when "q"
|
|
726
|
-
throw(:quit)
|
|
727
|
-
end
|
|
728
|
-
end
|
|
729
|
-
|
|
730
|
-
def retry_selected
|
|
731
|
-
job = @jobs[@selected]
|
|
732
|
-
return unless job
|
|
733
|
-
|
|
734
|
-
failed = Models::FailedExecution.find_by(job_id: job.id)
|
|
735
|
-
unless failed
|
|
736
|
-
@message = "Job #{job.id} is not failed"
|
|
737
|
-
return
|
|
738
|
-
end
|
|
739
|
-
|
|
740
|
-
failed.retry!
|
|
741
|
-
@message = "Retried job #{job.id} (#{job.class_name})"
|
|
742
|
-
load_data
|
|
743
|
-
end
|
|
744
|
-
|
|
745
|
-
def discard_selected
|
|
746
|
-
job = @jobs[@selected]
|
|
747
|
-
return unless job
|
|
748
|
-
|
|
749
|
-
failed = Models::FailedExecution.find_by(job_id: job.id)
|
|
750
|
-
unless failed
|
|
751
|
-
@message = "Job #{job.id} is not failed"
|
|
752
|
-
return
|
|
753
|
-
end
|
|
754
|
-
|
|
755
|
-
failed.discard!
|
|
756
|
-
@message = "Discarded job #{job.id} (#{job.class_name})"
|
|
757
|
-
load_data
|
|
758
|
-
end
|
|
759
198
|
end
|
|
760
199
|
end
|