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.
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Sqdash
6
+ class Config
7
+ CONFIG_FILENAME = ".sqdash.yml"
8
+
9
+ def self.load(path = nil)
10
+ new(path)
11
+ end
12
+
13
+ def initialize(path = nil)
14
+ @data = {}
15
+ file = path || find_config_file
16
+ return unless file
17
+
18
+ @data = YAML.safe_load_file(file) || {}
19
+ rescue Errno::ENOENT
20
+ # Explicit path given but file not found
21
+ abort "\e[31mConfig file not found: #{path}\e[0m" if path
22
+ rescue Psych::SyntaxError => e
23
+ abort "\e[31mInvalid YAML in #{file}: #{e.message}\e[0m"
24
+ end
25
+
26
+ def database_url
27
+ @data["database_url"]
28
+ end
29
+
30
+ private
31
+
32
+ def find_config_file
33
+ [
34
+ File.join(Dir.pwd, CONFIG_FILENAME),
35
+ File.join(Dir.home, CONFIG_FILENAME)
36
+ ].find { |p| File.exist?(p) }
37
+ end
38
+ end
39
+ end
@@ -15,7 +15,7 @@ module Sqdash
15
15
  require_adapter!(url)
16
16
  ActiveRecord::Base.establish_connection(url)
17
17
  ActiveRecord::Base.connection
18
- rescue => e
18
+ rescue StandardError => e
19
19
  abort "\e[31mFailed to connect: #{e.message}\e[0m\n\n" \
20
20
  "Usage: sqdash <database-url>\n" \
21
21
  "Run sqdash --help for details."
@@ -32,8 +32,8 @@ module Sqdash
32
32
 
33
33
  require config[:gem]
34
34
  rescue LoadError
35
- abort "\e[31mMissing database adapter gem '#{config[:gem]}'. Install it with:\n" \
36
- " gem install #{config[:gem]}\e[0m"
35
+ abort "\e[31mMissing database adapter gem '#{config[:gem]}'. Install it with:\n " \
36
+ "gem install #{config[:gem]}\e[0m"
37
37
  end
38
38
  end
