solid_queue_monitor 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.
Files changed (24) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -4
  3. data/app/controllers/solid_queue_monitor/base_controller.rb +194 -0
  4. data/app/controllers/solid_queue_monitor/failed_jobs_controller.rb +62 -0
  5. data/app/controllers/solid_queue_monitor/in_progress_jobs_controller.rb +27 -0
  6. data/app/controllers/solid_queue_monitor/monitor_controller.rb +75 -7
  7. data/app/controllers/solid_queue_monitor/overview_controller.rb +25 -0
  8. data/app/controllers/solid_queue_monitor/queues_controller.rb +11 -0
  9. data/app/controllers/solid_queue_monitor/ready_jobs_controller.rb +14 -0
  10. data/app/controllers/solid_queue_monitor/recurring_jobs_controller.rb +14 -0
  11. data/app/controllers/solid_queue_monitor/scheduled_jobs_controller.rb +24 -0
  12. data/app/presenters/solid_queue_monitor/base_presenter.rb +57 -48
  13. data/app/presenters/solid_queue_monitor/failed_jobs_presenter.rb +257 -21
  14. data/app/presenters/solid_queue_monitor/in_progress_jobs_presenter.rb +71 -0
  15. data/app/presenters/solid_queue_monitor/jobs_presenter.rb +36 -2
  16. data/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb +33 -10
  17. data/app/presenters/solid_queue_monitor/stats_presenter.rb +2 -2
  18. data/app/services/solid_queue_monitor/failed_job_service.rb +97 -0
  19. data/app/services/solid_queue_monitor/html_generator.rb +28 -2
  20. data/app/services/solid_queue_monitor/stats_calculator.rb +1 -0
  21. data/app/services/solid_queue_monitor/stylesheet_generator.rb +150 -1
  22. data/config/routes.rb +13 -7
  23. data/lib/solid_queue_monitor/version.rb +2 -2
  24. metadata +12 -2
@@ -22,55 +22,37 @@ module SolidQueueMonitor
22
22
  def generate_pagination(current_page, total_pages)
23
23
  return '' if total_pages <= 1
24
24
 
25
- links = []
25
+ html = '<div class="pagination">'
26
26
 
27
27
  # Previous page link
28
28
  if current_page > 1
29
- links << "<a href='?page=#{current_page - 1}#{query_params}' class='pagination-link'>&laquo; Previous</a>"
29
+ html += "<a href=\"?page=#{current_page - 1}#{query_params}\" class=\"pagination-link pagination-nav\">Previous</a>"
30
30
  else
31
- links << "<span class='pagination-link disabled'>&laquo; Previous</span>"
31
+ html += '<span class="pagination-link pagination-nav disabled">Previous</span>'
32
32
  end
33
33
 
34
- # Page number links
35
- if total_pages <= 7
36
- # Show all pages if there are 7 or fewer
37
- (1..total_pages).each do |page|
38
- links << page_link(page, current_page)
39
- end
40
- else
41
- # Show first page, last page, and pages around current
42
- links << page_link(1, current_page)
43
-
44
- if current_page > 3
45
- links << "<span class='pagination-gap'>...</span>"
46
- end
47
-
48
- start_page = [current_page - 1, 2].max
49
- end_page = [current_page + 1, total_pages - 1].min
50
-
51
- (start_page..end_page).each do |page|
52
- links << page_link(page, current_page)
53
- end
54
-
55
- if current_page < total_pages - 2
56
- links << "<span class='pagination-gap'>...</span>"
34
+ # Page links
35
+ visible_pages = calculate_visible_pages(current_page, total_pages)
36
+
37
+ visible_pages.each do |page|
38
+ if page == :gap
39
+ html += "<span class=\"pagination-gap\">...</span>"
40
+ elsif page == current_page
41
+ html += "<span class=\"pagination-current\">#{page}</span>"
42
+ else
43
+ html += "<a href=\"?page=#{page}#{query_params}\" class=\"pagination-link\">#{page}</a>"
57
44
  end
58
-
59
- links << page_link(total_pages, current_page)
60
45
  end
61
46
 
62
47
  # Next page link
63
48
  if current_page < total_pages
