solid_queue_tui 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.
Files changed (31) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/exe/sqtui +6 -0
  4. data/lib/solid_queue_tui/actions/discard_job.rb +34 -0
  5. data/lib/solid_queue_tui/actions/discard_scheduled_job.rb +33 -0
  6. data/lib/solid_queue_tui/actions/dispatch_scheduled_job.rb +35 -0
  7. data/lib/solid_queue_tui/actions/retry_job.rb +76 -0
  8. data/lib/solid_queue_tui/application.rb +468 -0
  9. data/lib/solid_queue_tui/cli.rb +48 -0
  10. data/lib/solid_queue_tui/components/header.rb +105 -0
  11. data/lib/solid_queue_tui/components/help_bar.rb +77 -0
  12. data/lib/solid_queue_tui/components/job_table.rb +122 -0
  13. data/lib/solid_queue_tui/connection.rb +58 -0
  14. data/lib/solid_queue_tui/data/failed_query.rb +118 -0
  15. data/lib/solid_queue_tui/data/jobs_query.rb +178 -0
  16. data/lib/solid_queue_tui/data/processes_query.rb +75 -0
  17. data/lib/solid_queue_tui/data/queues_query.rb +36 -0
  18. data/lib/solid_queue_tui/data/stats.rb +65 -0
  19. data/lib/solid_queue_tui/dev_reloader.rb +53 -0
  20. data/lib/solid_queue_tui/version.rb +5 -0
  21. data/lib/solid_queue_tui/views/blocked_view.rb +121 -0
  22. data/lib/solid_queue_tui/views/dashboard_view.rb +187 -0
  23. data/lib/solid_queue_tui/views/failed_view.rb +298 -0
  24. data/lib/solid_queue_tui/views/finished_view.rb +216 -0
  25. data/lib/solid_queue_tui/views/in_progress_view.rb +114 -0
  26. data/lib/solid_queue_tui/views/job_detail_view.rb +236 -0
  27. data/lib/solid_queue_tui/views/processes_view.rb +142 -0
  28. data/lib/solid_queue_tui/views/queues_view.rb +96 -0
  29. data/lib/solid_queue_tui/views/scheduled_view.rb +215 -0
  30. data/lib/solid_queue_tui.rb +46 -0
  31. metadata +157 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d6243ef7535750aa1f1082a43021898238d2cad4af26ce9292ca1d70f85016c1