39
39
  end
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+ require "io/wait"
5
+
6
+ module Sqdash
7
+ module InputHandler
8
+ def handle_input
9
+ @saved_stty = `stty -g`.chomp
10
+ system("stty", "-echo", "-icanon", "min", "1")
11
+ loop do
12
+ if @needs_redraw
13
+ @needs_redraw = false
14
+ adjust_scroll
15
+ full_draw
16
+ end
17
+
18
+ key = read_key
19
+
20
+ unless key
21
+ if @detail_job
22
+ @detail_job.reload
23
+ else
24
+ load_data
25
+ end
26
+ draw_screen
27
+ next
28
+ end
29
+
30
+ if @detail_job
31
+ handle_detail_input(key)
32
+ elsif @command_mode
33
+ handle_command_input(key)
34
+ elsif @filter_mode
35
+ handle_filter_input(key)
36
+ else
37
+ handle_normal_input(key)
38
+ end
39
+
40
+ draw_screen
41
+ end
42
+ ensure
43
+ system("stty", @saved_stty) if @saved_stty
44
+ end
45
+
46
+ def read_key
47
+ ready = $stdin.wait_readable(1)
48
+ return nil unless ready
49
+
50
+ $stdin.getc
51
+ end
52
+
53
+ def handle_normal_input(key)
54
+ case key
55
+ when "\e"
56
+ next_chars = begin
57
+ $stdin.read_nonblock(2)
58
+ rescue StandardError
59
+ nil
60
+ end
61
+ case next_chars
62
+ when "[A" # up
63
+ @selected = [0, @selected - 1].max
64
+ adjust_scroll
65
+ when "[B" # down
66
+ max = [@jobs.length - 1, 0].max
67
+ @selected = [max, @selected + 1].min
68
+ adjust_scroll
69
+ when nil # bare Escape — clear active filter
70
+ if @filter_text.length.positive?
71
+ @filter_text = ""
72
+ load_data
73
+ end
74
+ end
75
+ when "q"
76
+ throw(:quit)
77
+ when "/"
78
+ @filter_mode = true
79
+ @filter_text = ""
80
+ when ":"
81
+ @command_mode = true
82
+ @command_text = ""
83
+ when "r"
84
+ retry_selected
85
+ when "d"
86
+ discard_selected
87
+ when "\r", "\n"
88
+ show_detail
89
+ when " "
90
+ load_data
91
+ end
92
+ end
93
+
94
+ def handle_filter_input(key)
95
+ case key
96
+ when "\r", "\n"
97
+ @filter_mode = false
98
+ print "\e[?25l"
99
+ load_data
100
+ when "\e"
101
+ begin
102
+ $stdin.read_nonblock(2)
103
+ rescue StandardError
104
+ nil
105
+ end
106
+ @filter_mode = false
107
+ @filter_text = ""
108
+ print "\e[?25l"
109
+ load_data
110
+ when "\t"
111
+ autocomplete_filter
112
+ when "\u007F", "\b"
113
+ @filter_text = @filter_text[0..-2]
114
+ load_data
115
+ else
116
+ if key.match?(/[[:print:]]/)
117
+ @filter_text += key
118
+ load_data
119
+ end
120
+ end
121
+ end
122
+
123
+ def handle_command_input(key)
124
+ case key
125
+ when "\r", "\n"
126
+ execute_command
127
+ @command_mode = false
128
+ @command_text = ""
129
+ print "\e[?25l"
130
+ when "\e"
131
+ begin
132
+ $stdin.read_nonblock(2)
133
+ rescue StandardError
134
+ nil
135
+ end
136
+ @command_mode = false
137
+ @command_text = ""
138
+ print "\e[?25l"
139
+ when "\t"
140
+ autocomplete_command
141
+ when "\u007F", "\b"
142
+ @command_text = @command_text[0..-2]
143
+ else
144
+ @command_text += key if key.match?(/[[:print:]]/)
145
+ end
146
+ end
147
+
148
+ def handle_detail_input(key)
149
+ case key
150
+ when "\e"
151
+ next_chars = begin
152
+ $stdin.read_nonblock(2)
153
+ rescue StandardError
154
+ nil
155
+ end
156
+ case next_chars
157
+ when "[A"
158
+ @detail_scroll = [@detail_scroll - 1, 0].max
159
+ when "[B"
160
+ @detail_scroll += 1
161
+ when nil
162
+ @detail_job = nil
163
+ full_draw
164
+ end
165
+ when "\u007F", "\b"
166
+ @detail_job = nil
167
+ full_draw
168
+ when "r"
169
+ failed = Models::FailedExecution.find_by(job_id: @detail_job.id)
170
+ if failed
171
+ failed.retry!
172
+ @message = "Retried job #{@detail_job.id} (#{@detail_job.class_name})"
173
+ @detail_job.reload
174
+ load_data
175
+ else
176
+ @message = "Job #{@detail_job.id} is not failed"
177
+ end
178
+ when "d"
179
+ failed = Models::FailedExecution.find_by(job_id: @detail_job.id)
180
+ if failed
181
+ failed.discard!
182
+ @message = "Discarded job #{@detail_job.id} (#{@detail_job.class_name})"
183
+ @detail_job = nil
184
+ load_data
185
+ full_draw
186
+ else
187
+ @message = "Job #{@detail_job.id} is not failed"
188
+ end
189
+ when "q"
190
+ throw(:quit)
191
+ end
192
+ end
193
+
194
+ def execute_command
195
+ parts = @command_text.strip.split(/\s+/)
196
+ return if parts.empty?
197
+
198
+ case parts[0]
199
+ when "sort"
200
+ field = parts[1] || "created"
201
+ direction = parts[2] || "desc"
202
+ case field
203
+ when "created"
204
+ @sort_column = :created_at
205
+ when "id"
206
+ @sort_column = :id
207
+ else
208
+ @message = "Unknown sort field: #{field}"
209
+ return
210
+ end
211
+ case direction
212
+ when "asc" then @sort_dir = :asc
213
+ when "desc" then @sort_dir = :desc
214
+ else
215
+ @message = "Unknown sort direction: #{direction}"
216
+ return
217
+ end
218
+ @selected = 0
219
+ @scroll_offset = 0
220
+ load_data
221
+ when "view"
222
+ target = parts[1] || "all"
223
+ case target
224
+ when "all" then switch_view(:all)
225
+ when "failed" then switch_view(:failed)
226
+ when "completed" then switch_view(:completed)
227
+ when "pending" then switch_view(:pending)
228
+ else
229
+ @message = "Unknown view: #{target}"
230
+ end
231
+ else
232
+ @message = "Unknown command: #{parts[0]}"
233
+ end
234
+ end
235
+
236
+ def show_detail
237
+ return if @jobs.empty?
238
+
239
+ @detail_job = @jobs[@selected]
240
+ @detail_scroll = 0
241
+ full_draw
242
+ end
243
+
244
+ def switch_view(view)
245
+ @view = view
246
+ @selected = 0
247
+ @scroll_offset = 0
248
+ load_data
249
+ end
250
+
251
+ def retry_selected
252
+ job = @jobs[@selected]
253
+ return unless job
254
+
255
+ failed = Models::FailedExecution.find_by(job_id: job.id)
256
+ unless failed
257
+ @message = "Job #{job.id} is not failed"
258
+ return
259
+ end
260
+
261
+ failed.retry!
262
+ @message = "Retried job #{job.id} (#{job.class_name})"
263
+ load_data
264
+ end
265
+
266
+ def discard_selected
267
+ job = @jobs[@selected]
268
+ return unless job
269
+
270
+ failed = Models::FailedExecution.find_by(job_id: job.id)
271
+ unless failed
272
+ @message = "Job #{job.id} is not failed"
273
+ return
274
+ end
275
+
276
+ failed.discard!
277
+ @message = "Discarded job #{job.id} (#{job.class_name})"
278
+ load_data
279
+ end
280
+ end
281
+ end
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Sqdash
6
+ module Renderer
7
+ STATUS_TEXT = {
8
+ failed: "\e[31m● failed\e[0m ",
9
+ completed: "\e[32m● completed\e[0m",
10
+ pending: "\e[33m● pending\e[0m "
11
+ }.freeze
12
+
13
+ VIEW_LABEL = {
14
+ all: "ALL",
15
+ failed: "\e[31mFAILED\e[0m",
16
+ completed: "\e[32mCOMPLETED\e[0m",
17
+ pending: "\e[33mPENDING\e[0m"
18
+ }.freeze
19
+
20
+ def truncate(str, max)
21
+ return str if str.length <= max
22
+
23
+ visible = str.gsub(/\e\[[0-9;]*m/, "")
24
+ return str if visible.length <= max
25
+
26
+ result = +""
27
+ visible_count = 0
28
+ i = 0
29
+ while i < str.length && visible_count < max
30
+ if str[i] == "\e" && str[i..] =~ /\A(\e\[[0-9;]*m)/
31
+ result << ::Regexp.last_match(1)
32
+ i += ::Regexp.last_match(1).length
33
+ else
34
+ result << str[i]
35
+ visible_count += 1
36
+ i += 1
37
+ end
38
+ end
39
+ result << "\e[0m"
40
+ end
41
+
42
+ def terminal_height
43
+ $stdout.winsize[0]
44
+ rescue StandardError
45
+ 24
46
+ end
47
+
48
+ def terminal_width
49
+ $stdout.winsize[1]
50
+ rescue StandardError
51
+ 80
52
+ end
53
+
54
+ def visible_rows
55
+ [terminal_height - 11, 5].max
56
+ end
57
+
58
+ def status_text(status)
59
+ STATUS_TEXT[status]
60
+ end
61
+
62
+ def view_label
63
+ VIEW_LABEL[@view]
64
+ end
65
+
66
+ def full_draw
67
+ print "\e[?25l"
68
+ print "\e[2J\e[H"
69
+ draw_screen
70
+ end
71
+
72
+ def cleanup
73
+ print "\e[?25h"
74
+ print "\e[2J\e[H"
75
+ puts "Goodbye!"
76
+ end
77
+
78
+ def column_widths
79
+ w = terminal_width
80
+ remaining = [w - 36, 10].max
81
+ job_w = [remaining * 65 / 100, 6].max
82
+ queue_w = [remaining - job_w, 4].max
83
+ { id: 8, job: job_w, queue: queue_w, status: 14, created: 12 }
84
+ end
85
+
86
+ def draw_screen
87
+ if @detail_job
88
+ draw_detail_screen
89
+ else
90
+ draw_list_screen
91
+ end
92
+ end
93
+
94
+ def draw_list_screen
95
+ print "\e[H"
96
+ w = terminal_width
97
+ rows = visible_rows
98
+ cols = column_widths
99
+
100
+ # Header
101
+ puts truncate("\e[1;36m sqdash \e[0m\e[36m Solid Queue Dashboard v#{Sqdash::VERSION}\e[0m", w) + "\e[K"
102
+ puts "\e[90m#{'─' * w}\e[0m\e[K"
103
+
104
+ # Stats bar
105
+ total = Models::Job.count
106
+ completed = Models::Job.where.not(finished_at: nil).count
107
+ failed = @failed_ids.length
108
+ pending = Models::ReadyExecution.count
109
+ sort_label = "#{@sort_column == :id ? 'ID' : 'Created'} #{@sort_dir == :asc ? '↑' : '↓'}"
110
+ showing = @all_loaded ? @jobs.length.to_s : "#{@jobs.length}/#{@total_count}"
111
+ 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: #{showing}"
112
+ puts truncate(stats, w) + "\e[K"
113
+
114
+ # Filter / Command bar
115
+ if @command_mode
116
+ print "\e[?25h"
117
+ hint = command_autocomplete_hint
118
+ puts truncate(
119
+ " \e[1;35m:\e[0m #{@command_text}\e[90m#{hint}\e[0m \e[90m<Tab> complete <Enter> run <Esc> cancel\e[0m", w
120
+ ) + "\e[K"
121
+ elsif @filter_mode
122
+ print "\e[?25h"
123
+ hint = autocomplete_hint
124
+ puts truncate(" \e[1;33m/\e[0m #{@filter_text}\e[90m#{hint}\e[0m \e[90m<Tab> complete <Esc> cancel\e[0m",
125
+ w) + "\e[K"
126
+ elsif @filter_text.length.positive?
127
+ puts truncate(" \e[33m/#{@filter_text}\e[0m \e[90m(/ to edit, Esc to clear)\e[0m", w) + "\e[K"
128
+ else
129
+ puts "\e[K"
130
+ end
131
+
132
+ puts "\e[90m#{'─' * w}\e[0m\e[K"
133
+
134
+ # Column headers
135
+ puts truncate(
136
+ "\e[1m #{'ID'.ljust(cols[:id])}#{'Job'.ljust(cols[:job])}#{'Queue'.ljust(cols[:queue])}#{'Status'.ljust(cols[:status])}Created\e[0m", w
137
+ ) + "\e[K"
138
+
139
+ # Job list
140
+ visible_jobs = @jobs[@scroll_offset, rows] || []
141
+
142
+ visible_jobs.each_with_index do |job, i|
143
+ actual_index = @scroll_offset + i
144
+ status = job_status(job)
145
+ is_selected = actual_index == @selected
146
+ created = job.created_at&.strftime("%m/%d %H:%M") || "—"
147
+
148
+ 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}"
149
+
150
+ if is_selected
151
+ puts truncate("\e[7m▸ #{line}\e[0m", w) + "\e[K"
152
+ else
153
+ puts truncate(" #{line}", w) + "\e[K"
154
+ end
155
+ end
156
+
157
+ # Empty state
158
+ if visible_jobs.empty?
159
+ puts " \e[90mNo jobs found\e[0m\e[K"
160
+ (rows - 1).times { puts "\e[K" }
161
+ else
162
+ (rows - visible_jobs.length).times { puts "\e[K" }
163
+ end
164
+
165
+ # Scrollbar hint
166
+ puts "\e[90m#{'─' * w}\e[0m\e[K"
167
+
168
+ # Message or footer
169
+ if @message
170
+ puts " \e[1;32m#{@message}\e[0m\e[K"
171
+ @message = nil
172
+ else
173
+ puts truncate(" \e[90m↑↓ Navigate ↵ Detail /Filter :Command r Retry d Discard q Quit\e[0m",
174
+ w) + "\e[K"
175
+ end
176
+
177
+ # Position info
178
+ return unless @jobs.length.positive?
179
+
180
+ pos = "#{@selected + 1}/#{@total_count}"
181
+ print "\e[#{terminal_height};#{w - pos.length}H\e[90m#{pos}\e[0m"
182
+ end
183
+
184
+ def draw_detail_screen
185
+ print "\e[H"
186
+ w = terminal_width
187
+ rows = terminal_height
188
+
189
+ # Header
190
+ puts truncate("\e[1;36m sqdash \e[0m\e[36m Job ##{@detail_job.id}\e[0m", w) + "\e[K"
191
+ puts "\e[90m#{'─' * w}\e[0m\e[K"
192
+
193
+ # Content area
194
+ content_rows = rows - 4
195
+ lines = build_detail_lines(@detail_job)
196
+
197
+ # Clamp scroll
198
+ max_scroll = [lines.length - content_rows, 0].max
199
+ @detail_scroll = @detail_scroll.clamp(0, max_scroll)
200
+
201
+ visible = lines[@detail_scroll, content_rows] || []
202
+ visible.each { |line| puts truncate(" #{line}", w) + "\e[K" }
203
+
204
+ (content_rows - visible.length).times { puts "\e[K" }
205
+
206
+ puts "\e[90m#{'─' * w}\e[0m\e[K"
207
+
208
+ if @message
209
+ puts " \e[1;32m#{@message}\e[0m\e[K"
210
+ @message = nil
211
+ else
212
+ puts truncate(" \e[90mEsc Back ↑↓ Scroll r Retry d Discard q Quit\e[0m", w) + "\e[K"
213
+ end
214
+ end
215
+
216
+ def build_detail_lines(job)
217
+ lines = []
218
+
219
+ lines << "\e[1mClass:\e[0m #{job.class_name}"
220
+ lines << "\e[1mQueue:\e[0m #{job.queue_name}"
221
+ lines << "\e[1mPriority:\e[0m #{job.priority || '—'}"
222
+ lines << "\e[1mActive Job:\e[0m #{job.active_job_id || '—'}"
223
+ lines << ""
224
+
225
+ status = job_status(job)
226
+ lines << "\e[1mStatus:\e[0m #{status_text(status)}"
227
+ lines << ""
228
+
229
+ lines << "\e[1mCreated:\e[0m #{job.created_at || '—'}"
230
+ lines << "\e[1mScheduled:\e[0m #{job.scheduled_at || '—'}"
231
+ lines << "\e[1mFinished:\e[0m #{job.finished_at || '—'}"
232
+ lines << ""
233
+
234
+ lines << "\e[1mArguments:\e[0m"
235
+ if job.arguments.nil? || job.arguments.empty?
236
+ lines << " —"
237
+ else
238
+ begin
239
+ args = JSON.parse(job.arguments)
240
+ JSON.pretty_generate(args).each_line { |l| lines << " #{l.chomp}" }
241
+ rescue JSON::ParserError, TypeError
242
+ lines << " #{job.arguments}"
243
+ end
244
+ end
245
+
246
+ if status == :failed && job.failed_execution
247
+ lines << ""
248
+ lines << "\e[1;31mError:\e[0m"
249
+ error_text = job.failed_execution.error || "No error message"
250
+ error_text.each_line { |l| lines << " #{l.chomp}" }
251
+ end
252
+
253
+ lines
254
+ end
255
+ end
256
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sqdash
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/sqdash.rb CHANGED
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "sqdash/version"
4
+ require_relative "sqdash/config"
4
5
  require_relative "sqdash/database"
5
6
  require_relative "sqdash/models/job"
6
7
  require_relative "sqdash/models/failed_execution"
7
8
  require_relative "sqdash/models/ready_execution"
9
+ require_relative "sqdash/renderer"
10
+ require_relative "sqdash/autocomplete"
11
+ require_relative "sqdash/input_handler"
8
12
  require_relative "sqdash/cli"
9
13
 
10
14
  module Sqdash
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sqdash
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nuha
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-03-15 00:00:00.000000000 Z
11
+ date: 2026-03-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -34,16 +34,21 @@ executables:
34
34
  extensions: []
35
35
  extra_rdoc_files: []
36
36
  files:
37
+ - ".rubocop.yml"
37
38
  - LICENSE.txt
38
39
  - README.md
39
40
  - Rakefile
40
41
  - exe/sqdash
41
42
  - lib/sqdash.rb
43
+ - lib/sqdash/autocomplete.rb
42
44
  - lib/sqdash/cli.rb
45
+ - lib/sqdash/config.rb
43
46
  - lib/sqdash/database.rb
47
+ - lib/sqdash/input_handler.rb
44
48
  - lib/sqdash/models/failed_execution.rb
45
49
  - lib/sqdash/models/job.rb
46
50
  - lib/sqdash/models/ready_execution.rb
51
+ - lib/sqdash/renderer.rb
47
52
  - lib/sqdash/version.rb
48
53
  - sig/sqdash.rbs
49
54
  homepage: https://github.com/nuhasami/sqdash
@@ -52,6 +57,7 @@ licenses:
52
57
  metadata:
53
58
  homepage_uri: https://github.com/nuhasami/sqdash
54
59
  source_code_uri: https://github.com/nuhasami/sqdash
60
+ rubygems_mfa_required: 'true'
55
61
  post_install_message:
56
62
  rdoc_options: []
57
63
  require_paths:
@@ -60,7 +66,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
60
66
  requirements:
61
67
  - - ">="
62
68
  - !ruby/object:Gem::Version
63
- version: 3.0.0
69
+ version: 3.2.0
64
70
  required_rubygems_version: !ruby/object:Gem::Requirement
65
71
  requirements:
66
72
  - - ">="