64
- links << "<a href='?page=#{current_page + 1}#{query_params}' class='pagination-link'>Next &raquo;</a>"
49
+ html += "<a href=\"?page=#{current_page + 1}#{query_params}\" class=\"pagination-link pagination-nav\">Next</a>"
65
50
  else
66
- links << "<span class='pagination-link disabled'>Next &raquo;</span>"
51
+ html += '<span class="pagination-link pagination-nav disabled">Next</span>'
67
52
  end
68
53
 
69
- <<-HTML
70
- <div class="pagination">
71
- #{links.join}
72
- </div>
73
- HTML
54
+ html += '</div>'
55
+ html
74
56
  end
75
57
 
76
58
  def calculate_visible_pages(current_page, total_pages)
@@ -96,12 +78,15 @@ module SolidQueueMonitor
96
78
  def format_arguments(arguments)
97
79
  return '-' unless arguments.present?
98
80
 
99
- if arguments.is_a?(Array) && arguments.length == 1 && arguments[0].is_a?(Hash)
100
- # Handle ActiveJob-style arguments
101
- format_hash(arguments[0])
102
- else
103
- "<code>#{arguments.to_json}</code>"
81
+ # For ActiveJob format
82
+ if arguments.is_a?(Hash) && arguments['arguments'].present?
83
+ return "<code>#{arguments['arguments'].inspect}</code>"
84
+ elsif arguments.is_a?(Array) && arguments.length == 1 && arguments[0].is_a?(Hash) && arguments[0]['arguments'].present?
85
+ return "<code>#{arguments[0]['arguments'].inspect}</code>"
104
86
  end
87
+
88
+ # For regular arguments format
89
+ "<code>#{arguments.inspect}</code>"
105
90
  end
106
91
 
107
92
  def format_hash(hash)
@@ -114,23 +99,47 @@ module SolidQueueMonitor
114
99
  "<code>#{formatted}</code>"
115
100
  end
116
101
 
117
- private
102
+ # Helper method to get the current request path
103
+ def request_path
104
+ # Try to get the current path from the controller's request
105
+ if defined?(controller) && controller.respond_to?(:request)
106
+ controller.request.path
107
+ else
108
+ # Fallback to a default path if we can't get the current path
109
+ "/solid_queue"
110
+ end
111
+ end
118
112
 
119
- def page_link(page, current_page)
120
- if page == current_page
121
- "<span class='pagination-current'>#{page}</span>"
113
+ # Helper method to get the mount point of the engine
114
+ def engine_mount_point
115
+ path_parts = request_path.split('/')
116
+ if path_parts.length >= 3
117
+ "/#{path_parts[1]}/#{path_parts[2]}"
122
118
  else
123
- "<a href='?page=#{page}#{query_params}' class='pagination-link'>#{page}</a>"
119
+ "/solid_queue"
124
120
  end
125
121
  end
126
122
 
123
+ private
124
+
127
125
  def query_params
128
126
  params = []
129
- params << "class_name=#{CGI.escape(@filters[:class_name])}" if @filters && @filters[:class_name].present?
130
- params << "queue_name=#{CGI.escape(@filters[:queue_name])}" if @filters && @filters[:queue_name].present?
131
- params << "status=#{CGI.escape(@filters[:status])}" if @filters && @filters[:status].present?
127
+ params << "class_name=#{@filters[:class_name]}" if @filters && @filters[:class_name].present?
128
+ params << "queue_name=#{@filters[:queue_name]}" if @filters && @filters[:queue_name].present?
129
+ params << "status=#{@filters[:status]}" if @filters && @filters[:status].present?
132
130
 
133
131
  params.empty? ? '' : "&#{params.join('&')}"
134
132
  end
133
+
134
+ # Helper method to get the full path for a route
135
+ def full_path(route_name, *args)
136
+ begin
137
+ # Try to use the engine routes first
138
+ SolidQueueMonitor::Engine.routes.url_helpers.send(route_name, *args)
139
+ rescue NoMethodError
140
+ # Fall back to main app routes
141
+ Rails.application.routes.url_helpers.send("solid_queue_#{route_name}", *args)
142
+ end
143
+ end
135
144
  end
136
145
  end
@@ -1,5 +1,8 @@
1
1
  module SolidQueueMonitor
