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.
- checksums.yaml +4 -4
- data/README.md +30 -4
- data/app/controllers/solid_queue_monitor/base_controller.rb +194 -0
- data/app/controllers/solid_queue_monitor/failed_jobs_controller.rb +62 -0
- data/app/controllers/solid_queue_monitor/in_progress_jobs_controller.rb +27 -0
- data/app/controllers/solid_queue_monitor/monitor_controller.rb +75 -7
- data/app/controllers/solid_queue_monitor/overview_controller.rb +25 -0
- data/app/controllers/solid_queue_monitor/queues_controller.rb +11 -0
- data/app/controllers/solid_queue_monitor/ready_jobs_controller.rb +14 -0
- data/app/controllers/solid_queue_monitor/recurring_jobs_controller.rb +14 -0
- data/app/controllers/solid_queue_monitor/scheduled_jobs_controller.rb +24 -0
- data/app/presenters/solid_queue_monitor/base_presenter.rb +57 -48
- data/app/presenters/solid_queue_monitor/failed_jobs_presenter.rb +257 -21
- data/app/presenters/solid_queue_monitor/in_progress_jobs_presenter.rb +71 -0
- data/app/presenters/solid_queue_monitor/jobs_presenter.rb +36 -2
- data/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb +33 -10
- data/app/presenters/solid_queue_monitor/stats_presenter.rb +2 -2
- data/app/services/solid_queue_monitor/failed_job_service.rb +97 -0
- data/app/services/solid_queue_monitor/html_generator.rb +28 -2
- data/app/services/solid_queue_monitor/stats_calculator.rb +1 -0
- data/app/services/solid_queue_monitor/stylesheet_generator.rb +150 -1
- data/config/routes.rb +13 -7
- data/lib/solid_queue_monitor/version.rb +2 -2
- 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
|
-
|
25
|
+
html = '<div class="pagination">'
|
26
26
|
|
27
27
|
# Previous page link
|
28
28
|
if current_page > 1
|
29
|
-
|
29
|
+
html += "<a href=\"?page=#{current_page - 1}#{query_params}\" class=\"pagination-link pagination-nav\">Previous</a>"
|
30
30
|
else
|
31
|
-
|
31
|
+
html += '<span class="pagination-link pagination-nav disabled">Previous</span>'
|
32
32
|
end
|
33
33
|
|
34
|
-
# Page
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
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
|
-
|
49
|
+
html += "<a href=\"?page=#{current_page + 1}#{query_params}\" class=\"pagination-link pagination-nav\">Next</a>"
|
65
50
|
else
|
66
|
-
|
51
|
+
html += '<span class="pagination-link pagination-nav disabled">Next</span>'
|
67
52
|
end
|
68
53
|
|
69
|
-
|
70
|
-
|
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
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
"<code>#{arguments.
|
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
|
-
|
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
|
-
|
120
|
-
|
121
|
-
|
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
|
-
"
|
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=#{
|
130
|
-
params << "queue_name=#{
|
131
|
-
params << "status=#{
|
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
|
-
<
|
42
|
-
<table>
|
43
|
-
<
|
44
|
-
<
|
45
|
-
<
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
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(
|
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
|
63
|
-
<td
|
64
|
-
|
65
|
-
|
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
|
-
|
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
|