4
+ data.tar.gz: 9ddb31de0c4c129ffadabbf5119bf963a5d8d14fe19a17619e8f30af019194ae
5
+ SHA512:
6
+ metadata.gz: 52fc648194283f11b06f8c20446d496deec0f754f44b96b146f52beeae548474263a5d89efbb939d0413490f38aee96aa1723abaefd79bab26124cc0a249251f
7
+ data.tar.gz: e43a9df45b2de55da0a1fb28358d7f0d2ac14adabdf0a861c6585ff94231df03602bb55ca581af6ddcfd7d467831660fab24dbf7443068c568412b9eeb102ceb
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Shiva Reddy
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/exe/sqtui ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "solid_queue_tui"
5
+
6
+ SolidQueueTui::CLI.run(ARGV)
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module Actions
5
+ class DiscardJob
6
+ def self.call(failed_execution_id)
7
+ conn = ActiveRecord::Base.connection
8
+
9
+ row = conn.select_one(
10
+ "SELECT fe.id, fe.job_id FROM solid_queue_failed_executions fe " \
11
+ "WHERE fe.id = #{conn.quote(failed_execution_id.to_i)}"
12
+ )
13
+ return false unless row
14
+
15
+ conn.transaction do
16
+ # Remove the failed execution
17
+ conn.execute(
18
+ "DELETE FROM solid_queue_failed_executions WHERE id = #{conn.quote(failed_execution_id.to_i)}"
19
+ )
20
+
21
+ # Mark the job as finished (discarded)
22
+ conn.execute(
23
+ "UPDATE solid_queue_jobs SET finished_at = #{conn.quote(Time.now.utc.iso8601)} " \
24
+ "WHERE id = #{conn.quote(row['job_id'])}"
25
+ )
26
+ end
27
+
28
+ true
29
+ rescue => e
30
+ false
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module Actions
5
+ class DiscardScheduledJob
6
+ def self.call(job_id)
7
+ conn = ActiveRecord::Base.connection
8
+
9
+ row = conn.select_one(
10
+ "SELECT se.id, se.job_id " \
11
+ "FROM solid_queue_scheduled_executions se " \
12
+ "WHERE se.job_id = #{conn.quote(job_id.to_i)}"
13
+ )
14
+ return false unless row
15
+
16
+ conn.transaction do
17
+ conn.execute(
18
+ "DELETE FROM solid_queue_scheduled_executions WHERE id = #{conn.quote(row['id'])}"
19
+ )
20
+
21
+ conn.execute(
22
+ "UPDATE solid_queue_jobs SET finished_at = #{conn.quote(Time.now.utc.iso8601)} " \
23
+ "WHERE id = #{conn.quote(row['job_id'])}"
24
+ )
25
+ end
26
+
27
+ true
28
+ rescue => e
29
+ false
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module Actions
5
+ class DispatchScheduledJob
6
+ def self.call(job_id)
7
+ conn = ActiveRecord::Base.connection
8
+
9
+ row = conn.select_one(
10
+ "SELECT se.id, se.job_id, j.queue_name, j.priority " \
11
+ "FROM solid_queue_scheduled_executions se " \
12
+ "JOIN solid_queue_jobs j ON j.id = se.job_id " \
13
+ "WHERE j.id = #{conn.quote(job_id.to_i)}"
14
+ )
15
+ return false unless row
16
+
17
+ conn.transaction do
18
+ conn.execute(
19
+ "INSERT INTO solid_queue_ready_executions (job_id, queue_name, priority, created_at) " \
20
+ "VALUES (#{conn.quote(row['job_id'])}, #{conn.quote(row['queue_name'])}, " \
21
+ "#{conn.quote(row['priority'])}, #{conn.quote(Time.now.utc.iso8601)})"
22
+ )
23
+
24
+ conn.execute(
25
+ "DELETE FROM solid_queue_scheduled_executions WHERE id = #{conn.quote(row['id'])}"
26
+ )
27
+ end
28
+
29
+ true
30
+ rescue => e
31
+ false
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module Actions
5
+ class RetryJob
6
+ def self.call(failed_execution_id)
7
+ conn = ActiveRecord::Base.connection
8
+
9
+ # Get the failed execution and its job
10
+ row = conn.select_one(
11
+ "SELECT fe.id, fe.job_id, j.queue_name, j.priority " \
12
+ "FROM solid_queue_failed_executions fe " \
13
+ "JOIN solid_queue_jobs j ON j.id = fe.job_id " \
14
+ "WHERE fe.id = #{conn.quote(failed_execution_id.to_i)}"
15
+ )
16
+ return false unless row
17
+
18
+ conn.transaction do
19
+ # Create a ready execution for the job
20
+ conn.execute(
21
+ "INSERT INTO solid_queue_ready_executions (job_id, queue_name, priority, created_at) " \
22
+ "VALUES (#{conn.quote(row['job_id'])}, #{conn.quote(row['queue_name'])}, " \
23
+ "#{conn.quote(row['priority'])}, #{conn.quote(Time.now.utc.iso8601)})"
24
+ )
25
+
26
+ # Remove the failed execution
27
+ conn.execute(
28
+ "DELETE FROM solid_queue_failed_executions WHERE id = #{conn.quote(failed_execution_id.to_i)}"
29
+ )
30
+
31
+ # Clear finished_at on the job
32
+ conn.execute(
33
+ "UPDATE solid_queue_jobs SET finished_at = NULL " \
34
+ "WHERE id = #{conn.quote(row['job_id'])}"
35
+ )
36
+ end
37
+
38
+ true
39
+ rescue => e
40
+ false
41
+ end
42
+
43
+ def self.retry_all
44
+ conn = ActiveRecord::Base.connection
45
+
46
+ rows = conn.select_all(
47
+ "SELECT fe.id, fe.job_id, j.queue_name, j.priority " \
48
+ "FROM solid_queue_failed_executions fe " \
49
+ "JOIN solid_queue_jobs j ON j.id = fe.job_id"
50
+ )
51
+
52
+ count = 0
53
+ rows.each do |row|
54
+ conn.transaction do
55
+ conn.execute(
56
+ "INSERT INTO solid_queue_ready_executions (job_id, queue_name, priority, created_at) " \
57
+ "VALUES (#{conn.quote(row['job_id'])}, #{conn.quote(row['queue_name'])}, " \
58
+ "#{conn.quote(row['priority'])}, #{conn.quote(Time.now.utc.iso8601)})"
59
+ )
60
+ conn.execute(
61
+ "DELETE FROM solid_queue_failed_executions WHERE id = #{conn.quote(row['id'])}"
62
+ )
63
+ conn.execute(
64
+ "UPDATE solid_queue_jobs SET finished_at = NULL WHERE id = #{conn.quote(row['job_id'])}"
65
+ )
66
+ count += 1
67
+ end
68
+ rescue
69
+ next
70
+ end
71
+
72
+ count
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,468 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ratatui_ruby"
4
+ require "json"
5
+
6
+ module SolidQueueTui
7
+ class Application
8
+ VIEW_DASHBOARD = 0
9
+ VIEW_QUEUES = 1
10
+ VIEW_FAILED = 2
11
+ VIEW_IN_PROGRESS = 3
12
+ VIEW_BLOCKED = 4
13
+ VIEW_SCHEDULED = 5
14
+ VIEW_FINISHED = 6
15
+ VIEW_WORKERS = 7
16
+
17
+ VIEW_COUNT = 8
18
+
19
+ COMMAND_MAP = {
20
+ "dashboard" => VIEW_DASHBOARD,
21
+ "queues" => VIEW_QUEUES,
22
+ "failed" => VIEW_FAILED,
23
+ "inprogress" => VIEW_IN_PROGRESS,
24
+ "blocked" => VIEW_BLOCKED,
25
+ "scheduled" => VIEW_SCHEDULED,
26
+ "finished" => VIEW_FINISHED,
27
+ "workers" => VIEW_WORKERS
28
+ }.freeze
29
+
30
+ def initialize(dev: false)
31
+ @current_view = VIEW_DASHBOARD
32
+ @last_refresh = Time.at(0)
33
+ @stats = Data::Stats.empty
34
+ @show_help = false
35
+ @command_mode = false
36
+ @command_input = ""
37
+ @command_error = nil
38
+ @dev = dev
39
+ end
40
+
41
+ def run
42
+ config = Connection.establish!
43
+ @refresh_interval = config.fetch("refresh", 2).to_i
44
+ setup_dev_reloader! if @dev
45
+
46
+ RatatuiRuby.run do |tui|
47
+ @tui = tui
48
+ init_views
49
+ refresh_data!
50
+
51
+ loop do
52
+ hot_reload! if @dev
53
+ render
54
+ event = @tui.poll_event
55
+ refresh_data_if_needed
56
+ break if handle_input(event)
57
+ end
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def init_views
64
+ @views = {
65
+ VIEW_DASHBOARD => Views::DashboardView.new(@tui),
66
+ VIEW_QUEUES => Views::QueuesView.new(@tui),
67
+ VIEW_FAILED => Views::FailedView.new(@tui),
68
+ VIEW_IN_PROGRESS => Views::InProgressView.new(@tui),
69
+ VIEW_BLOCKED => Views::BlockedView.new(@tui),
70
+ VIEW_SCHEDULED => Views::ScheduledView.new(@tui),
71
+ VIEW_FINISHED => Views::FinishedView.new(@tui),
72
+ VIEW_WORKERS => Views::ProcessesView.new(@tui)
73
+ }
74
+ @job_detail = Views::JobDetailView.new(@tui)
75
+ end
76
+
77
+ def render
78
+ @tui.draw do |frame|
79
+ if @show_help
80
+ render_help_overlay(frame, frame.area)
81
+ return
82
+ end
83
+
84
+ # Layout: header (6) | content (fill) | help_bar (1)
85
+ header_area, content_area, help_area = @tui.layout_split(
86
+ frame.area,
87
+ direction: :vertical,
88
+ constraints: [
89
+ @tui.constraint_length(6),
90
+ @tui.constraint_fill(1),
91
+ @tui.constraint_length(1)
92
+ ]
93
+ )
94
+
95
+ # Header
96
+ Components::Header.new(
97
+ @tui,
98
+ current_view: @current_view
99
+ ).render(frame, header_area)
100
+
101
+ # Content — current view or detail overlay
102
+ if @job_detail.active?
103
+ @job_detail.render(frame, content_area)
104
+ else
105
+ current_view.render(frame, content_area)
106
+ end
107
+
108
+ # Help bar or command input
109
+ if @command_mode
110
+ render_command_bar(frame, help_area)
111
+ else
112
+ active_view = @job_detail.active? ? @job_detail : current_view
113
+ Components::HelpBar.new(
114
+ @tui,
115
+ breadcrumb: active_view.breadcrumb,
116
+ bindings: active_view.bindings,
117
+ status: status_message
118
+ ).render(frame, help_area)
119
+ end
120
+ end
121
+ end
122
+
123
+ def handle_input(event)
124
+ return false unless event
125
+
126
+ # Job detail overlay gets priority
127
+ if @job_detail.active?
128
+ result = @job_detail.handle_input(event)
129
+ refresh_data! if result == :refresh
130
+ return false
131
+ end
132
+
133
+ # Help overlay
134
+ if @show_help
135
+ case event
136
+ in { type: :key, code: "esc" } | { type: :key, code: "?" } | { type: :key, code: "q" }
137
+ @show_help = false
138
+ else
139
+ nil
140
+ end
141
+ return false
142
+ end
143
+
144
+ # If view is in a modal state (filter, confirm), it gets all input
145
+ if current_view.respond_to?(:capturing_input?) && current_view.capturing_input?
146
+ result = current_view.handle_input(event)
147
+ refresh_data! if result == :refresh
148
+ return false
149
+ end
150
+
151
+ # Command mode input
152
+ if @command_mode
153
+ handle_command_input(event)
154
+ return @quit || false
155
+ end
156
+
157
+ # Global keybindings
158
+ case event
159
+ in { type: :key, code: "q" }
160
+ return true
161
+ in { type: :key, code: "c", modifiers: ["ctrl"] }
162
+ return true
163
+ in { type: :key, code: "esc" }
164
+ switch_view(VIEW_DASHBOARD) if @current_view != VIEW_DASHBOARD
165
+ return false
166
+ in { type: :key, code: "r" }
167
+ refresh_data!
168
+ return false
169
+ in { type: :key, code: "?" }
170
+ @show_help = true
171
+ return false
172
+ in { type: :key, code: "1" }
173
+ switch_view(VIEW_DASHBOARD)
174
+ return false
175
+ in { type: :key, code: "2" }
176
+ switch_view(VIEW_QUEUES)
177
+ return false
178
+ in { type: :key, code: "3" }
179
+ switch_view(VIEW_FAILED)
180
+ return false
181
+ in { type: :key, code: "4" }
182
+ switch_view(VIEW_IN_PROGRESS)
183
+ return false
184
+ in { type: :key, code: "5" }
185
+ switch_view(VIEW_BLOCKED)
186
+ return false
187
+ in { type: :key, code: "6" }
188
+ switch_view(VIEW_SCHEDULED)
189
+ return false
190
+ in { type: :key, code: "7" }
191
+ switch_view(VIEW_FINISHED)
192
+ return false
193
+ in { type: :key, code: "8" }
194
+ switch_view(VIEW_WORKERS)
195
+ return false
196
+ in { type: :key, code: "tab" }
197
+ switch_view((@current_view + 1) % VIEW_COUNT)
198
+ return false
199
+ in { type: :key, code: "enter" }
200
+ open_detail
201
+ return false
202
+ in { type: :key, code: ":" }
203
+ @command_mode = true
204
+ @command_input = ""
205
+ @command_error = nil
206
+ return false
207
+ else
208
+ nil
209
+ end
210
+
211
+ # Pass to current view
212
+ result = current_view.handle_input(event)
213
+ refresh_data! if result == :refresh
214
+
215
+ false
216
+ end
217
+
218
+ def current_view
219
+ @views[@current_view]
220
+ end
221
+
222
+ def switch_view(index)
223
+ @current_view = index
224
+ refresh_data!
225
+ end
226
+
227
+ def open_detail
228
+ item = current_view.selected_item
229
+ return unless item
230
+
231
+ case @current_view
232
+ when VIEW_FAILED
233
+ failed_job = Data::FailedQuery.fetch_one(item.id) if item.respond_to?(:id)
234
+ @job_detail.show(failed_job: failed_job || item)
235
+ when VIEW_IN_PROGRESS, VIEW_BLOCKED, VIEW_SCHEDULED, VIEW_FINISHED
236
+ @job_detail.show(job: item) if item.respond_to?(:id)
237
+ end
238
+ end
239
+
240
+ def refresh_data_if_needed
241
+ return if Time.now - @last_refresh < @refresh_interval
242
+ refresh_data!
243
+ end
244
+
245
+ def refresh_data!
246
+ @stats = Data::Stats.fetch
247
+ @last_refresh = Time.now
248
+
249
+ case @current_view
250
+ when VIEW_DASHBOARD
251
+ current_view.update(stats: @stats)
252
+ when VIEW_QUEUES
253
+ queues = Data::QueuesQuery.fetch
254
+ current_view.update(queues: queues)
255
+ when VIEW_FAILED
256
+ filter = current_view.filter
257
+ failed_jobs = Data::FailedQuery.fetch(filter: filter)
258
+ current_view.update(failed_jobs: failed_jobs)
259
+ when VIEW_IN_PROGRESS
260
+ jobs = Data::JobsQuery.fetch(status: "claimed")
261
+ current_view.update(jobs: jobs)
262
+ when VIEW_BLOCKED
263
+ jobs = Data::JobsQuery.fetch(status: "blocked")
264
+ current_view.update(jobs: jobs)
265
+ when VIEW_SCHEDULED
266
+ jobs = Data::JobsQuery.fetch(status: "scheduled")
267
+ current_view.update(jobs: jobs)
268
+ when VIEW_FINISHED
269
+ filter = current_view.respond_to?(:filter) ? current_view.filter : nil
270
+ jobs = Data::JobsQuery.fetch(status: "completed", filter: filter)
271
+ current_view.update(jobs: jobs)
272
+ when VIEW_WORKERS
273
+ processes = Data::ProcessesQuery.fetch
274
+ current_view.update(processes: processes)
275
+ end
276
+ rescue => e
277
+ # Silently handle refresh errors to keep TUI responsive
278
+ end
279
+
280
+ def setup_dev_reloader!
281
+ lib_dir = File.expand_path("../..", __FILE__)
282
+ @reloader = DevReloader.new(lib_dir)
283
+ end
284
+
285
+ # Check for file changes and re-instantiate views if code was reloaded.
286
+ def hot_reload!
287
+ return unless @reloader
288
+ if @reloader.check!
289
+ init_views
290
+ refresh_data!
291
+ end
292
+ end
293
+
294
+ def handle_command_input(event)
295
+ case event
296
+ in { type: :key, code: "enter" }
297
+ execute_command(@command_input.strip)
298
+ @command_mode = false
299
+ @command_input = ""
300
+ in { type: :key, code: "esc" }
301
+ @command_mode = false
302
+ @command_input = ""
303
+ @command_error = nil
304
+ in { type: :key, code: "tab" }
305
+ autocomplete_command
306
+ in { type: :key, code: "backspace" }
307
+ @command_input = @command_input[0...-1]
308
+ in { type: :key, code: /\A.\z/ => char }
309
+ @command_input += char
310
+ else
311
+ nil
312
+ end
313
+ end
314
+
315
+ def autocomplete_command
316
+ input = @command_input.strip
317
+ commands = COMMAND_MAP.keys
318
+
319
+ if input.empty?
320
+ @command_input = commands.first
321
+ return
322
+ end
323
+
324
+ matches = commands.select { |cmd| cmd.start_with?(input) }
325
+ return if matches.empty?
326
+
327
+ if matches.size == 1
328
+ @command_input = matches.first
329
+ elsif @command_input == matches.first
330
+ # Already on first match, cycle to next
331
+ idx = matches.index(@command_input) || 0
332
+ @command_input = matches[(idx + 1) % matches.size]
333
+ else
334
+ # Complete to first match
335
+ @command_input = matches.first
336
+ end
337
+ end
338
+
339
+ def execute_command(input)
340
+ return if input.empty?
341
+
342
+ if %w[quit exit].include?(input)
343
+ @quit = true
344
+ return
345
+ end
346
+
347
+ # Exact match first
348
+ if COMMAND_MAP.key?(input)
349
+ switch_view(COMMAND_MAP[input])
350
+ return
351
+ end
352
+
353
+ # Prefix match
354
+ matches = COMMAND_MAP.keys.select { |cmd| cmd.start_with?(input) }
355
+ if matches.size == 1
356
+ switch_view(COMMAND_MAP[matches.first])
357
+ end
358
+ end
359
+
360
+ def render_command_bar(frame, area)
361
+ # Show completions as hint
362
+ input = @command_input.strip
363
+ hint = if input.empty?
364
+ COMMAND_MAP.keys.uniq { |k| COMMAND_MAP[k] }.join(" ")
365
+ else
366
+ matches = COMMAND_MAP.keys.select { |cmd| cmd.start_with?(input) }
367
+ matches.empty? ? "no match" : matches.join(" ")
368
+ end
369
+
370
+ frame.render_widget(
371
+ @tui.paragraph(
372
+ text: @tui.text_line(spans: [
373
+ @tui.text_span(content: ":", style: @tui.style(fg: :cyan, modifiers: [:bold])),
374
+ @tui.text_span(content: @command_input, style: @tui.style(fg: :white)),
375
+ @tui.text_span(content: "\u2588", style: @tui.style(fg: :white)),
376
+ @tui.text_span(content: " #{hint}", style: @tui.style(fg: :dark_gray))
377
+ ]),
378
+ style: @tui.style(fg: :white)
379
+ ),
380
+ area
381
+ )
382
+ end
383
+
384
+ def status_message
385
+ elapsed = (Time.now - @last_refresh).to_i
386
+ "Last refresh: #{elapsed}s ago"
387
+ end
388
+
389
+ def render_help_overlay(frame, area)
390
+ help_text = [
391
+ @tui.text_line(spans: [
392
+ @tui.text_span(content: " SOLID QUEUE TUI — KEYBOARD SHORTCUTS", style: @tui.style(fg: :yellow, modifiers: [:bold]))
393
+ ]),
394
+ empty_line,
395
+ help_section("Navigation"),
396
+ help_line("1-8", "Switch between views"),
397
+ help_line("Tab", "Next view"),
398
+ help_line(":", "Command mode (:queues, :failed, ...)"),
399
+ help_line("Esc", "Back to Dashboard"),
400
+ help_line("j / Up", "Move selection up"),
401
+ help_line("k / Down", "Move selection down"),
402
+ help_line("g", "Jump to top"),
403
+ help_line("G", "Jump to bottom"),
404
+ help_line("Enter", "View details"),
405
+ empty_line,
406
+ help_section("Actions"),
407
+ help_line("r", "Refresh data"),
408
+ help_line("/", "Filter by class name"),
409
+ help_line("R", "Retry failed job (in Failed view)"),
410
+ help_line("D", "Discard failed job (in Failed view)"),
411
+ help_line("A", "Retry all failed jobs"),
412
+ empty_line,
413
+ help_section("Views"),
414
+ help_line("1", "Dashboard — Overview with stats"),
415
+ help_line("2", "Queues — Per-queue breakdown"),
416
+ help_line("3", "Failed — Failed jobs with errors"),
417
+ help_line("4", "In Progress — Currently processing"),
418
+ help_line("5", "Blocked — Concurrency-blocked jobs"),
419
+ help_line("6", "Scheduled — Future scheduled jobs"),
420
+ help_line("7", "Finished — Completed jobs"),
421
+ help_line("8", "Workers — Active processes"),
422
+ empty_line,
423
+ help_section("General"),
424
+ help_line("?", "Toggle this help"),
425
+ help_line("q", "Quit"),
426
+ help_line("Ctrl+C", "Force quit")
427
+ ]
428
+
429
+ frame.render_widget(
430
+ @tui.paragraph(
431
+ text: help_text,
432
+ block: @tui.block(
433
+ title: " Help ",
434
+ title_style: @tui.style(fg: :cyan, modifiers: [:bold]),
435
+ titles: [
436
+ { content: " Press ? or Esc to close ",
437
+ position: :bottom, alignment: :center }
438
+ ],
439
+ borders: [:all],
440
+ border_type: :rounded,
441
+ border_style: @tui.style(fg: :cyan),
442
+ style: @tui.style(fg: :white)
443
+ )
444
+ ),
445
+ area
446
+ )
447
+ end
448
+
449
+ def help_section(title)
450
+ @tui.text_line(spans: [
451
+ @tui.text_span(content: " ── #{title} ──", style: @tui.style(fg: :cyan, modifiers: [:bold]))
452
+ ])
453
+ end
454
+
455
+ def help_line(key, desc)
456
+ @tui.text_line(spans: [
457
+ @tui.text_span(content: " #{key.ljust(14)}", style: @tui.style(fg: :green, modifiers: [:bold])),
458
+ @tui.text_span(content: desc, style: @tui.style(fg: :white))
459
+ ])
460
+ end
461
+
462
+ def empty_line
463
+ @tui.text_line(spans: [
464
+ @tui.text_span(content: "", style: @tui.style(fg: :white))
465
+ ])
466
+ end
467
+ end
468
+ end