2
2
  class FailedJobsPresenter < BasePresenter
3
+ include Rails.application.routes.url_helpers
4
+ include SolidQueueMonitor::Engine.routes.url_helpers
5
+
3
6
  def initialize(jobs, current_page: 1, total_pages: 1, filters: {})
4
7
  @jobs = jobs
5
8
  @current_page = current_page
@@ -16,7 +19,7 @@ module SolidQueueMonitor
16
19
  def generate_filter_form
17
20
  <<-HTML
18
21
  <div class="filter-form-container">
19
- <form method="get" action="" class="filter-form">
22
+ <form method="get" action="#{failed_jobs_path}" class="filter-form">
20
23
  <div class="filter-group">
21
24
  <label for="class_name">Job Class:</label>
22
25
  <input type="text" name="class_name" id="class_name" value="#{@filters[:class_name]}" placeholder="Filter by class name">
@@ -33,38 +36,271 @@ module SolidQueueMonitor
33
36
  </div>
34
37
  </form>
35
38
  </div>
39
+
40
+ <div class="bulk-actions-bar">
41
+ <button type="button" class="action-button retry-button" id="retry-selected-top" disabled>Retry Selected</button>
42
+ <button type="button" class="action-button discard-button" id="discard-selected-top" disabled>Discard Selected</button>
43
+ </div>
36
44
  HTML
37
45
  end
38
46
 
39
47
  def generate_table
40
48
  <<-HTML
