sqdash 0.1.1 → 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 +60 -3
- data/Rakefile +8 -1
- data/exe/sqdash +2 -0
- data/lib/sqdash/autocomplete.rb +164 -0
- data/lib/sqdash/cli.rb +68 -684
- data/lib/sqdash/config.rb +39 -0
- data/lib/sqdash/database.rb +3 -3
- 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 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 72ee9eb4f696be8766e6dbdbd24a75651674c2c7369a3d634f68600002eff350
|
|
4
|
+
data.tar.gz: 0bf7bc1d1834a98dc8a9db6640b64967e8e193a67a4f6550bd7fc5b601a68de3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6e1396b021bc066a486d5b5be623bf76d058c123ada0184d253f05aa98b4996e194767db96dc8377311a1c8e6d2f6c6eb237acef48d545c1c9bc8698a15cf5a2
|
|
7
|
+
data.tar.gz: '007933f8ea693afcb433bb41ba1e72e274b0d8f1506a4e3dfc62dd52acfe447251604cc3be2668bb0872290453d515a894fcd915ff1f46a5824a7e01a29f015e'
|
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
AllCops:
|
|
2
|
+
TargetRubyVersion: 3.2
|
|
3
|
+
NewCops: enable
|
|
4
|
+
SuggestExtensions: false
|
|
5
|
+
Exclude:
|
|
6
|
+
- "vendor/**/*"
|
|
7
|
+
- "bin/**/*"
|
|
8
|
+
|
|
9
|
+
Style/StringLiterals:
|
|
10
|
+
EnforcedStyle: double_quotes
|
|
11
|
+
|
|
12
|
+
Style/FrozenStringLiteralComment:
|
|
13
|
+
Enabled: true
|
|
14
|
+
|
|
15
|
+
Style/Documentation:
|
|
16
|
+
Enabled: false
|
|
17
|
+
|
|
18
|
+
Style/StringConcatenation:
|
|
19
|
+
Enabled: false
|
|
20
|
+
|
|
21
|
+
Layout/LineLength:
|
|
22
|
+
Max: 120
|
|
23
|
+
Exclude:
|
|
24
|
+
- "lib/sqdash/cli.rb"
|
|
25
|
+
- "lib/sqdash/renderer.rb"
|
|
26
|
+
|
|
27
|
+
Metrics/MethodLength:
|
|
28
|
+
Max: 30
|
|
29
|
+
Exclude:
|
|
30
|
+
- "lib/sqdash/cli.rb"
|
|
31
|
+
- "lib/sqdash/renderer.rb"
|
|
32
|
+
- "lib/sqdash/input_handler.rb"
|
|
33
|
+
- "lib/sqdash/autocomplete.rb"
|
|
34
|
+
|
|
35
|
+
Metrics/ClassLength:
|
|
36
|
+
Max: 300
|
|
37
|
+
Exclude:
|
|
38
|
+
- "lib/sqdash/cli.rb"
|
|
39
|
+
- "test/cli_test.rb"
|
|
40
|
+
|
|
41
|
+
Metrics/ModuleLength:
|
|
42
|
+
Max: 100
|
|
43
|
+
Exclude:
|
|
44
|
+
- "lib/sqdash/renderer.rb"
|
|
45
|
+
- "lib/sqdash/input_handler.rb"
|
|
46
|
+
- "lib/sqdash/autocomplete.rb"
|
|
47
|
+
|
|
48
|
+
Metrics/AbcSize:
|
|
49
|
+
Max: 30
|
|
50
|
+
Exclude:
|
|
51
|
+
- "lib/sqdash/cli.rb"
|
|
52
|
+
- "lib/sqdash/renderer.rb"
|
|
53
|
+
- "lib/sqdash/autocomplete.rb"
|
|
54
|
+
|
|
55
|
+
Metrics/CyclomaticComplexity:
|
|
56
|
+
Max: 15
|
|
57
|
+
Exclude:
|
|
58
|
+
- "lib/sqdash/cli.rb"
|
|
59
|
+
- "lib/sqdash/autocomplete.rb"
|
|
60
|
+
|
|
61
|
+
Metrics/PerceivedComplexity:
|
|
62
|
+
Max: 15
|
|
63
|
+
Exclude:
|
|
64
|
+
- "lib/sqdash/cli.rb"
|
|
65
|
+
- "lib/sqdash/renderer.rb"
|
|
66
|
+
- "lib/sqdash/autocomplete.rb"
|
|
67
|
+
|
|
68
|
+
Metrics/BlockLength:
|
|
69
|
+
Exclude:
|
|
70
|
+
- "test/**/*"
|
|
71
|
+
- "sqdash.gemspec"
|
data/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# sqdash
|
|
1
|
+
# sqdash: Solid Queue Terminal Dashboard
|
|
2
2
|
|
|
3
3
|
A terminal dashboard for Rails 8's Solid Queue.
|
|
4
4
|
|
|
@@ -7,6 +7,7 @@ Solid Queue is the default Active Job backend in Rails 8, but it ships with no b
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
9
|
- Live overview of all Solid Queue jobs with status, queue, and timestamps
|
|
10
|
+
- Paginated loading — handles 100k+ jobs without slowing down
|
|
10
11
|
- View filters: all, failed, completed, pending
|
|
11
12
|
- Sortable by created date or ID, ascending or descending
|
|
12
13
|
- Fuzzy text filter across job class, queue name, and ID
|
|
@@ -32,7 +33,7 @@ bundle add sqdash
|
|
|
32
33
|
sqdash connects directly to your Solid Queue database. You need:
|
|
33
34
|
|
|
34
35
|
- A database with the Solid Queue schema (`solid_queue_*` tables) — PostgreSQL, MySQL, or SQLite
|
|
35
|
-
- Ruby >= 3.
|
|
36
|
+
- Ruby >= 3.2
|
|
36
37
|
- The database adapter gem for your database:
|
|
37
38
|
|
|
38
39
|
```bash
|
|
@@ -62,11 +63,66 @@ sqdash sqlite3:///path/to/queue.db
|
|
|
62
63
|
export DATABASE_URL=postgres://user:pass@localhost:5432/myapp_queue
|
|
63
64
|
sqdash
|
|
64
65
|
|
|
66
|
+
# Use a specific config file
|
|
67
|
+
sqdash --config /path/to/config.yml
|
|
68
|
+
|
|
65
69
|
# Falls back to default: postgres://sqd:sqd@localhost:5432/sqd_web_development_queue
|
|
66
70
|
sqdash
|
|
67
71
|
```
|
|
68
72
|
|
|
69
|
-
Connection priority: **CLI argument** > **`DATABASE_URL` env var** > **built-in default**.
|
|
73
|
+
Connection priority: **CLI argument** > **`DATABASE_URL` env var** > **`.sqdash.yml`** > **`~/.sqdash.yml`** > **built-in default**.
|
|
74
|
+
|
|
75
|
+
### Config file
|
|
76
|
+
|
|
77
|
+
Instead of passing a database URL every time, create a `.sqdash.yml` file in your project directory or home directory:
|
|
78
|
+
|
|
79
|
+
```yaml
|
|
80
|
+
# .sqdash.yml or ~/.sqdash.yml
|
|
81
|
+
database_url: postgres://user:pass@localhost:5432/myapp_queue
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
A project-local `.sqdash.yml` takes precedence over `~/.sqdash.yml`. You can also specify a config file explicitly with `--config` / `-c`.
|
|
85
|
+
|
|
86
|
+
## How to use
|
|
87
|
+
|
|
88
|
+
Once connected, sqdash shows a live dashboard of all your Solid Queue jobs. Here are common workflows:
|
|
89
|
+
|
|
90
|
+
### Quick start
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
# Connect to your Rails app's queue database
|
|
94
|
+
sqdash postgres://user:pass@localhost:5432/myapp_development
|
|
95
|
+
|
|
96
|
+
# Or if you have a .sqdash.yml config file, just run:
|
|
97
|
+
sqdash
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Investigating failed jobs
|
|
101
|
+
|
|
102
|
+
1. Type `:view failed` and press `Enter` to show only failed jobs
|
|
103
|
+
2. Use `↑`/`↓` to select a job
|
|
104
|
+
3. Press `Enter` to see the full error backtrace and job arguments
|
|
105
|
+
4. Press `r` to retry the job, or `d` to discard it
|
|
106
|
+
5. Press `Esc` to go back to the list
|
|
107
|
+
|
|
108
|
+
### Finding a specific job
|
|
109
|
+
|
|
110
|
+
1. Press `/` to open the search filter
|
|
111
|
+
2. Start typing a job class name (e.g., `UserMailer`), queue name, or job ID
|
|
112
|
+
3. Press `Tab` to autocomplete — sqdash suggests matching class and queue names
|
|
113
|
+
4. Press `Enter` to apply the filter
|
|
114
|
+
5. Press `Esc` to clear the filter and see all jobs again
|
|
115
|
+
|
|
116
|
+
### Sorting jobs
|
|
117
|
+
|
|
118
|
+
1. Press `:` to open the command bar
|
|
119
|
+
2. Type `sort created asc` to see oldest jobs first (useful for finding stuck jobs)
|
|
120
|
+
3. Press `Enter` to apply
|
|
121
|
+
4. Other options: `sort created desc` (default), `sort id asc`, `sort id desc`
|
|
122
|
+
|
|
123
|
+
### Monitoring a queue in real time
|
|
124
|
+
|
|
125
|
+
sqdash auto-refreshes data every second when idle. You can also press `Space` to manually refresh. The stats bar at the top shows live counts of total, completed, failed, and pending jobs.
|
|
70
126
|
|
|
71
127
|
### Keyboard shortcuts
|
|
72
128
|
|
|
@@ -106,6 +162,7 @@ git clone https://github.com/nuhasami/sqdash.git
|
|
|
106
162
|
cd sqdash
|
|
107
163
|
bin/setup
|
|
108
164
|
bundle exec ruby exe/sqdash
|
|
165
|
+
rake test
|
|
109
166
|
```
|
|
110
167
|
|
|
111
168
|
## Contributing
|
data/Rakefile
CHANGED
data/exe/sqdash
CHANGED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sqdash
|
|
4
|
+
module Autocomplete
|
|
5
|
+
def autocomplete_filter
|
|
6
|
+
return if @filter_text.empty?
|
|
7
|
+
|
|
8
|
+
query = @filter_text.downcase
|
|
9
|
+
|
|
10
|
+
candidates = (
|
|
11
|
+
Models::Job.distinct.pluck(:class_name) +
|
|
12
|
+
Models::Job.distinct.pluck(:queue_name)
|
|
13
|
+
).uniq
|
|
14
|
+
|
|
15
|
+
matches = candidates.select { |c| c.downcase.start_with?(query) }
|
|
16
|
+
|
|
17
|
+
if matches.length == 1
|
|
18
|
+
@filter_text = matches.first
|
|
19
|
+
elsif matches.length > 1
|
|
20
|
+
@filter_text = common_prefix(matches)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
load_data
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def autocomplete_hint
|
|
27
|
+
return "" if @filter_text.empty?
|
|
28
|
+
|
|
29
|
+
query = @filter_text.downcase
|
|
30
|
+
candidates = (
|
|
31
|
+
Models::Job.distinct.pluck(:class_name) +
|
|
32
|
+
Models::Job.distinct.pluck(:queue_name)
|
|
33
|
+
).uniq
|
|
34
|
+
|
|
35
|
+
matches = candidates.select { |c| c.downcase.start_with?(query) }
|
|
36
|
+
|
|
37
|
+
if matches.length == 1
|
|
38
|
+
matches.first[@filter_text.length..]
|
|
39
|
+
elsif matches.length > 1
|
|
40
|
+
prefix = common_prefix(matches)
|
|
41
|
+
remaining = prefix[@filter_text.length..] || ""
|
|
42
|
+
remaining + " (#{matches.length} matches)"
|
|
43
|
+
else
|
|
44
|
+
" (no matches)"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def autocomplete_command
|
|
49
|
+
return if @command_text.empty?
|
|
50
|
+
|
|
51
|
+
parts = @command_text.strip.split(/\s+/)
|
|
52
|
+
completing_new_word = @command_text.end_with?(" ")
|
|
53
|
+
|
|
54
|
+
if completing_new_word
|
|
55
|
+
case parts.length
|
|
56
|
+
when 1
|
|
57
|
+
subtree = CLI::COMMANDS[parts[0]]
|
|
58
|
+
return unless subtree.is_a?(Hash)
|
|
59
|
+
|
|
60
|
+
completed = complete_word("", subtree.keys)
|
|
61
|
+
@command_text = "#{parts[0]} #{completed}" if completed
|
|
62
|
+
when 2
|
|
63
|
+
subtree = CLI::COMMANDS.dig(parts[0], parts[1])
|
|
64
|
+
return unless subtree.is_a?(Array) && subtree.any?
|
|
65
|
+
|
|
66
|
+
completed = complete_word("", subtree)
|
|
67
|
+
@command_text = "#{parts[0]} #{parts[1]} #{completed}" if completed
|
|
68
|
+
end
|
|
69
|
+
else
|
|
70
|
+
case parts.length
|
|
71
|
+
when 1
|
|
72
|
+
completed = complete_word(parts[0], CLI::COMMANDS.keys)
|
|
73
|
+
@command_text = completed if completed
|
|
74
|
+
when 2
|
|
75
|
+
subtree = CLI::COMMANDS[parts[0]]
|
|
76
|
+
return unless subtree.is_a?(Hash)
|
|
77
|
+
|
|
78
|
+
completed = complete_word(parts[1], subtree.keys)
|
|
79
|
+
@command_text = "#{parts[0]} #{completed}" if completed
|
|
80
|
+
when 3
|
|
81
|
+
subtree = CLI::COMMANDS.dig(parts[0], parts[1])
|
|
82
|
+
return unless subtree.is_a?(Array) && subtree.any?
|
|
83
|
+
|
|
84
|
+
completed = complete_word(parts[2], subtree)
|
|
85
|
+
@command_text = "#{parts[0]} #{parts[1]} #{completed}" if completed
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def command_autocomplete_hint
|
|
91
|
+
return "" if @command_text.empty?
|
|
92
|
+
|
|
93
|
+
parts = @command_text.strip.split(/\s+/)
|
|
94
|
+
completing_new_word = @command_text.end_with?(" ")
|
|
95
|
+
|
|
96
|
+
if completing_new_word
|
|
97
|
+
case parts.length
|
|
98
|
+
when 1
|
|
99
|
+
subtree = CLI::COMMANDS[parts[0]]
|
|
100
|
+
return "" unless subtree.is_a?(Hash)
|
|
101
|
+
|
|
102
|
+
hint_for_candidates("", subtree.keys)
|
|
103
|
+
when 2
|
|
104
|
+
subtree = CLI::COMMANDS.dig(parts[0], parts[1])
|
|
105
|
+
return "" unless subtree.is_a?(Array) && subtree.any?
|
|
106
|
+
|
|
107
|
+
hint_for_candidates("", subtree)
|
|
108
|
+
else
|
|
109
|
+
""
|
|
110
|
+
end
|
|
111
|
+
else
|
|
112
|
+
case parts.length
|
|
113
|
+
when 1
|
|
114
|
+
hint_for_candidates(parts[0], CLI::COMMANDS.keys)
|
|
115
|
+
when 2
|
|
116
|
+
subtree = CLI::COMMANDS[parts[0]]
|
|
117
|
+
return "" unless subtree.is_a?(Hash)
|
|
118
|
+
|
|
119
|
+
hint_for_candidates(parts[1], subtree.keys)
|
|
120
|
+
when 3
|
|
121
|
+
subtree = CLI::COMMANDS.dig(parts[0], parts[1])
|
|
122
|
+
return "" unless subtree.is_a?(Array) && subtree.any?
|
|
123
|
+
|
|
124
|
+
hint_for_candidates(parts[2], subtree)
|
|
125
|
+
else
|
|
126
|
+
""
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def complete_word(partial, candidates)
|
|
132
|
+
matches = candidates.select { |c| c.downcase.start_with?(partial.downcase) }
|
|
133
|
+
if matches.length == 1
|
|
134
|
+
matches.first
|
|
135
|
+
elsif matches.length > 1
|
|
136
|
+
prefix = common_prefix(matches)
|
|
137
|
+
prefix.length > partial.length ? prefix : nil
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def common_prefix(strings)
|
|
142
|
+
return "" if strings.empty?
|
|
143
|
+
|
|
144
|
+
prefix = strings.first
|
|
145
|
+
strings.each do |s|
|
|
146
|
+
prefix = prefix[0...prefix.length].chars.take_while.with_index { |c, i| s[i]&.downcase == c.downcase }.join
|
|
147
|
+
end
|
|
148
|
+
prefix
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def hint_for_candidates(partial, candidates)
|
|
152
|
+
matches = candidates.select { |c| c.downcase.start_with?(partial.downcase) }
|
|
153
|
+
if matches.length == 1
|
|
154
|
+
matches.first[partial.length..]
|
|
155
|
+
elsif matches.length > 1
|
|
156
|
+
prefix = common_prefix(matches)
|
|
157
|
+
remaining = prefix[partial.length..] || ""
|
|
158
|
+
remaining + " (#{matches.join('|')})"
|
|
159
|
+
else
|
|
160
|
+
" (no matches)"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|