ragdoll-rails 0.1.9 → 0.1.11

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/ragdoll/application.js +129 -0
  3. data/app/assets/javascripts/ragdoll/bulk_upload_status.js +454 -0
  4. data/app/assets/stylesheets/ragdoll/application.css +84 -0
  5. data/app/assets/stylesheets/ragdoll/bulk_upload_status.css +379 -0
  6. data/app/channels/application_cable/channel.rb +6 -0
  7. data/app/channels/application_cable/connection.rb +6 -0
  8. data/app/channels/ragdoll/bulk_upload_status_channel.rb +27 -0
  9. data/app/channels/ragdoll/file_processing_channel.rb +26 -0
  10. data/app/components/ragdoll/alert_component.html.erb +4 -0
  11. data/app/components/ragdoll/alert_component.rb +32 -0
  12. data/app/components/ragdoll/application_component.rb +6 -0
  13. data/app/components/ragdoll/card_component.html.erb +15 -0
  14. data/app/components/ragdoll/card_component.rb +21 -0
  15. data/app/components/ragdoll/document_list_component.html.erb +41 -0
  16. data/app/components/ragdoll/document_list_component.rb +13 -0
  17. data/app/components/ragdoll/document_table_component.html.erb +76 -0
  18. data/app/components/ragdoll/document_table_component.rb +13 -0
  19. data/app/components/ragdoll/empty_state_component.html.erb +12 -0
  20. data/app/components/ragdoll/empty_state_component.rb +17 -0
  21. data/app/components/ragdoll/flash_messages_component.html.erb +3 -0
  22. data/app/components/ragdoll/flash_messages_component.rb +37 -0
  23. data/app/components/ragdoll/navbar_component.html.erb +24 -0
  24. data/app/components/ragdoll/navbar_component.rb +31 -0
  25. data/app/components/ragdoll/page_header_component.html.erb +13 -0
  26. data/app/components/ragdoll/page_header_component.rb +15 -0
  27. data/app/components/ragdoll/stats_card_component.html.erb +11 -0
  28. data/app/components/ragdoll/stats_card_component.rb +17 -0
  29. data/app/components/ragdoll/status_badge_component.html.erb +3 -0
  30. data/app/components/ragdoll/status_badge_component.rb +30 -0
  31. data/app/controllers/ragdoll/api/v1/analytics_controller.rb +72 -0
  32. data/app/controllers/ragdoll/api/v1/base_controller.rb +29 -0
  33. data/app/controllers/ragdoll/api/v1/documents_controller.rb +148 -0
  34. data/app/controllers/ragdoll/api/v1/search_controller.rb +87 -0
  35. data/app/controllers/ragdoll/api/v1/system_controller.rb +97 -0
  36. data/app/controllers/ragdoll/application_controller.rb +17 -0
  37. data/app/controllers/ragdoll/configuration_controller.rb +82 -0
  38. data/app/controllers/ragdoll/dashboard_controller.rb +98 -0
  39. data/app/controllers/ragdoll/documents_controller.rb +460 -0
  40. data/app/controllers/ragdoll/documents_controller_backup.rb +68 -0
  41. data/app/controllers/ragdoll/jobs_controller.rb +116 -0
  42. data/app/controllers/ragdoll/search_controller.rb +368 -0
  43. data/app/jobs/application_job.rb +9 -0
  44. data/app/jobs/ragdoll/bulk_document_processing_job.rb +280 -0
  45. data/app/jobs/ragdoll/process_file_job.rb +166 -0
  46. data/app/services/ragdoll/worker_health_service.rb +111 -0
  47. data/app/views/layouts/ragdoll/application.html.erb +162 -0
  48. data/app/views/ragdoll/dashboard/analytics.html.erb +333 -0
  49. data/app/views/ragdoll/dashboard/index.html.erb +208 -0
  50. data/app/views/ragdoll/documents/edit.html.erb +91 -0
  51. data/app/views/ragdoll/documents/index.html.erb +302 -0
  52. data/app/views/ragdoll/documents/new.html.erb +1518 -0
  53. data/app/views/ragdoll/documents/show.html.erb +188 -0
  54. data/app/views/ragdoll/documents/upload_results.html.erb +248 -0
  55. data/app/views/ragdoll/jobs/index.html.erb +669 -0
  56. data/app/views/ragdoll/jobs/show.html.erb +129 -0
  57. data/app/views/ragdoll/search/index.html.erb +324 -0
  58. data/config/cable.yml +12 -0
  59. data/config/routes.rb +56 -1
  60. data/lib/ragdoll/rails/engine.rb +32 -1
  61. data/lib/ragdoll/rails/version.rb +1 -1
  62. metadata +86 -1