41
- <div class="table-container">
42
- <table>
43
- <thead>
44
- <tr>
45
- <th>Job</th>
46
- <th>Error</th>
47
- <th>Failed At</th>
48
- <th>Arguments</th>
49
- </tr>
50
- </thead>
51
- <tbody>
52
- #{@jobs.map { |execution| generate_row(execution) }.join}
53
- </tbody>
54
- </table>
55
- </div>
49
+ <form method="post" id="failed-jobs-form">
50
+ <div class="table-container">
51
+ <table>
52
+ <thead>
53
+ <tr>
54
+ <th><input type="checkbox" id="select-all" class="select-all-checkbox"></th>
55
+ <th>Job</th>
56
+ <th>Queue</th>
57
+ <th>Error</th>
58
+ <th>Actions</th>
59
+ </tr>
60
+ </thead>
61
+ <tbody>
62
+ #{@jobs.map { |failed_execution| generate_row(failed_execution) }.join}
63
+ </tbody>
64
+ </table>
65
+ </div>
66
+ </form>
67
+
68
+ <script>
69
+ document.addEventListener('DOMContentLoaded', function() {
70
+ // Handle select all checkboxes
71
+ const selectAllHeader = document.getElementById('select-all');
72
+ const checkboxes = document.querySelectorAll('.job-checkbox');
73
+ const retrySelectedBtn = document.getElementById('retry-selected-top');
74
+ const discardSelectedBtn = document.getElementById('discard-selected-top');
75
+ const form = document.getElementById('failed-jobs-form');
76
+
77
+ function updateButtonState() {
78
+ const checkedBoxes = document.querySelectorAll('.job-checkbox:checked');
79
+ retrySelectedBtn.disabled = checkedBoxes.length === 0;
80
+ discardSelectedBtn.disabled = checkedBoxes.length === 0;
81
+ }
82
+
83
+ function toggleAll(checked) {
84
+ checkboxes.forEach(checkbox => {
85
+ checkbox.checked = checked;
86
+ });
87
+ selectAllHeader.checked = checked;
88
+ updateButtonState();
89
+ }
90
+
91
+ selectAllHeader.addEventListener('change', function() {
92
+ toggleAll(this.checked);
93
+ });
94
+
95
+ checkboxes.forEach(checkbox => {
96
+ checkbox.addEventListener('change', function() {
97
+ updateButtonState();
98
+
99
+ // Update select all checkboxes if needed
100
+ const allChecked = document.querySelectorAll('.job-checkbox:checked').length === checkboxes.length;
101
+ selectAllHeader.checked = allChecked;
102
+ });
103
+ });
104
+
105
+ // Handle bulk actions
106
+ retrySelectedBtn.addEventListener('click', function() {
107
+ const selectedIds = Array.from(document.querySelectorAll('.job-checkbox:checked')).map(cb => cb.value);
108
+ if (selectedIds.length === 0) return;
109
+
110
+ if (confirm('Are you sure you want to retry the selected jobs?')) {
111
+ form.action = '#{retry_failed_jobs_path}';
112
+
113
+ // Add a special flag to indicate this should redirect properly
114
+ const redirectInput = document.createElement('input');
115
+ redirectInput.type = 'hidden';
116
+ redirectInput.name = 'redirect_cleanly';
117
+ redirectInput.value = 'true';
118
+ form.appendChild(redirectInput);
119
+
120
+ // Add selected IDs as hidden inputs
121
+ selectedIds.forEach(id => {
122
+ const input = document.createElement('input');
123
+ input.type = 'hidden';
124
+ input.name = 'job_ids[]';
125
+ input.value = id;
126
+ form.appendChild(input);
127
+ });
128
+
129
+ // Submit the form and then replace the URL location immediately after
130
+ form.submit();
131
+
132
+ // Delay the redirect to give the form time to submit
133
+ setTimeout(function() {
134
+ // Reset to the clean URL without query parameters
135
+ window.history.replaceState({}, '', window.location.pathname);
136
+ }, 100);
137
+ }
138
+ });
139
+
140
+ discardSelectedBtn.addEventListener('click', function() {
141
+ const selectedIds = Array.from(document.querySelectorAll('.job-checkbox:checked')).map(cb => cb.value);
142
+ if (selectedIds.length === 0) return;
143
+
144
+ if (confirm('Are you sure you want to discard the selected jobs?')) {
145
+ form.action = '#{discard_failed_jobs_path}';
146
+
147
+ // Add a special flag to indicate this should redirect properly
148
+ const redirectInput = document.createElement('input');
149
+ redirectInput.type = 'hidden';
150
+ redirectInput.name = 'redirect_cleanly';
151
+ redirectInput.value = 'true';
152
+ form.appendChild(redirectInput);
153
+
154
+ // Add selected IDs as hidden inputs
155
+ selectedIds.forEach(id => {
156
+ const input = document.createElement('input');
157
+ input.type = 'hidden';
158
+ input.name = 'job_ids[]';
159
+ input.value = id;
160
+ form.appendChild(input);
161
+ });
162
+
163
+ // Submit the form and then replace the URL location immediately after
164
+ form.submit();
165
+
166
+ // Delay the redirect to give the form time to submit
167
+ setTimeout(function() {
168
+ // Reset to the clean URL without query parameters
169
+ window.history.replaceState({}, '', window.location.pathname);
170
+ }, 100);
171
+ }
172
+ });
173
+
174
+ // Initialize button state
175
+ updateButtonState();
176
+
177
+ // Global function for retry action
178
+ window.submitRetryForm = function(id) {
179
+ const form = document.createElement('form');
180
+ form.method = 'post';
181
+ form.action = '#{retry_failed_job_path(id: "PLACEHOLDER")}';
182
+ form.action = form.action.replace('PLACEHOLDER', id);
183
+ form.style.display = 'none';
184
+
185
+ // Add a special flag to indicate this should redirect properly
186
+ const redirectInput = document.createElement('input');
187
+ redirectInput.type = 'hidden';
188
+ redirectInput.name = 'redirect_cleanly';
189
+ redirectInput.value = 'true';
190
+ form.appendChild(redirectInput);
191
+
192
+ document.body.appendChild(form);
193
+
194
+ // Submit the form and then replace the URL location immediately after
195
+ form.submit();
196
+
197
+ // Delay the redirect to give the form time to submit
198
+ setTimeout(function() {
199
+ // Reset to the clean URL without query parameters
200
+ window.history.replaceState({}, '', window.location.pathname);
201
+ }, 100);
202
+ };
203
+
204
+ // Global function for discard action
205
+ window.submitDiscardForm = function(id) {
206
+ if (confirm('Are you sure you want to discard this job?')) {
207
+ const form = document.createElement('form');
208
+ form.method = 'post';
209
+ form.action = '#{discard_failed_job_path(id: "PLACEHOLDER")}';
210
+ form.action = form.action.replace('PLACEHOLDER', id);
211
+ form.style.display = 'none';
212
+
213
+ // Add a special flag to indicate this should redirect properly
214
+ const redirectInput = document.createElement('input');
215
+ redirectInput.type = 'hidden';
216
+ redirectInput.name = 'redirect_cleanly';
217
+ redirectInput.value = 'true';
218
+ form.appendChild(redirectInput);
219
+
220
+ document.body.appendChild(form);
221
+
222
+ // Submit the form and then replace the URL location immediately after
223
+ form.submit();
224
+
225
+ // Delay the redirect to give the form time to submit
226
+ setTimeout(function() {
227
+ // Reset to the clean URL without query parameters
228
+ window.history.replaceState({}, '', window.location.pathname);
229
+ }, 100);
230
+ }
231
+ };
232
+ });
233
+ </script>
56
234
  HTML
