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