solid_queue_monitor 1.3.0 → 2.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +66 -4
  3. data/app/assets/javascripts/solid_queue_monitor/application.js +393 -0
  4. data/app/{services/solid_queue_monitor/stylesheet_generator.rb → assets/stylesheets/solid_queue_monitor/application.css} +23 -12
  5. data/app/controllers/solid_queue_monitor/application_controller.rb +9 -3
  6. data/app/controllers/solid_queue_monitor/assets_controller.rb +52 -0
  7. data/app/controllers/solid_queue_monitor/base_controller.rb +0 -29
  8. data/app/controllers/solid_queue_monitor/failed_jobs_controller.rb +3 -7
  9. data/app/controllers/solid_queue_monitor/in_progress_jobs_controller.rb +3 -6
  10. data/app/controllers/solid_queue_monitor/jobs_controller.rb +3 -7
  11. data/app/controllers/solid_queue_monitor/overview_controller.rb +3 -12
  12. data/app/controllers/solid_queue_monitor/queues_controller.rb +4 -18
  13. data/app/controllers/solid_queue_monitor/ready_jobs_controller.rb +3 -6
  14. data/app/controllers/solid_queue_monitor/recurring_jobs_controller.rb +3 -6
  15. data/app/controllers/solid_queue_monitor/scheduled_jobs_controller.rb +3 -7
  16. data/app/controllers/solid_queue_monitor/search_controller.rb +3 -4
  17. data/app/controllers/solid_queue_monitor/workers_controller.rb +24 -8
  18. data/app/helpers/solid_queue_monitor/application_helper.rb +46 -0
  19. data/app/helpers/solid_queue_monitor/chart_helper.rb +293 -0
  20. data/app/helpers/solid_queue_monitor/job_details_helper.rb +66 -0
  21. data/app/helpers/solid_queue_monitor/jobs_helper.rb +134 -0
  22. data/app/helpers/solid_queue_monitor/pagination_helper.rb +23 -0
  23. data/app/helpers/solid_queue_monitor/sort_helper.rb +30 -0
  24. data/app/helpers/solid_queue_monitor/workers_helper.rb +88 -0
  25. data/app/services/solid_queue_monitor/asset_cache.rb +56 -0
  26. data/app/views/layouts/solid_queue_monitor/application.html.erb +25 -0
  27. data/app/views/solid_queue_monitor/failed_jobs/_row.html.erb +26 -0
  28. data/app/views/solid_queue_monitor/failed_jobs/index.html.erb +38 -0
  29. data/app/views/solid_queue_monitor/in_progress_jobs/_row.html.erb +13 -0
  30. data/app/views/solid_queue_monitor/in_progress_jobs/index.html.erb +25 -0
  31. data/app/views/solid_queue_monitor/jobs/_arguments.html.erb +9 -0
  32. data/app/views/solid_queue_monitor/jobs/_error.html.erb +26 -0
  33. data/app/views/solid_queue_monitor/jobs/_execution_history.html.erb +25 -0
  34. data/app/views/solid_queue_monitor/jobs/_header.html.erb +37 -0
  35. data/app/views/solid_queue_monitor/jobs/_metadata.html.erb +22 -0
  36. data/app/views/solid_queue_monitor/jobs/_raw_data.html.erb +11 -0
  37. data/app/views/solid_queue_monitor/jobs/_timeline.html.erb +29 -0
  38. data/app/views/solid_queue_monitor/jobs/_timing.html.erb +9 -0
  39. data/app/views/solid_queue_monitor/jobs/_worker.html.erb +12 -0
  40. data/app/views/solid_queue_monitor/jobs/show.html.erb +22 -0
  41. data/app/views/solid_queue_monitor/overview/_chart.html.erb +1 -0
  42. data/app/views/solid_queue_monitor/overview/_recent_job_row.html.erb +26 -0
  43. data/app/views/solid_queue_monitor/overview/_recent_jobs.html.erb +31 -0
  44. data/app/views/solid_queue_monitor/overview/_stat_card.html.erb +4 -0
  45. data/app/views/solid_queue_monitor/overview/_stats.html.erb +11 -0
  46. data/app/views/solid_queue_monitor/overview/index.html.erb +9 -0
  47. data/app/views/solid_queue_monitor/queues/_job_row.html.erb +26 -0
  48. data/app/views/solid_queue_monitor/queues/_row.html.erb +33 -0
  49. data/app/views/solid_queue_monitor/queues/index.html.erb +18 -0
  50. data/app/views/solid_queue_monitor/queues/show.html.erb +63 -0
  51. data/app/views/solid_queue_monitor/ready_jobs/_row.html.erb +7 -0
  52. data/app/views/solid_queue_monitor/ready_jobs/index.html.erb +25 -0
  53. data/app/views/solid_queue_monitor/recurring_jobs/_row.html.erb +8 -0
  54. data/app/views/solid_queue_monitor/recurring_jobs/index.html.erb +26 -0
  55. data/app/views/solid_queue_monitor/scheduled_jobs/_row.html.erb +7 -0
  56. data/app/views/solid_queue_monitor/scheduled_jobs/index.html.erb +36 -0
  57. data/app/views/solid_queue_monitor/search/_completed_row.html.erb +6 -0
  58. data/app/views/solid_queue_monitor/search/_failed_row.html.erb +6 -0
  59. data/app/views/solid_queue_monitor/search/_job_row.html.erb +9 -0
  60. data/app/views/solid_queue_monitor/search/_recurring_row.html.erb +6 -0
  61. data/app/views/solid_queue_monitor/search/_section.html.erb +25 -0
  62. data/app/views/solid_queue_monitor/search/index.html.erb +23 -0
  63. data/app/views/solid_queue_monitor/shared/_filters.html.erb +48 -0
  64. data/app/views/solid_queue_monitor/shared/_flash.html.erb +17 -0
  65. data/app/views/solid_queue_monitor/shared/_footer.html.erb +3 -0
  66. data/app/views/solid_queue_monitor/shared/_header.html.erb +81 -0
  67. data/app/views/solid_queue_monitor/shared/_jobs_table.html.erb +20 -0
  68. data/app/views/solid_queue_monitor/shared/_pagination.html.erb +25 -0
  69. data/app/views/solid_queue_monitor/workers/_row.html.erb +22 -0
  70. data/app/views/solid_queue_monitor/workers/index.html.erb +82 -0
  71. data/config/routes.rb +6 -1
  72. data/lib/solid_queue_monitor/engine.rb +2 -0
  73. data/lib/solid_queue_monitor/version.rb +1 -1
  74. data/lib/solid_queue_monitor.rb +8 -1
  75. metadata +57 -17
  76. data/app/presenters/solid_queue_monitor/base_presenter.rb +0 -211
  77. data/app/presenters/solid_queue_monitor/failed_jobs_presenter.rb +0 -225
  78. data/app/presenters/solid_queue_monitor/in_progress_jobs_presenter.rb +0 -84
  79. data/app/presenters/solid_queue_monitor/job_details_presenter.rb +0 -707
  80. data/app/presenters/solid_queue_monitor/jobs_presenter.rb +0 -144
  81. data/app/presenters/solid_queue_monitor/queue_details_presenter.rb +0 -195
  82. data/app/presenters/solid_queue_monitor/queues_presenter.rb +0 -89
  83. data/app/presenters/solid_queue_monitor/ready_jobs_presenter.rb +0 -81
  84. data/app/presenters/solid_queue_monitor/recurring_jobs_presenter.rb +0 -81
  85. data/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb +0 -178
  86. data/app/presenters/solid_queue_monitor/search_results_presenter.rb +0 -190
  87. data/app/presenters/solid_queue_monitor/stats_presenter.rb +0 -36
  88. data/app/presenters/solid_queue_monitor/workers_presenter.rb +0 -325
  89. data/app/services/solid_queue_monitor/chart_presenter.rb +0 -239
  90. data/app/services/solid_queue_monitor/html_generator.rb +0 -427