57
235
  end
58
236
 
59
- def generate_row(execution)
237
+ def generate_row(failed_execution)
238
+ job = failed_execution.job
239
+ error = parse_error(failed_execution.error)
240
+
60
241
  <<-HTML
61
242
  <tr>
62
- <td>#{execution.job.class_name}</td>
63
- <td class="error-message">#{execution.error['message']}</td>
64
- <td>#{format_datetime(execution.created_at)}</td>
65
- <td>#{format_arguments(execution.job.arguments)}</td>
243
+ <td><input type="checkbox" class="job-checkbox" value="#{failed_execution.id}"></td>
244
+ <td>
245
+ <div class="job-class">#{job.class_name}</div>
246
+ <div class="job-meta">
247
+ <span class="job-timestamp">Queued at: #{format_datetime(job.created_at)}</span>
248
+ </div>
249
+ </td>
250
+ <td>
251
+ <div class="job-queue">#{job.queue_name}</div>
252
+ </td>
253
+ <td>
254
+ <div class="error-message">#{error[:message]}</div>
255
+ <div class="job-meta">
256
+ <span class="job-timestamp">Failed at: #{format_datetime(failed_execution.created_at)}</span>
257
+ </div>
258
+ <details>
259
+ <summary>Backtrace</summary>
260
+ <pre class="error-backtrace">#{error[:backtrace]}</pre>
261
+ </details>
262
+ </td>
263
+ <td class="actions-cell">
264
+ <div class="job-actions">
265
+ <a href="javascript:void(0)"
266
+ onclick="submitRetryForm(#{failed_execution.id})"
267
+ class="action-button retry-button">Retry</a>
268
+
269
+ <a href="javascript:void(0)"
270
+ onclick="submitDiscardForm(#{failed_execution.id})"
271
+ class="action-button discard-button">Discard</a>
272
+ </div>
273
+ </td>
66
274
  </tr>
67
275
  HTML
68
276
  end
277
+
278
+ def parse_error(error)
279
+ return { message: 'Unknown error', backtrace: '' } unless error
280
+
281
+ if error.is_a?(String)
282
+ { message: error, backtrace: '' }
283
+ elsif error.is_a?(Hash)
284
+ message = error['message'] || error[:message] || 'Unknown error'
285
+ backtrace = error['backtrace'] || error[:backtrace] || []
286
+ backtrace = backtrace.join("\n") if backtrace.is_a?(Array)
287
+ { message: message, backtrace: backtrace }
288
+ else
289
+ { message: 'Unknown error format', backtrace: error.to_s }
290
+ end
291
+ end
292
+
293
+ def get_queue_name(failed_execution, job)
294
+ # Try to get queue_name from failed_execution if the method exists
295
+ if failed_execution.respond_to?(:queue_name) && !failed_execution.queue_name.nil?
296
+ failed_execution.queue_name
297
+ else
298
+ # Fall back to job's queue_name
299
+ job.queue_name
300
+ end
301
+ rescue NoMethodError
302
+ # If there's an error accessing queue_name, fall back to job's queue_name
303
+ job.queue_name
304
+ end
69
305
  end
70
306
  end
