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.
@@ -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