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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +110 -0
- data/Rakefile +4 -0
- data/exe/sqdash +3 -0
- data/lib/sqdash/cli.rb +760 -0
- data/lib/sqdash/database.rb +37 -0
- data/lib/sqdash/models/failed_execution.rb +29 -0
- data/lib/sqdash/models/job.rb +12 -0
- data/lib/sqdash/models/ready_execution.rb +9 -0
- data/lib/sqdash/version.rb +5 -0
- data/lib/sqdash.rb +12 -0
- data/sig/sqdash.rbs +4 -0
- metadata +75 -0
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
data/exe/sqdash
ADDED
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
|
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
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: []
|