@@ -0,0 +1,71 @@
1
+ module SolidQueueMonitor
2
+ class InProgressJobsPresenter < BasePresenter
3
+ include SolidQueueMonitor::Engine.routes.url_helpers
4
+
5
+ def initialize(jobs, current_page: 1, total_pages: 1, filters: {})
6
+ @jobs = jobs
7
+ @current_page = current_page
8
+ @total_pages = total_pages
9
+ @filters = filters
10
+ end
11
+
12
+ def render
13
+ section_wrapper('In Progress Jobs', generate_filter_form + generate_table + generate_pagination(@current_page, @total_pages))
14
+ end
15
+
16
+ private
17
+
18
+ def generate_filter_form
19
+ <<-HTML
20
+ <div class="filter-form-container">
21
+ <form method="get" action="#{in_progress_jobs_path}" class="filter-form">
22
+ <div class="filter-group">
23
+ <label for="class_name">Job Class:</label>
24
+ <input type="text" name="class_name" id="class_name" value="#{@filters[:class_name]}" placeholder="Filter by class name">
25
+ </div>
26
+
27
+ <div class="filter-actions">
28
+ <button type="submit" class="filter-button">Apply Filters</button>
29
+ <a href="#{in_progress_jobs_path}" class="reset-button">Reset</a>
30
+ </div>
31
+ </form>
32
+ </div>
33
+ HTML
34
+ end
35
+
36
+ def generate_table
37
+ <<-HTML
38
+ <div class="table-container">
39
+ <table>
40
+ <thead>
41
+ <tr>
42
+ <th>Job</th>
43
+ <th>Started At</th>
44
+ <th>Process ID</th>
45
+ </tr>
46
+ </thead>
47
+ <tbody>
48
+ #{@jobs.map { |execution| generate_row(execution) }.join}
49
+ </tbody>
50
+ </table>
51
+ </div>
52
+ HTML
53
+ end
54
+
55
+ def generate_row(execution)
56
+ job = execution.job
57
+ <<-HTML
58
+ <tr>
59
+ <td>
60
+ <div class="job-class">#{job.class_name}</div>
61
+ <div class="job-meta">
62
+ <span class="job-timestamp">Queued at: #{format_datetime(job.created_at)}</span>
63
+ </div>
64
+ </td>
65
+ <td>#{format_datetime(execution.created_at)}</td>
66
+ <td>#{execution.process_id}</td>
67
+ </tr>
68
+ HTML
69
+ end
70
+ end
71
+ end
@@ -70,6 +70,7 @@ module SolidQueueMonitor
70
70
  <th>Queue</th>
71
71
  <th>Status</th>
72
72
  <th>Created At</th>
73
+ <th>Actions</th>
73
74
  </tr>
74
75
  </thead>
75
76
  <tbody>
@@ -82,15 +83,48 @@ module SolidQueueMonitor
82
83
 
83
84
  def generate_row(job)
84
85
  status = job_status(job)
85
- <<-HTML
86
+
87
+ # Build the row HTML
88
+ row_html = <<-HTML
86
89
  <tr>
87
90
  <td>#{job.id}</td>
88
91
  <td>#{job.class_name}</td>
89
92
  <td>#{job.queue_name}</td>
90
93
  <td><span class='status-badge status-#{status}'>#{status}</span></td>
91
94
  <td>#{format_datetime(job.created_at)}</td>
92
- </tr>
93
95
  HTML
96
+
97
+ # Add actions column only for failed jobs
98
+ if status == 'failed'
99
+ # Find the failed execution record for this job
100
+ failed_execution = SolidQueue::FailedExecution.find_by(job_id: job.id)
101
+
102
+ if failed_execution
103
+ row_html += <<-HTML
104
+ <td class="actions-cell">
105
+ <div class="job-actions">
106
+ <form method="post" action="#{retry_failed_job_path(id: failed_execution.id)}" class="inline-form">
107
+ <input type="hidden" name="redirect_to" value="#{root_path}">
108
+ <button type="submit" class="action-button retry-button">Retry</button>
109
+ </form>
110
+
111
+ <form method="post" action="#{discard_failed_job_path(id: failed_execution.id)}" class="inline-form"
112
+ onsubmit="return confirm('Are you sure you want to discard this job?');">
113
+ <input type="hidden" name="redirect_to" value="#{root_path}">
114
+ <button type="submit" class="action-button discard-button">Discard</button>
115
+ </form>
116
+ </div>
117
+ </td>
118
+ HTML
119
+ else
120
+ row_html += "<td></td>"
121
+ end
122
+ else
123
+ row_html += "<td></td>"
124
+ end
125
+
126
+ row_html += "</tr>"
127
+ row_html
94
128
  end
