sqdash 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c6331093df3973dab4ab641090a1065e284147718c0475f104f831553b6ce106
4
+ data.tar.gz: 974aada0730279565ca8c7f24d9bc79d644052c960af4fe98725d0b771e9f9a3
5
+ SHA512:
6
+ metadata.gz: 85d9f93d466ef2731a851c34375a6675b9e94a965752614a730825593f2641168e3cf522b2ca82338b235a7346b9e5df88391866fef1598c5892e1eb67a553ce
7
+ data.tar.gz: bfe3cfe773ea0cca2aa708ffd87c216012d93985d91eaef90784be752e5d529b5d5a52f3ede55d4dc818a905da54e7b1c34acc138009b32db3ff1d631ae8d584
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Nuha
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # sqdash
2
+
3
+ A terminal dashboard for Rails 8's Solid Queue.
4
+
5
+ Solid Queue is the default Active Job backend in Rails 8, but it ships with no built-in UI. sqdash gives you a fast, keyboard-driven TUI to monitor and manage jobs without leaving your terminal — no browser, no extra server, no mounted routes.
6
+
7
+ ## Features
8
+
9
+ - Live overview of all Solid Queue jobs with status, queue, and timestamps
10
+ - View filters: all, failed, completed, pending
11
+ - Sortable by created date or ID, ascending or descending
12
+ - Fuzzy text filter across job class, queue name, and ID
13
+ - Retry or discard failed jobs with a single keypress
14
+ - k9s-style `:` command bar with Tab autocomplete
15
+ - `/` search with inline autocomplete hints
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ gem install sqdash
21
+ ```
22
+
23
+ Or add it to your Gemfile:
24
+
25
+ ```bash
26
+ bundle add sqdash
27
+ ```
28
+
29
+ ## Prerequisites
30
+
31
+ sqdash connects directly to your Solid Queue database. You need:
32
+
33
+ - A database with the Solid Queue schema (`solid_queue_*` tables) — PostgreSQL, MySQL, or SQLite
34
+ - Ruby >= 3.0
35
+ - The database adapter gem for your database:
36
+
37
+ ```bash
38
+ gem install pg # PostgreSQL
39
+ gem install mysql2 # MySQL
40
+ gem install sqlite3 # SQLite
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ```bash
46
+ # PostgreSQL
47
+ sqdash postgres://user:pass@localhost:5432/myapp_queue
48
+
49
+ # MySQL
50
+ sqdash mysql2://user:pass@localhost:3306/myapp_queue
51
+
52
+ # SQLite
53
+ sqdash sqlite3:///path/to/queue.db
54
+
55
+ # Or set the DATABASE_URL environment variable
56
+ export DATABASE_URL=postgres://user:pass@localhost:5432/myapp_queue
57
+ sqdash
58
+
59
+ # Falls back to default: postgres://sqd:sqd@localhost:5432/sqd_web_development_queue
60
+ sqdash
61
+ ```
62
+
63
+ Connection priority: **CLI argument** > **`DATABASE_URL` env var** > **built-in default**.
64
+
65
+ ### Keyboard shortcuts
66
+
67
+ | Key | Action |
68
+ |-----|--------|
69
+ | `↑` `↓` | Navigate job list |
70
+ | `/` | Filter jobs (fuzzy search across all columns) |
71
+ | `:` | Command bar (sort, switch views) |
72
+ | `Tab` | Autocomplete (in filter or command mode) |
73
+ | `r` | Retry selected failed job |
74
+ | `d` | Discard selected failed job |
75
+ | `Space` | Refresh data |
76
+ | `q` | Quit |
77
+
78
+ ### Commands
79
+
80
+ Type `:` to open the command bar, then:
81
+
82
+ | Command | Description |
83
+ |---------|-------------|
84
+ | `sort created desc` | Sort by created date, newest first (default) |
85
+ | `sort created asc` | Sort by created date, oldest first |
86
+ | `sort id desc` | Sort by job ID, highest first |
87
+ | `sort id asc` | Sort by job ID, lowest first |
88
+ | `view all` | Show all jobs |
89
+ | `view failed` | Show only failed jobs |
90
+ | `view completed` | Show only completed jobs |
91
+ | `view pending` | Show only pending jobs |
92
+
93
+ Arguments are optional — `sort` defaults to `sort created desc`, `view` defaults to `view all`.
94
+
95
+ ## Development
96
+
97
+ ```bash
98
+ git clone https://github.com/nuhasami/sqdash.git
99
+ cd sqdash
100
+ bin/setup
101
+ bundle exec ruby exe/sqdash
102
+ ```
103
+
104
+ ## Contributing
105
+
106
+ Bug reports and pull requests are welcome on GitHub at https://github.com/nuhasami/sqdash.
107
+
108
+ ## License
109
+
110
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ task default: %i[]
data/exe/sqdash ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env ruby
2
+ require "sqdash"
3
+ Sqdash::CLI.start
data/lib/sqdash/cli.rb ADDED
@@ -0,0 +1,760 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+ require "json"
5
+
6
+ module Sqdash
7
+ class CLI
8
+ DEFAULT_DB_URL = "postgres://sqd:sqd@localhost:5432/sqd_web_development_queue"
9
+
10
+ COMMANDS = {
11
+ "sort" => {
12
+ "created" => ["asc", "desc"],
13
+ "id" => ["asc", "desc"]
14
+ },
15
+ "view" => {
16
+ "all" => [],
17
+ "failed" => [],
18
+ "completed" => [],
19
+ "pending" => []
20
+ }
21
+ }.freeze
22
+
23
+ def self.start
24
+ new.run
25
+ end
26
+
27
+ def run
28
+ Database.connect!(resolve_db_url)
29
+ @selected = 0
30
+ @scroll_offset = 0
31
+ @filter_text = ""
32
+ @filter_mode = false
33
+ @view = :all # :all, :failed, :completed, :pending
34
+ @jobs = []
35
+ @failed_ids = []
36
+ @message = nil
37
+ @sort_column = :created_at
38
+ @sort_dir = :desc
39
+ @command_mode = false
40
+ @command_text = ""
41
+ @detail_job = nil
42
+ @detail_scroll = 0
43
+ trap_resize
44
+ load_data
45
+ full_draw
46
+ catch(:quit) { handle_input }
47
+ ensure
48
+ Signal.trap("WINCH", "DEFAULT")
49
+ cleanup
50
+ end
51
+
52
+ private
53
+
54
+ def resolve_db_url
55
+ ARGV[0] || ENV["DATABASE_URL"] || DEFAULT_DB_URL
56
+ end
57
+
58
+ def cleanup
59
+ print "\e[?25h"
60
+ print "\e[2J\e[H"
61
+ puts "Goodbye!"
62
+ end
63
+
64
+ def trap_resize
65
+ Signal.trap("WINCH") do
66
+ @needs_redraw = true
67
+ end
68
+ end
69
+
70
+ def truncate(str, max)
71
+ return str if str.length <= max
72
+
73
+ # Strip ANSI codes to measure visible length
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
93
+
94
+ def terminal_height
95
+ $stdout.winsize[0]
96
+ end
97
+
98
+ def terminal_width
99
+ $stdout.winsize[1]
100
+ end
101
+
102
+ def visible_rows
103
+ [terminal_height - 11, 5].max
104
+ end
105
+
106
+ def load_data
107
+ @failed_ids = Models::FailedExecution.pluck(:job_id)
108
+
109
+ scope = Models::Job.order(@sort_column => @sort_dir)
110
+
111
+ # View filter
112
+ case @view
113
+ when :failed
114
+ scope = @failed_ids.any? ? scope.where(id: @failed_ids) : scope.none
115
+ when :completed
116
+ scope = scope.where.not(finished_at: nil).where.not(id: @failed_ids)
117
+ when :pending
118
+ scope = scope.where(finished_at: nil).where.not(id: @failed_ids)
119
+ end
120
+
121
+ @jobs = scope.to_a
122
+
123
+ # Text filter (k9s style — filters across all visible columns)
124
+ if @filter_text.length > 0
125
+ query = @filter_text.downcase
126
+ @jobs = @jobs.select do |job|
127
+ job.class_name.downcase.include?(query) ||
128
+ job.queue_name.downcase.include?(query) ||
129
+ job.id.to_s.include?(query)
130
+ end
131
+ end
132
+
133
+ # Clamp selection
134
+ @selected = [[@selected, @jobs.length - 1].min, 0].max
135
+ adjust_scroll
136
+ end
137
+
138
+ def adjust_scroll
139
+ if @selected < @scroll_offset
140
+ @scroll_offset = @selected
141
+ elsif @selected >= @scroll_offset + visible_rows
142
+ @scroll_offset = @selected - visible_rows + 1
143
+ end
144
+ end
145
+
146
+ def job_status(job)
147
+ if @failed_ids.include?(job.id)
148
+ :failed
149
+ elsif job.finished_at
150
+ :completed
151
+ else
152
+ :pending
153
+ end
154
+ 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
+ end
760
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module Sqdash
6
+ class Database
7
+ ADAPTERS = {
8
+ "postgres" => { gem: "pg", adapter: "postgresql" },
9
+ "postgresql" => { gem: "pg", adapter: "postgresql" },
10
+ "mysql2" => { gem: "mysql2", adapter: "mysql2" },
11
+ "sqlite3" => { gem: "sqlite3", adapter: "sqlite3" }
12
+ }.freeze
13
+
14
+ def self.connect!(url)
15
+ require_adapter!(url)
16
+ ActiveRecord::Base.establish_connection(url)
17
+ ActiveRecord::Base.connection
18
+ rescue ActiveRecord::ConnectionNotEstablished => e
19
+ abort "\e[31mFailed to connect to database: #{e.message}\e[0m"
20
+ end
21
+
22
+ def self.require_adapter!(url)
23
+ scheme = url.split("://").first.split(":").first
24
+ config = ADAPTERS[scheme]
25
+
26
+ unless config
27
+ abort "\e[31mUnsupported database adapter: #{scheme}\n" \
28
+ "Supported: postgres, mysql2, sqlite3\e[0m"
29
+ end
30
+
31
+ require config[:gem]
32
+ rescue LoadError
33
+ abort "\e[31mMissing database adapter gem '#{config[:gem]}'. Install it with:\n" \
34
+ " gem install #{config[:gem]}\e[0m"
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sqdash
4
+ module Models
5
+ class FailedExecution < ActiveRecord::Base
6
+ self.table_name = "solid_queue_failed_executions"
7
+
8
+ belongs_to :job, class_name: "Sqdash::Models::Job"
9
+
10
+ def retry!
11
+ transaction do
12
+ ReadyExecution.create!(
13
+ job_id: job_id,
14
+ queue_name: job.queue_name,
15
+ priority: job.priority
16
+ )
17
+ destroy!
18
+ end
19
+ end
20
+
21
+ def discard!
22
+ transaction do
23
+ job.update!(finished_at: Time.now)
24
+ destroy!
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sqdash
4
+ module Models
5
+ class Job < ActiveRecord::Base
6
+ self.table_name = "solid_queue_jobs"
7
+
8
+ has_one :failed_execution, class_name: "Sqdash::Models::FailedExecution", foreign_key: :job_id
9
+ has_one :ready_execution, class_name: "Sqdash::Models::ReadyExecution", foreign_key: :job_id
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sqdash
4
+ module Models
5
+ class ReadyExecution < ActiveRecord::Base
6
+ self.table_name = "solid_queue_ready_executions"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sqdash
4
+ VERSION = "0.1.0"
5
+ end
data/lib/sqdash.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sqdash/version"
4
+ require_relative "sqdash/database"
5
+ require_relative "sqdash/models/job"
6
+ require_relative "sqdash/models/failed_execution"
7
+ require_relative "sqdash/models/ready_execution"
8
+ require_relative "sqdash/cli"
9
+
10
+ module Sqdash
11
+ class Error < StandardError; end
12
+ end
data/sig/sqdash.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Sqdash
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,75 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sqdash
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nuha
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-03-15 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '8.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '8.0'
27
+ description: sqdash is a fast, keyboard-driven TUI for monitoring and managing Solid
28
+ Queue jobs. View pending, failed, and completed jobs, retry or discard failures,
29
+ filter, sort, and navigate — all without leaving your terminal.
30
+ email:
31
+ - nuha.sami@hey.com
32
+ executables:
33
+ - sqdash
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - LICENSE.txt
38
+ - README.md
39
+ - Rakefile
40
+ - exe/sqdash
41
+ - lib/sqdash.rb
42
+ - lib/sqdash/cli.rb
43
+ - lib/sqdash/database.rb
44
+ - lib/sqdash/models/failed_execution.rb
45
+ - lib/sqdash/models/job.rb
46
+ - lib/sqdash/models/ready_execution.rb
47
+ - lib/sqdash/version.rb
48
+ - sig/sqdash.rbs
49
+ homepage: https://github.com/nuhasami/sqdash
50
+ licenses:
51
+ - MIT
52
+ metadata:
53
+ homepage_uri: https://github.com/nuhasami/sqdash
54
+ source_code_uri: https://github.com/nuhasami/sqdash
55
+ changelog_uri: https://github.com/nuhasami/sqdash/blob/main/CHANGELOG.md
56
+ post_install_message:
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: 3.0.0
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 3.5.22
72
+ signing_key:
73
+ specification_version: 4
74
+ summary: A terminal dashboard for Rails 8's Solid Queue
75
+ test_files: []