@@ -0,0 +1,669 @@
1
+ <% content_for :title, "Job Dashboard - Ragdoll Engine" %>
2
+
3
+ <%= render Ragdoll::PageHeaderComponent.new(
4
+ title: "Job Dashboard",
5
+ icon: "fas fa-tasks",
6
+ subtitle: "Monitor background job processing and queue status"
7
+ ) %>
8
+
9
+ <div class="row mb-4">
10
+ <div class="col-md-3">
11
+ <%= render Ragdoll::StatsCardComponent.new(
12
+ title: "Pending",
13
+ value: @stats[:pending],
14
+ icon: "fas fa-clock",
15
+ color: "warning",
16
+ description: "Jobs waiting to process"
17
+ ) %>
18
+ </div>
19
+ <div class="col-md-3">
20
+ <%= render Ragdoll::StatsCardComponent.new(
21
+ title: "Completed",
22
+ value: @stats[:completed],
23
+ icon: "fas fa-check-circle",
24
+ color: "success",
25
+ description: "Successfully completed"
26
+ ) %>
27
+ </div>
28
+ <div class="col-md-3">
29
+ <%= render Ragdoll::StatsCardComponent.new(
30
+ title: "Failed",
31
+ value: @stats[:failed],
32
+ icon: "fas fa-exclamation-circle",
33
+ color: "danger",
34
+ description: "Jobs that failed"
35
+ ) %>
36
+ </div>
37
+ <div class="col-md-3">
38
+ <%= render Ragdoll::StatsCardComponent.new(
39
+ title: "Total",
40
+ value: @stats[:total],
41
+ icon: "fas fa-tasks",
42
+ color: "info",
43
+ description: "All jobs processed"
44
+ ) %>
45
+ </div>
46
+ </div>
47
+
48
+ <div class="row">
49
+ <div class="col-12">
50
+ <div class="card">
51
+ <div class="card-header">
52
+ <ul class="nav nav-tabs card-header-tabs" role="tablist">
53
+ <li class="nav-item">
54
+ <a class="nav-link active" id="pending-tab" data-bs-toggle="tab" href="#pending" role="tab">
55
+ <i class="fas fa-clock"></i> Pending Jobs (<%= @stats[:pending] %>)
56
+ </a>
57
+ </li>
58
+ <li class="nav-item">
59
+ <a class="nav-link" id="completed-tab" data-bs-toggle="tab" href="#completed" role="tab">
60
+ <i class="fas fa-check-circle"></i> Completed Jobs (<%= @stats[:completed] %>)
61
+ </a>
62
+ </li>
63
+ <li class="nav-item">
64
+ <a class="nav-link" id="failed-tab" data-bs-toggle="tab" href="#failed" role="tab">
65
+ <i class="fas fa-exclamation-circle"></i> Failed Jobs (<%= @stats[:failed] %>)
66
+ </a>
67
+ </li>
68
+ </ul>
69
+ </div>
70
+ <div class="card-body">
71
+ <div class="tab-content">
72
+ <!-- Pending Jobs Tab -->
73
+ <div class="tab-pane fade show active" id="pending" role="tabpanel">
74
+ <% if @pending_jobs.any? %>
75
+ <!-- Bulk Actions for Pending Jobs -->
76
+ <div class="mb-3 p-3 bg-light rounded">
77
+ <div class="d-flex align-items-center justify-content-between">
78
+ <div>
79
+ <input type="checkbox" id="select-all-pending" class="form-check-input me-2">
80
+ <label for="select-all-pending" class="form-check-label">Select All</label>
81
+ <span id="pending-selected-count" class="ms-2 text-muted">(0 selected)</span>
82
+ </div>
83
+ <div>
84
+ <button type="button" id="bulk-delete-pending" class="btn btn-danger btn-sm" disabled>
85
+ <i class="fas fa-trash"></i> Delete Selected
86
+ </button>
87
+ </div>
88
+ </div>
89
+ </div>
90
+
91
+ <div class="table-responsive">
92
+ <table class="table table-striped">
93
+ <thead>
94
+ <tr>
95
+ <th width="40px">
96
+ <input type="checkbox" id="select-all-pending-header" class="form-check-input">
97
+ </th>
98
+ <th>Job Class</th>
99
+ <th>Arguments</th>
100
+ <th>Queue</th>
101
+ <th>Created</th>
102
+ <th>Actions</th>
103
+ </tr>
104
+ </thead>
105
+ <tbody>
106
+ <% @pending_jobs.each do |job| %>
107
+ <tr>
108
+ <td>
109
+ <input type="checkbox" class="form-check-input job-checkbox-pending" value="<%= job.id %>">
110
+ </td>
111
+ <td>
112
+ <strong><%= job.class_name %></strong>
113
+ </td>
114
+ <td>
115
+ <small class="text-muted">
116
+ <%= truncate(job.arguments.to_s, length: 100) %>
117
+ </small>
118
+ </td>
119
+ <td>
120
+ <span class="badge bg-primary"><%= job.queue_name %></span>
121
+ </td>
122
+ <td>
123
+ <small><%= time_ago_in_words(job.created_at) %> ago</small>
124
+ </td>
125
+ <td>
126
+ <%= link_to "Details", ragdoll.job_path(job), class: "btn btn-sm btn-outline-info" %>
127
+ <%= form_with url: ragdoll.job_path(job), method: :delete, local: true,
128
+ data: { confirm: "Are you sure?" },
129
+ style: "display: inline;" do |form| %>
130
+ <%= form.submit "Delete", class: "btn btn-sm btn-outline-danger" %>
131
+ <% end %>
132
+ </td>
133
+ </tr>
134
+ <% end %>
135
+ </tbody>
136
+ </table>
137
+ </div>
138
+ <% else %>
139
+ <div class="text-center py-4">
140
+ <i class="fas fa-check-circle text-success" style="font-size: 3rem;"></i>
141
+ <h5 class="mt-3">No Pending Jobs</h5>
142
+ <p class="text-muted">All jobs have been processed!</p>
143
+ </div>
144
+ <% end %>
145
+ </div>
146
+
147
+ <!-- Completed Jobs Tab -->
148
+ <div class="tab-pane fade" id="completed" role="tabpanel">
149
+ <% if @completed_jobs.any? %>
150
+ <div class="table-responsive">
151
+ <table class="table table-striped">
152
+ <thead>
153
+ <tr>
154
+ <th>Job Class</th>
155
+ <th>Arguments</th>
156
+ <th>Queue</th>
157
+ <th>Completed</th>
158
+ <th>Duration</th>
159
+ <th>Actions</th>
160
+ </tr>
161
+ </thead>
162
+ <tbody>
163
+ <% @completed_jobs.each do |job| %>
164
+ <tr>
165
+ <td>
166
+ <strong><%= job.class_name %></strong>
167
+ </td>
168
+ <td>
169
+ <small class="text-muted">
170
+ <%= truncate(job.arguments.to_s, length: 100) %>
171
+ </small>
172
+ </td>
173
+ <td>
174
+ <span class="badge bg-success"><%= job.queue_name %></span>
175
+ </td>
176
+ <td>
177
+ <small><%= time_ago_in_words(job.finished_at) %> ago</small>
178
+ </td>
179
+ <td>
180
+ <% if job.finished_at && job.created_at %>
181
+ <small><%= number_with_precision((job.finished_at - job.created_at), precision: 2) %>s</small>
182
+ <% else %>
183
+ <small>-</small>
184
+ <% end %>
185
+ </td>
186
+ <td>
187
+ <%= link_to "Details", ragdoll.job_path(job), class: "btn btn-sm btn-outline-info" %>
188
+ </td>
189
+ </tr>
190
+ <% end %>
191
+ </tbody>
192
+ </table>
193
+ </div>
194
+ <% else %>
195
+ <div class="text-center py-4">
196
+ <i class="fas fa-history text-muted" style="font-size: 3rem;"></i>
197
+ <h5 class="mt-3">No Completed Jobs</h5>
198
+ <p class="text-muted">No jobs have been completed yet.</p>
199
+ </div>
200
+ <% end %>
201
+ </div>
202
+
203
+ <!-- Failed Jobs Tab -->
204
+ <div class="tab-pane fade" id="failed" role="tabpanel">
205
+ <% if @failed_jobs.any? %>
206
+ <!-- Bulk Actions for Failed Jobs -->
207
+ <div class="mb-3 p-3 bg-light rounded">
208
+ <div class="d-flex align-items-center justify-content-between">
209
+ <div>
210
+ <input type="checkbox" id="select-all-failed" class="form-check-input me-2">
211
+ <label for="select-all-failed" class="form-check-label">Select All</label>
212
+ <span id="failed-selected-count" class="ms-2 text-muted">(0 selected)</span>
213
+ </div>
214
+ <div>
215
+ <button type="button" id="bulk-retry-failed" class="btn btn-warning btn-sm me-2" disabled>
216
+ <i class="fas fa-redo"></i> Retry Selected
217
+ </button>
218
+ <button type="button" id="bulk-delete-failed" class="btn btn-danger btn-sm" disabled>
219
+ <i class="fas fa-trash"></i> Delete Selected
220
+ </button>
221
+ </div>
222
+ </div>
223
+ </div>
224
+
225
+ <div class="table-responsive">
226
+ <table class="table table-striped">
227
+ <thead>
228
+ <tr>
229
+ <th width="40px">
230
+ <input type="checkbox" id="select-all-failed-header" class="form-check-input">
231
+ </th>
232
+ <th>Job Class</th>
233
+ <th>Error</th>
234
+ <th>Failed At</th>
235
+ <th>Actions</th>
236
+ </tr>
237
+ </thead>
238
+ <tbody>
239
+ <% @failed_jobs.each do |failed_job| %>
240
+ <tr>
241
+ <td>
242
+ <input type="checkbox" class="form-check-input job-checkbox-failed" value="<%= failed_job.id %>">
243
+ </td>
244
+ <td>
245
+ <strong><%= failed_job.job.class_name %></strong>
246
+ </td>
247
+ <td>
248
+ <small class="text-danger">
249
+ <%= truncate(failed_job.error.to_s, length: 150) %>
250
+ </small>
251
+ </td>
252
+ <td>
253
+ <small><%= time_ago_in_words(failed_job.created_at) %> ago</small>
254
+ </td>
255
+ <td>
256
+ <%= link_to "Retry", ragdoll.retry_job_path(failed_job), method: :post,
257
+ class: "btn btn-sm btn-warning" %>
258
+ <%= form_with url: ragdoll.job_path(failed_job, type: 'failed'), method: :delete, local: true,
259
+ data: { confirm: "Are you sure?" },
260
+ style: "display: inline;" do |form| %>
261
+ <%= form.submit "Delete", class: "btn btn-sm btn-outline-danger" %>
262
+ <% end %>
263
+ </td>
264
+ </tr>
265
+ <% end %>
266
+ </tbody>
267
+ </table>
268
+ </div>
269
+ <% else %>
270
+ <div class="text-center py-4">
271
+ <i class="fas fa-shield-alt text-success" style="font-size: 3rem;"></i>
272
+ <h5 class="mt-3">No Failed Jobs</h5>
273
+ <p class="text-muted">All jobs are running smoothly!</p>
274
+ </div>
275
+ <% end %>
276
+ </div>
277
+ </div>
278
+ </div>
279
+ </div>
280
+ </div>
281
+ </div>
282
+
283
+ <div class="row mt-4">
284
+ <div class="col-md-6">
285
+ <div class="card">
286
+ <div class="card-header">
287
+ <h5><i class="fas fa-info-circle"></i> Queue Information</h5>
288
+ </div>
289
+ <div class="card-body">
290
+ <p><strong>Queue Adapter:</strong> SolidQueue</p>
291
+ <p><strong>Default Queue:</strong> default</p>
292
+ <p><strong>Auto-refresh:</strong> This page auto-refreshes every 30 seconds</p>
293
+ <div class="mt-3">
294
+ <%= link_to "Refresh Now", ragdoll.jobs_path, class: "btn btn-primary" %>
295
+ <%= link_to "Back to Dashboard", ragdoll.dashboard_index_path, class: "btn btn-outline-secondary" %>
296
+ <% if @stats[:pending] > 0 %>
297
+ <%= form_with url: ragdoll.cancel_all_pending_jobs_path, method: :delete, local: true,
298
+ data: { confirm: "⚠️ WARNING: This will cancel ALL #{@stats[:pending]} pending jobs! This cannot be undone. Are you absolutely sure?" },
299
+ style: "display: inline;" do |form| %>
300
+ <%= form.submit "⚡ Cancel All Pending", class: "btn btn-danger ms-2" %>
301
+ <% end %>
302
+ <% end %>
303
+ </div>
304
+ </div>
305
+ </div>
306
+ </div>
307
+
308
+ <div class="col-md-6">
309
+ <div class="card">
310
+ <div class="card-header">
311
+ <h5><i class="fas fa-heartbeat"></i> Worker Health</h5>
312
+ </div>
313
+ <div class="card-body">
314
+ <div id="worker-health-status">
315
+ <div class="d-flex justify-content-center">
316
+ <div class="spinner-border text-primary" role="status">
317
+ <span class="visually-hidden">Checking...</span>
318
+ </div>
319
+ </div>
320
+ </div>
321
+ <div class="mt-3">
322
+ <%= link_to "Check Health", ragdoll.health_jobs_path,
323
+ class: "btn btn-info",
324
+ id: "check-health-btn" %>
325
+ <%= form_with url: ragdoll.restart_workers_jobs_path, method: :post, local: true,
326
+ data: { confirm: "Are you sure you want to restart the workers?" },
327
+ style: "display: inline;" do |form| %>
328
+ <%= form.submit "Restart Workers", class: "btn btn-warning", id: "restart-workers-btn" %>
329
+ <% end %>
330
+ </div>
331
+ </div>
332
+ </div>
333
+ </div>
334
+ </div>
335
+
336
+ <%= content_for :javascript do %>
337
+ <script>
338
+ // Worker health checking
339
+ function checkWorkerHealth() {
340
+ fetch('<%= ragdoll.health_jobs_path %>')
341
+ .then(response => response.json())
342
+ .then(data => {
343
+ updateHealthDisplay(data);
344
+ })
345
+ .catch(error => {
346
+ console.error('Health check failed:', error);
347
+ document.getElementById('worker-health-status').innerHTML =
348
+ '<div class="alert alert-danger">Health check failed</div>';
349
+ });
350
+ }
351
+
352
+ function updateHealthDisplay(health) {
353
+ const statusElement = document.getElementById('worker-health-status');
354
+ const restartBtn = document.getElementById('restart-workers-btn');
355
+
356
+ let statusHtml = '';
357
+ let alertClass = '';
358
+
359
+ switch(health.status) {
360
+ case 'healthy':
361
+ alertClass = 'alert-success';
362
+ statusHtml = `
363
+ <div class="alert ${alertClass}">
364
+ <i class="fas fa-check-circle"></i> <strong>Healthy</strong><br>
365
+ <small>${health.worker_count} workers running, ${health.stalled_jobs} pending jobs</small>
366
+ </div>
367
+ `;
368
+ restartBtn.disabled = false;
369
+ break;
370
+
371
+ case 'stalled':
372
+ alertClass = 'alert-warning';
373
+ const oldestAge = Math.round(health.oldest_pending_job / 60);
374
+ statusHtml = `
375
+ <div class="alert ${alertClass}">
376
+ <i class="fas fa-exclamation-triangle"></i> <strong>Stalled</strong><br>
377
+ <small>${health.stalled_jobs} jobs pending for ${oldestAge}+ minutes</small>
378
+ </div>
379
+ `;
380
+ restartBtn.disabled = false;
381
+ restartBtn.classList.add('btn-danger');
382
+ restartBtn.classList.remove('btn-warning');
383
+ break;
384
+
385
+ case 'no_workers':
386
+ alertClass = 'alert-danger';
387
+ statusHtml = `
388
+ <div class="alert ${alertClass}">
389
+ <i class="fas fa-times-circle"></i> <strong>No Workers</strong><br>
390
+ <small>No worker processes detected</small>
391
+ </div>
392
+ `;
393
+ restartBtn.disabled = false;
394
+ restartBtn.classList.add('btn-danger');
395
+ restartBtn.classList.remove('btn-warning');
396
+ break;
397
+
398
+ case 'processing':
399
+ alertClass = 'alert-info';
400
+ statusHtml = `
401
+ <div class="alert ${alertClass}">
402
+ <i class="fas fa-cog fa-spin"></i> <strong>Processing</strong><br>
403
+ <small>${health.worker_count} workers, ${health.stalled_jobs} jobs in queue</small>
404
+ </div>
405
+ `;
406
+ restartBtn.disabled = false;
407
+ break;
408
+ }
409
+
410
+ statusElement.innerHTML = statusHtml;
411
+ }
412
+
413
+ // Check health on page load
414
+ document.addEventListener('DOMContentLoaded', function() {
415
+ checkWorkerHealth();
416
+
417
+ // Check health every 15 seconds
418
+ setInterval(checkWorkerHealth, 15000);
419
+
420
+ // Manual health check button
421
+ document.getElementById('check-health-btn').addEventListener('click', function(e) {
422
+ e.preventDefault();
423
+ checkWorkerHealth();
424
+ });
425
+ });
426
+
427
+ // Auto-refresh page every 30 seconds (disabled when jobs are selected to prevent losing selection)
428
+ let autoRefreshTimeout;
429
+ function scheduleAutoRefresh() {
430
+ clearTimeout(autoRefreshTimeout);
431
+ autoRefreshTimeout = setTimeout(function() {
432
+ // Don't auto-refresh if jobs are selected
433
+ const pendingSelected = document.querySelectorAll('.job-checkbox-pending:checked').length;
434
+ const failedSelected = document.querySelectorAll('.job-checkbox-failed:checked').length;
435
+
436
+ if (pendingSelected === 0 && failedSelected === 0) {
437
+ window.location.reload();
438
+ } else {
439
+ // Reschedule for later
440
+ scheduleAutoRefresh();
441
+ }
442
+ }, 30000);
443
+ }
444
+ scheduleAutoRefresh();
445
+
446
+ // Bulk operations for pending jobs
447
+ document.addEventListener('DOMContentLoaded', function() {
448
+ // Pending jobs bulk operations
449
+ const selectAllPending = document.getElementById('select-all-pending');
450
+ const selectAllPendingHeader = document.getElementById('select-all-pending-header');
451
+ const pendingCheckboxes = document.querySelectorAll('.job-checkbox-pending');
452
+ const bulkDeletePending = document.getElementById('bulk-delete-pending');
453
+ const pendingSelectedCount = document.getElementById('pending-selected-count');
454
+
455
+ // Sync "Select All" checkboxes for pending jobs
456
+ if (selectAllPending && selectAllPendingHeader) {
457
+ [selectAllPending, selectAllPendingHeader].forEach(checkbox => {
458
+ checkbox.addEventListener('change', function() {
459
+ const checked = this.checked;
460
+ pendingCheckboxes.forEach(cb => cb.checked = checked);
461
+ if (selectAllPending !== this) selectAllPending.checked = checked;
462
+ if (selectAllPendingHeader !== this) selectAllPendingHeader.checked = checked;
463
+ updatePendingBulkButtons();
464
+ });
465
+ });
466
+ }
467
+
468
+ // Individual checkbox handlers for pending jobs
469
+ pendingCheckboxes.forEach(checkbox => {
470
+ checkbox.addEventListener('change', updatePendingBulkButtons);
471
+ });
472
+
473
+ function updatePendingBulkButtons() {
474
+ const checkedBoxes = document.querySelectorAll('.job-checkbox-pending:checked');
475
+ const count = checkedBoxes.length;
476
+
477
+ if (pendingSelectedCount) {
478
+ pendingSelectedCount.textContent = `(${count} selected)`;
479
+ }
480
+
481
+ if (bulkDeletePending) {
482
+ bulkDeletePending.disabled = count === 0;
483
+ }
484
+
485
+ // Update "Select All" checkboxes
486
+ const allChecked = count === pendingCheckboxes.length && count > 0;
487
+ if (selectAllPending) selectAllPending.checked = allChecked;
488
+ if (selectAllPendingHeader) selectAllPendingHeader.checked = allChecked;
489
+ }
490
+
491
+ // Bulk delete pending jobs
492
+ if (bulkDeletePending) {
493
+ bulkDeletePending.addEventListener('click', function() {
494
+ const selectedIds = Array.from(document.querySelectorAll('.job-checkbox-pending:checked')).map(cb => cb.value);
495
+
496
+ if (selectedIds.length === 0) return;
497
+
498
+ if (confirm(`Are you sure you want to delete ${selectedIds.length} pending job(s)? This cannot be undone.`)) {
499
+ const form = document.createElement('form');
500
+ form.method = 'POST';
501
+ form.action = '<%= ragdoll.bulk_delete_jobs_path %>';
502
+
503
+ // Add CSRF token
504
+ const csrfToken = document.querySelector('meta[name="csrf-token"]');
505
+ if (csrfToken) {
506
+ const csrfInput = document.createElement('input');
507
+ csrfInput.type = 'hidden';
508
+ csrfInput.name = 'authenticity_token';
509
+ csrfInput.value = csrfToken.content;
510
+ form.appendChild(csrfInput);
511
+ }
512
+
513
+ // Add job type
514
+ const typeInput = document.createElement('input');
515
+ typeInput.type = 'hidden';
516
+ typeInput.name = 'job_type';
517
+ typeInput.value = 'pending';
518
+ form.appendChild(typeInput);
519
+
520
+ // Add selected job IDs
521
+ selectedIds.forEach(id => {
522
+ const input = document.createElement('input');
523
+ input.type = 'hidden';
524
+ input.name = 'job_ids[]';
525
+ input.value = id;
526
+ form.appendChild(input);
527
+ });
528
+
529
+ document.body.appendChild(form);
530
+ form.submit();
531
+ }
532
+ });
533
+ }
534
+
535
+ // Failed jobs bulk operations
536
+ const selectAllFailed = document.getElementById('select-all-failed');
537
+ const selectAllFailedHeader = document.getElementById('select-all-failed-header');
538
+ const failedCheckboxes = document.querySelectorAll('.job-checkbox-failed');
539
+ const bulkRetryFailed = document.getElementById('bulk-retry-failed');
540
+ const bulkDeleteFailed = document.getElementById('bulk-delete-failed');
541
+ const failedSelectedCount = document.getElementById('failed-selected-count');
542
+
543
+ // Sync "Select All" checkboxes for failed jobs
544
+ if (selectAllFailed && selectAllFailedHeader) {
545
+ [selectAllFailed, selectAllFailedHeader].forEach(checkbox => {
546
+ checkbox.addEventListener('change', function() {
547
+ const checked = this.checked;
548
+ failedCheckboxes.forEach(cb => cb.checked = checked);
549
+ if (selectAllFailed !== this) selectAllFailed.checked = checked;
550
+ if (selectAllFailedHeader !== this) selectAllFailedHeader.checked = checked;
551
+ updateFailedBulkButtons();
552
+ });
553
+ });
554
+ }
555
+
556
+ // Individual checkbox handlers for failed jobs
557
+ failedCheckboxes.forEach(checkbox => {
558
+ checkbox.addEventListener('change', updateFailedBulkButtons);
559
+ });
560
+
561
+ function updateFailedBulkButtons() {
562
+ const checkedBoxes = document.querySelectorAll('.job-checkbox-failed:checked');
563
+ const count = checkedBoxes.length;
564
+
565
+ if (failedSelectedCount) {
566
+ failedSelectedCount.textContent = `(${count} selected)`;
567
+ }
568
+
569
+ if (bulkRetryFailed) {
570
+ bulkRetryFailed.disabled = count === 0;
571
+ }
572
+
573
+ if (bulkDeleteFailed) {
574
+ bulkDeleteFailed.disabled = count === 0;
575
+ }
576
+
577
+ // Update "Select All" checkboxes
578
+ const allChecked = count === failedCheckboxes.length && count > 0;
579
+ if (selectAllFailed) selectAllFailed.checked = allChecked;
580
+ if (selectAllFailedHeader) selectAllFailedHeader.checked = allChecked;
581
+ }
582
+
583
+ // Bulk retry failed jobs
584
+ if (bulkRetryFailed) {
585
+ bulkRetryFailed.addEventListener('click', function() {
586
+ const selectedIds = Array.from(document.querySelectorAll('.job-checkbox-failed:checked')).map(cb => cb.value);
587
+
588
+ if (selectedIds.length === 0) return;
589
+
590
+ if (confirm(`Are you sure you want to retry ${selectedIds.length} failed job(s)?`)) {
591
+ const form = document.createElement('form');
592
+ form.method = 'POST';
593
+ form.action = '<%= ragdoll.bulk_retry_jobs_path %>';
594
+
595
+ // Add CSRF token
596
+ const csrfToken = document.querySelector('meta[name="csrf-token"]');
597
+ if (csrfToken) {
598
+ const csrfInput = document.createElement('input');
599
+ csrfInput.type = 'hidden';
600
+ csrfInput.name = 'authenticity_token';
601
+ csrfInput.value = csrfToken.content;
602
+ form.appendChild(csrfInput);
603
+ }
604
+
605
+ // Add selected job IDs
606
+ selectedIds.forEach(id => {
607
+ const input = document.createElement('input');
608
+ input.type = 'hidden';
609
+ input.name = 'job_ids[]';
610
+ input.value = id;
611
+ form.appendChild(input);
612
+ });
613
+
614
+ document.body.appendChild(form);
615
+ form.submit();
616
+ }
617
+ });
618
+ }
619
+
620
+ // Bulk delete failed jobs
621
+ if (bulkDeleteFailed) {
622
+ bulkDeleteFailed.addEventListener('click', function() {
623
+ const selectedIds = Array.from(document.querySelectorAll('.job-checkbox-failed:checked')).map(cb => cb.value);
624
+
625
+ if (selectedIds.length === 0) return;
626
+
627
+ if (confirm(`Are you sure you want to delete ${selectedIds.length} failed job(s)? This cannot be undone.`)) {
628
+ const form = document.createElement('form');
629
+ form.method = 'POST';
630
+ form.action = '<%= ragdoll.bulk_delete_jobs_path %>';
631
+
632
+ // Add CSRF token
633
+ const csrfToken = document.querySelector('meta[name="csrf-token"]');
634
+ if (csrfToken) {
635
+ const csrfInput = document.createElement('input');
636
+ csrfInput.type = 'hidden';
637
+ csrfInput.name = 'authenticity_token';
638
+ csrfInput.value = csrfToken.content;
639
+ form.appendChild(csrfInput);
640
+ }
641
+
642
+ // Add job type
643
+ const typeInput = document.createElement('input');
644
+ typeInput.type = 'hidden';
645
+ typeInput.name = 'job_type';
646
+ typeInput.value = 'failed';
647
+ form.appendChild(typeInput);
648
+
649
+ // Add selected job IDs
650
+ selectedIds.forEach(id => {
651
+ const input = document.createElement('input');
652
+ input.type = 'hidden';
653
+ input.name = 'job_ids[]';
654
+ input.value = id;
655
+ form.appendChild(input);
656
+ });
657
+
658
+ document.body.appendChild(form);
659
+ form.submit();
660
+ }
661
+ });
662
+ }
663
+
664
+ // Initialize button states
665
+ updatePendingBulkButtons();
666
+ updateFailedBulkButtons();
667
+ });
668
+ </script>
669
+ <% end %>