95
129
 
96
130
  def job_status(job)
@@ -37,21 +37,24 @@ module SolidQueueMonitor
37
37
  </div>
38
38
  </form>
39
39
  </div>
40
+
41
+ <div class="bulk-actions-bar">
42
+ <button type="button" class="action-button execute-button" id="execute-selected-top" disabled>Execute Selected</button>
43
+ </div>
40
44
  HTML
41
45
  end
42
46
 
43
47
  def generate_table_with_actions
44
48
  <<-HTML
45
- <form action="#{execute_jobs_path}" method="POST">
49
+ <form id="scheduled-jobs-form" action="#{execute_jobs_path}" method="POST">
46
50
  #{generate_table}
47
- <div class="table-actions">
48
- <button type="submit" class="execute-btn" id="bulk-execute" disabled>Execute Selected</button>
49
- </div>
50
51
  </form>
51
52
  <script>
52
53
  document.addEventListener('DOMContentLoaded', function() {
53
54
  const selectAllCheckbox = document.querySelector('th input[type="checkbox"]');
54
55
  const jobCheckboxes = document.getElementsByName('job_ids[]');
56
+ const executeButton = document.getElementById('execute-selected-top');
57
+ const form = document.getElementById('scheduled-jobs-form');
55
58
 
56
59
  selectAllCheckbox.addEventListener('change', function() {
57
60
  jobCheckboxes.forEach(checkbox => checkbox.checked = this.checked);
@@ -64,13 +67,33 @@ module SolidQueueMonitor
64
67
  updateExecuteButton();
65
68
  });
66
69
  });
70
+
71
+ // Add event listener for the execute button
72
+ executeButton.addEventListener('click', function() {
73
+ const selectedIds = Array.from(document.querySelectorAll('input[name="job_ids[]"]:checked')).map(cb => cb.value);
74
+ if (selectedIds.length === 0) return;
75
+
76
+ // Add selected IDs as hidden inputs
77
+ selectedIds.forEach(id => {
78
+ const input = document.createElement('input');
79
+ input.type = 'hidden';
80
+ input.name = 'job_ids[]';
81
+ input.value = id;
82
+ form.appendChild(input);
83
+ });
84
+
85
+ form.submit();
86
+ });
87
+
88
+ function updateExecuteButton() {
89
+ const checkboxes = document.getElementsByName('job_ids[]');
90
+ const checked = Array.from(checkboxes).some(cb => cb.checked);
91
+ executeButton.disabled = !checked;
92
+ }
93
+
94
+ // Initialize button state
95
+ updateExecuteButton();
67
96
  });
68
-
69
- function updateExecuteButton() {
70
- const checkboxes = document.getElementsByName('job_ids[]');
71
- const checked = Array.from(checkboxes).some(cb => cb.checked);
72
- document.getElementById('bulk-execute').disabled = !checked;
73
- }
74
97
  </script>
75
98
  HTML
76
99
  end
@@ -10,12 +10,12 @@ module SolidQueueMonitor
10
10
  <h3>Queue Statistics</h3>
11
11
  <div class="stats">
12
12
  #{generate_stat_card('Total Jobs', @stats[:total_jobs])}
13
- #{generate_stat_card('Unique Queues', @stats[:unique_queues])}
14
13
  #{generate_stat_card('Ready', @stats[:ready])}
14
+ #{generate_stat_card('In Progress', @stats[:in_progress])}
15
15
  #{generate_stat_card('Scheduled', @stats[:scheduled])}
16
+ #{generate_stat_card('Recurring', @stats[:recurring])}
16
17
  #{generate_stat_card('Failed', @stats[:failed])}
17
18
  #{generate_stat_card('Completed', @stats[:completed])}
18
- #{generate_stat_card('Recurring', @stats[:recurring])}
19
19
  </div>
20
20
  </div>
21
21
  HTML