@@ -1,707 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module SolidQueueMonitor
4
- class JobDetailsPresenter < BasePresenter
5
- def initialize(job, failed_execution: nil, claimed_execution: nil, scheduled_execution: nil,
6
- recent_executions: [], back_path: nil, nonce: nil)
7
- @job = job
8
- @failed_execution = failed_execution
9
- @claimed_execution = claimed_execution
10
- @scheduled_execution = scheduled_execution
11
- @recent_executions = recent_executions
12
- @back_path = back_path
13
- @nonce = nonce
14
- calculate_timing
15
- end
16
-
17
- def render
18
- <<-HTML
19
- <div class="job-details-page">
20
- #{render_back_link}
21
- #{render_header}
22
- #{render_timeline}
23
- #{render_timing_cards}
24
- #{render_error_section if @failed_execution}
25
- #{render_arguments_section}
26
- #{render_details_section}
27
- #{render_worker_section if @claimed_execution}
28
- #{render_recent_executions}
29
- #{render_raw_data_section}
30
- </div>
31
- HTML
32
- end
33
-
34
- private
35
-
36
- def script_tag_open
37
- @nonce ? %(<script nonce="#{@nonce}">) : '<script>'
38
- end
39
-
40
- def calculate_timing
41
- @created_at = @job.created_at
42
- @scheduled_at = @job.scheduled_at || @scheduled_execution&.scheduled_at
43
- @started_at = @claimed_execution&.created_at
44
- @finished_at = @job.finished_at
45
- @failed_at = @failed_execution&.created_at
46
-
47
- # Calculate durations
48
- @queue_wait_time = calculate_queue_wait
49
- @execution_time = calculate_execution_time
50
- @total_time = calculate_total_time
51
- end
52
-
53
- def calculate_queue_wait
54
- return nil unless @started_at && @created_at
55
-
56
- @started_at - @created_at
57
- end
58
-
59
- def calculate_execution_time
60
- end_time = @finished_at || @failed_at
61
- return nil unless @started_at && end_time
62
-
63
- end_time - @started_at
64
- end
65
-
66
- def calculate_total_time
67
- end_time = @finished_at || @failed_at
68
- return nil unless @created_at && end_time
69
-
70
- end_time - @created_at
71
- end
72
-
73
- def job_status
74
- return :failed if @failed_execution
75
- return :in_progress if @claimed_execution
76
- return :scheduled if @scheduled_execution || @job.scheduled_at&.future?
77
- return :completed if @job.finished_at
78
-
79
- :pending
80
- end
81
-
82
- def status_label
83
- {
84
- failed: 'Failed',
85
- in_progress: 'In Progress',
86
- scheduled: 'Scheduled',
87
- completed: 'Completed',
88
- pending: 'Pending'
89
- }[job_status]
90
- end
91
-
92
- def status_class
93
- {
94
- failed: 'status-failed',
95
- in_progress: 'status-in-progress',
96
- scheduled: 'status-scheduled',
97
- completed: 'status-completed',
98
- pending: 'status-pending'
99
- }[job_status]
100
- end
101
-
102
- def render_back_link
103
- <<-HTML
104
- <div class="job-back-link">
105
- <a href="#{@back_path}" class="back-link">
106
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
107
- <path d="M19 12H5M12 19l-7-7 7-7"/>
108
- </svg>
109
- Back
110
- </a>
111
- </div>
112
- HTML
113
- end
114
-
115
- def render_header
116
- <<-HTML
117
- <div class="job-header">
118
- <div class="job-header-main">
119
- <h1 class="job-title">#{@job.class_name}</h1>
120
- <span class="job-status-badge #{status_class}">#{status_label}</span>
121
- </div>
122
- <div class="job-header-meta">
123
- <span class="job-queue">#{queue_link(@job.queue_name)}</span>
124
- <span class="job-separator">•</span>
125
- <span class="job-priority">Priority #{@job.priority}</span>
126
- <span class="job-separator">•</span>
127
- <span class="job-id">Job ##{@job.id}</span>
128
- </div>
129
- #{render_actions}
130
- </div>
131
- HTML
132
- end
133
-
134
- def render_actions
135
- actions = []
136
-
137
- if @failed_execution
138
- actions << <<-HTML
139
- <form action="#{retry_failed_job_path(id: @failed_execution.id)}" method="post" class="inline-form">
140
- <input type="hidden" name="redirect_to" value="#{job_path(@job)}">
141
- <button type="submit" class="action-button retry-button">Retry</button>
142
- </form>
143
- HTML
144
-
145
- actions << <<-HTML
146
- <form action="#{discard_failed_job_path(id: @failed_execution.id)}" method="post" class="inline-form"
147
- data-confirm="Are you sure you want to discard this job?">
148
- <input type="hidden" name="redirect_to" value="#{failed_jobs_path}">
149
- <button type="submit" class="action-button discard-button">Discard</button>
150
- </form>
151
- HTML
152
- end
153
-
154
- if @scheduled_execution
155
- actions << <<-HTML
156
- <form action="#{execute_scheduled_job_path(id: @scheduled_execution.id)}" method="post" class="inline-form">
157
- <input type="hidden" name="redirect_to" value="#{scheduled_jobs_path}">
158
- <button type="submit" class="action-button retry-button">Execute Now</button>
159
- </form>
160
- HTML
161
- end
162
-
163
- return '' if actions.empty?
164
-
165
- <<-HTML
166
- <div class="job-actions">
167
- #{actions.join}
168
- </div>
169
- HTML
170
- end
171
-
172
- def render_timeline
173
- events = build_timeline_events
174
- return '' if events.size < 2
175
-
176
- <<-HTML
177
- <div class="job-section">
178
- <h3 class="section-title">Timeline</h3>
179
- <div class="job-timeline">
180
- <div class="timeline-track">
181
- #{render_timeline_events(events)}
182
- </div>
183
- </div>
184
- </div>
185
- HTML
186
- end
187
-
188
- def build_timeline_events
189
- events = []
190
- events << { label: 'Created', time: @created_at, status: :done } if @created_at
191
- events << { label: 'Scheduled', time: @scheduled_at, status: :done } if @scheduled_at && @scheduled_at != @created_at
192
- events << { label: 'Started', time: @started_at, status: :done } if @started_at
193
-
194
- case job_status
195
- when :completed
196
- events << { label: 'Completed', time: @finished_at, status: :success }
197
- when :failed
198
- events << { label: 'Failed', time: @failed_at, status: :failed }
199
- when :in_progress
200
- events << { label: 'Running...', time: nil, status: :active }
201
- end
202
-
203
- events
204
- end
205
-
206
- def render_timeline_events(events)
207
- total = events.size
208
- events.map.with_index do |event, index|
209
- is_last = index == total - 1
210
- status_class = "timeline-#{event[:status]}"
211
-
212
- <<-HTML
213
- <div class="timeline-event #{status_class}">
214
- <div class="timeline-dot"></div>
215
- #{is_last ? '' : '<div class="timeline-line"></div>'}
216
- <div class="timeline-content">
217
- <div class="timeline-label">#{event[:label]}</div>
218
- <div class="timeline-time">#{event[:time] ? format_datetime(event[:time]) : ''}</div>
219
- </div>
220
- </div>
221
- HTML
222
- end.join
223
- end
224
-
225
- def render_timing_cards
226
- <<-HTML
227
- <div class="timing-cards">
228
- #{render_timing_card('Queue Wait', @queue_wait_time, queue_wait_indicator, timing_unavailable_reason(:queue_wait))}
229
- #{render_timing_card('Execution', @execution_time, execution_indicator, timing_unavailable_reason(:execution))}
230
- #{render_timing_card('Total Time', @total_time, nil, nil)}
231
- </div>
232
- HTML
233
- end
234
-
235
- def render_timing_card(label, duration, indicator, unavailable_reason)
236
- formatted = duration ? format_duration(duration) : '-'
237
- indicator_html = indicator ? "<div class=\"timing-indicator #{indicator[:class]}\">#{indicator[:label]}</div>" : ''
238
- tooltip = unavailable_reason && !duration ? " title=\"#{unavailable_reason}\"" : ''
239
-
240
- <<-HTML
241
- <div class="timing-card"#{tooltip}>
242
- <div class="timing-value">#{formatted}</div>
243
- <div class="timing-label">#{label}</div>
244
- #{indicator_html}
245
- </div>
246
- HTML
247
- end
248
-
249
- def timing_unavailable_reason(timing_type)
250
- return nil if @claimed_execution # In-progress jobs have all timing data
251
- return nil unless %i[queue_wait execution].include?(timing_type)
252
-
253
- if @failed_execution || @job.finished_at
254
- 'Not available - execution record deleted after job completed'
255
- else
256
- 'Available once job starts processing'
257
- end
258
- end
259
-
260
- def queue_wait_indicator
261
- return nil unless @queue_wait_time
262
-
263
- if @queue_wait_time > 300 # > 5 minutes
264
- { class: 'indicator-warning', label: 'High' }
265
- elsif @queue_wait_time > 60 # > 1 minute
266
- { class: 'indicator-normal', label: 'Normal' }
267
- else
268
- { class: 'indicator-good', label: 'Fast' }
269
- end
270
- end
271
-
272
- def execution_indicator
273
- return nil unless @execution_time
274
-
275
- if @execution_time > 60 # > 1 minute
276
- { class: 'indicator-warning', label: 'Slow' }
277
- elsif @execution_time > 10 # > 10 seconds
278
- { class: 'indicator-normal', label: 'Normal' }
279
- else
280
- { class: 'indicator-good', label: 'Fast' }
281
- end
282
- end
283
-
284
- def format_duration(seconds)
285
- return '-' unless seconds
286
-
287
- if seconds < 1
288
- "#{(seconds * 1000).round}ms"
289
- elsif seconds < 60
290
- "#{seconds.round(1)}s"
291
- elsif seconds < 3600
292
- minutes = (seconds / 60).floor
293
- secs = (seconds % 60).round
294
- "#{minutes}m #{secs}s"
295
- else
296
- hours = (seconds / 3600).floor
297
- minutes = ((seconds % 3600) / 60).floor
298
- "#{hours}h #{minutes}m"
299
- end
300
- end
301
-
302
- def render_error_section
303
- error = parse_error(@failed_execution.error)
304
-
305
- <<-HTML
306
- <div class="job-section error-section">
307
- <div class="section-header">
308
- <h3 class="section-title">Error</h3>
309
- <button class="copy-button" data-action="copy" data-target="error-content">
310
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
311
- <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
312
- <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
313
- </svg>
314
- Copy
315
- </button>
316
- </div>
317
- <div id="error-content">
318
- <div class="error-type">#{error[:type]}</div>
319
- <div class="error-message-box">#{error[:message]}</div>
320
- </div>
321
- #{render_backtrace(error[:backtrace])}
322
- </div>
323
- HTML
324
- end
325
-
326
- def render_backtrace(backtrace)
327
- return '' if backtrace.blank?
328
-
329
- lines = backtrace.is_a?(Array) ? backtrace : backtrace.to_s.split("\n")
330
- app_lines = lines.select { |line| line.include?('/app/') || line.include?('/lib/') }
331
-
332
- <<-HTML
333
- <div class="backtrace-section">
334
- <div class="backtrace-header">
335
- <span class="backtrace-title">Backtrace</span>
336
- <div class="backtrace-toggle">
337
- <button class="toggle-btn active" data-action="show-backtrace" data-backtrace="app">App Only</button>
338
- <button class="toggle-btn" data-action="show-backtrace" data-backtrace="full">Full</button>
339
- </div>
340
- </div>
341
- <pre class="backtrace-content" id="app-backtrace">#{format_backtrace_lines(app_lines.presence || lines.first(5))}</pre>
342
- <pre class="backtrace-content is-hidden" id="full-backtrace">#{format_backtrace_lines(lines)}</pre>
343
- </div>
344
- HTML
345
- end
346
-
347
- def format_backtrace_lines(lines)
348
- lines.map.with_index do |line, index|
349
- "<span class=\"backtrace-line\"><span class=\"line-number\">#{index + 1}.</span> #{CGI.escapeHTML(line.to_s.strip)}</span>"
350
- end.join("\n")
351
- end
352
-
353
- def parse_error(error)
354
- return { type: 'Unknown', message: 'Unknown error', backtrace: [] } unless error
355
-
356
- # Convert to hash if it's a serialized string
357
- error_hash = deserialize_error(error)
358
-
359
- {
360
- type: extract_error_type(error_hash),
361
- message: extract_error_message(error_hash),
362
- backtrace: extract_backtrace(error_hash)
363
- }
364
- end
365
-
366
- def deserialize_error(error)
367
- return error if error.is_a?(Hash)
368
-
369
- if error.is_a?(String)
370
- # Try JSON first
371
- if error.strip.start_with?('{')
372
- begin
373
- return JSON.parse(error)
374
- rescue JSON::ParserError
375
- # Continue to try other formats
376
- end
377
- end
378
-
379
- # Try YAML (SolidQueue may use YAML serialization)
380
- begin
381
- parsed = YAML.safe_load(error, permitted_classes: [Symbol])
382
- return parsed if parsed.is_a?(Hash)
383
- rescue StandardError
384
- # Continue with string
385
- end
386
-
387
- # Return as simple error hash
388
- { 'message' => error }
389
- else
390
- { 'message' => error.to_s }
391
- end
392
- end
393
-
394
- def extract_error_type(error_hash)
395
- error_hash['exception_class'] || error_hash[:exception_class] ||
396
- error_hash['error_class'] || error_hash[:error_class] ||
397
- error_hash['class'] || error_hash[:class] || 'Error'
398
- end
399
-
400
- def extract_error_message(error_hash)
401
- error_hash['message'] || error_hash[:message] ||
402
- error_hash['error'] || error_hash[:error] || 'Unknown error'
403
- end
404
-
405
- def extract_backtrace(error_hash)
406
- bt = error_hash['backtrace'] || error_hash[:backtrace] ||
407
- error_hash['stack_trace'] || error_hash[:stack_trace] || []
408
-
409
- # Ensure it's an array
410
- return bt if bt.is_a?(Array)
411
- return bt.split("\n") if bt.is_a?(String) && bt.present?
412
-
413
- []
414
- end
415
-
416
- def render_arguments_section
417
- args = @job.arguments
418
- formatted_args = format_job_arguments_pretty(args)
419
-
420
- <<-HTML
421
- <div class="job-section">
422
- <div class="section-header">
423
- <h3 class="section-title">Arguments</h3>
424
- <div class="section-actions">
425
- <button class="copy-button" data-action="copy" data-target="arguments-content">
426
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
427
- <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
428
- <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
429
- </svg>
430
- Copy
431
- </button>
432
- </div>
433
- </div>
434
- <pre class="arguments-content" id="arguments-content">#{CGI.escapeHTML(formatted_args)}</pre>
435
- </div>
436
- HTML
437
- end
438
-
439
- def format_job_arguments_pretty(args)
440
- return '-' if args.blank?
441
-
442
- JSON.pretty_generate(args)
443
- rescue JSON::GeneratorError
444
- args.inspect
445
- end
446
-
447
- def render_details_section
448
- <<-HTML
449
- <div class="job-section">
450
- <h3 class="section-title">Job Details</h3>
451
- <div class="details-grid">
452
- <div class="detail-row">
453
- <span class="detail-label">Class</span>
454
- <span class="detail-value">#{@job.class_name}</span>
455
- </div>
456
- <div class="detail-row">
457
- <span class="detail-label">Queue</span>
458
- <span class="detail-value">#{queue_link(@job.queue_name, css_class: 'queue-badge')}</span>
459
- </div>
460
- <div class="detail-row">
461
- <span class="detail-label">Priority</span>
462
- <span class="detail-value">#{@job.priority}</span>
463
- </div>
464
- <div class="detail-row">
465
- <span class="detail-label">Active Job ID</span>
466
- <span class="detail-value detail-mono">#{@job.active_job_id || '-'}</span>
467
- </div>
468
- #{render_concurrency_key}
469
- <div class="detail-row">
470
- <span class="detail-label">Created At</span>
471
- <span class="detail-value">#{format_datetime(@job.created_at)}</span>
472
- </div>
473
- #{render_scheduled_at}
474
- #{render_finished_at}
475
- #{render_failed_at}
476
- </div>
477
- </div>
478
- HTML
479
- end
480
-
481
- def render_concurrency_key
482
- return '' if @job.concurrency_key.blank?
483
-
484
- <<-HTML
485
- <div class="detail-row">
486
- <span class="detail-label">Concurrency Key</span>
487
- <span class="detail-value detail-mono">#{@job.concurrency_key}</span>
488
- </div>
489
- HTML
490
- end
491
-
492
- def render_scheduled_at
493
- return '' unless @scheduled_at
494
-
495
- <<-HTML
496
- <div class="detail-row">
497
- <span class="detail-label">Scheduled At</span>
498
- <span class="detail-value">#{format_datetime(@scheduled_at)}</span>
499
- </div>
500
- HTML
501
- end
502
-
503
- def render_finished_at
504
- return '' unless @job.finished_at
505
-
506
- <<-HTML
507
- <div class="detail-row">
508
- <span class="detail-label">Finished At</span>
509
- <span class="detail-value">#{format_datetime(@job.finished_at)}</span>
510
- </div>
511
- HTML
512
- end
513
-
514
- def render_failed_at
515
- return '' unless @failed_at
516
-
517
- <<-HTML
518
- <div class="detail-row">
519
- <span class="detail-label">Failed At</span>
520
- <span class="detail-value">#{format_datetime(@failed_at)}</span>
521
- </div>
522
- HTML
523
- end
524
-
525
- def render_worker_section
526
- process = @claimed_execution.instance_variable_get(:@process)
527
- return '' unless process
528
-
529
- <<-HTML
530
- <div class="job-section">
531
- <h3 class="section-title">Worker</h3>
532
- <div class="details-grid">
533
- <div class="detail-row">
534
- <span class="detail-label">Hostname</span>
535
- <span class="detail-value">#{process.hostname}</span>
536
- </div>
537
- <div class="detail-row">
538
- <span class="detail-label">PID</span>
539
- <span class="detail-value">#{process.pid}</span>
540
- </div>
541
- <div class="detail-row">
542
- <span class="detail-label">Process Type</span>
543
- <span class="detail-value">#{process.kind}</span>
544
- </div>
545
- <div class="detail-row">
546
- <span class="detail-label">Started At</span>
547
- <span class="detail-value">#{format_datetime(@claimed_execution.created_at)}</span>
548
- </div>
549
- </div>
550
- </div>
551
- HTML
552
- end
553
-
554
- def render_recent_executions
555
- return '' if @recent_executions.empty?
556
-
557
- <<-HTML
558
- <div class="job-section">
559
- <div class="section-header">
560
- <h3 class="section-title">Recent Executions</h3>
561
- <span class="section-subtitle">Other #{@job.class_name} jobs</span>
562
- </div>
563
- <div class="table-container">
564
- <table class="recent-executions-table">
565
- <thead>
566
- <tr>
567
- <th>Status</th>
568
- <th>Arguments</th>
569
- <th>Created</th>
570
- <th>Duration</th>
571
- </tr>
572
- </thead>
573
- <tbody>
574
- #{@recent_executions.map { |job| render_execution_row(job) }.join}
575
- </tbody>
576
- </table>
577
- </div>
578
- </div>
579
- HTML
580
- end
581
-
582
- def render_execution_row(job)
583
- status = determine_job_status(job)
584
- status_badge = render_status_badge(status)
585
- duration = calculate_job_duration(job)
586
- args_preview = truncate_arguments(job.arguments)
587
-
588
- <<-HTML
589
- <tr>
590
- <td>#{status_badge}</td>
591
- <td class="args-preview"><a href="#{job_path(job)}">#{args_preview}</a></td>
592
- <td>#{time_ago_in_words(job.created_at)} ago</td>
593
- <td>#{duration}</td>
594
- </tr>
595
- HTML
596
- end
597
-
598
- def determine_job_status(job)
599
- return :failed if job.failed_execution.present?
600
- return :in_progress if job.claimed_execution.present?
601
- return :scheduled if job.scheduled_execution.present?
602
- return :ready if job.ready_execution.present?
603
- return :completed if job.finished_at
604
-
605
- :pending
606
- end
607
-
608
- def render_status_badge(status)
609
- labels = {
610
- failed: 'Failed',
611
- completed: 'Completed',
612
- in_progress: 'In Progress',
613
- scheduled: 'Scheduled',
614
- ready: 'Ready',
615
- pending: 'Pending'
616
- }
617
- classes = {
618
- failed: 'status-failed',
619
- completed: 'status-completed',
620
- in_progress: 'status-in-progress',
621
- scheduled: 'status-scheduled',
622
- ready: 'status-pending',
623
- pending: 'status-pending'
624
- }
625
-
626
- "<span class=\"mini-status-badge #{classes[status]}\">#{labels[status]}</span>"
627
- end
628
-
629
- def calculate_job_duration(job)
630
- return '-' unless job.finished_at || job.failed_execution&.created_at
631
-
632
- end_time = job.finished_at || job.failed_execution&.created_at
633
- format_duration(end_time - job.created_at)
634
- end
635
-
636
- def truncate_arguments(args)
637
- return '-' if args.blank?
638
-
639
- preview = args.inspect.truncate(60)
640
- CGI.escapeHTML(preview)
641
- end
642
-
643
- def render_raw_data_section
644
- <<-HTML
645
- <div class="job-section collapsible-section">
646
- <div class="section-header collapsible-header" data-action="toggle-section">
647
- <div class="collapsible-title">
648
- <svg class="collapse-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
649
- <polyline points="9 18 15 12 9 6"></polyline>
650
- </svg>
651
- <h3 class="section-title">Raw Data</h3>
652
- </div>
653
- <button class="copy-button" data-action="copy" data-target="raw-data-content" data-stop-propagation="true">
654
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
655
- <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
656
- <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
657
- </svg>
658
- Copy
659
- </button>
660
- </div>
661
- <div class="collapsible-content">
662
- <pre class="raw-data-content" id="raw-data-content">#{CGI.escapeHTML(JSON.pretty_generate(@job.attributes))}</pre>
663
- </div>
664
- </div>
665
- #{script_tag_open}
666
- (function() {
667
- document.addEventListener('click', function(e) {
668
- var el = e.target.closest('[data-action]');
669
- if (!el) return;
670
-
671
- if (el.dataset.stopPropagation === 'true') e.stopPropagation();
672
-
673
- var action = el.dataset.action;
674
-
675
- if (action === 'copy') {
676
- var target = document.getElementById(el.dataset.target);
677
- if (!target) return;
678
- var text = target.innerText || target.textContent;
679
- navigator.clipboard.writeText(text).then(function() {
680
- var original = el.innerHTML;
681
- el.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg> Copied!';
682
- setTimeout(function() { el.innerHTML = original; }, 2000);
683
- });
684
- }
685
-
686
- if (action === 'show-backtrace') {
687
- var which = el.dataset.backtrace;
688
- var appEl = document.getElementById('app-backtrace');
689
- var fullEl = document.getElementById('full-backtrace');
690
- if (appEl) appEl.classList.toggle('is-hidden', which !== 'app');
691
- if (fullEl) fullEl.classList.toggle('is-hidden', which !== 'full');
692
- document.querySelectorAll('[data-action="show-backtrace"]').forEach(function(btn) {
693
- btn.classList.toggle('active', btn.dataset.backtrace === which);
694
- });
695
- }
696
-
697
- if (action === 'toggle-section') {
698
- var section = el.closest('.collapsible-section');
699
- if (section) section.classList.toggle('is-expanded');
700
- }
701
- });
702
- })();
703
- </script>
704
- HTML
705
- end
706
- end
707
- end