solid_queue_monitor 1.2.2 → 2.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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +36 -1
  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} +80 -12
  5. data/app/controllers/solid_queue_monitor/application_controller.rb +2 -2
  6. data/app/controllers/solid_queue_monitor/assets_controller.rb +52 -0
  7. data/app/controllers/solid_queue_monitor/base_controller.rb +0 -28
  8. data/app/controllers/solid_queue_monitor/failed_jobs_controller.rb +3 -6
  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 -6
  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 -6
  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/services/solid_queue_monitor/chart_data_service.rb +2 -2
  27. data/app/views/layouts/solid_queue_monitor/application.html.erb +25 -0
  28. data/app/views/solid_queue_monitor/failed_jobs/_row.html.erb +26 -0
  29. data/app/views/solid_queue_monitor/failed_jobs/index.html.erb +38 -0
  30. data/app/views/solid_queue_monitor/in_progress_jobs/_row.html.erb +13 -0
  31. data/app/views/solid_queue_monitor/in_progress_jobs/index.html.erb +25 -0
  32. data/app/views/solid_queue_monitor/jobs/_arguments.html.erb +9 -0
  33. data/app/views/solid_queue_monitor/jobs/_error.html.erb +26 -0
  34. data/app/views/solid_queue_monitor/jobs/_execution_history.html.erb +25 -0
  35. data/app/views/solid_queue_monitor/jobs/_header.html.erb +37 -0
  36. data/app/views/solid_queue_monitor/jobs/_metadata.html.erb +22 -0
  37. data/app/views/solid_queue_monitor/jobs/_raw_data.html.erb +11 -0
  38. data/app/views/solid_queue_monitor/jobs/_timeline.html.erb +29 -0
  39. data/app/views/solid_queue_monitor/jobs/_timing.html.erb +9 -0
  40. data/app/views/solid_queue_monitor/jobs/_worker.html.erb +12 -0
  41. data/app/views/solid_queue_monitor/jobs/show.html.erb +22 -0
  42. data/app/views/solid_queue_monitor/overview/_chart.html.erb +1 -0
  43. data/app/views/solid_queue_monitor/overview/_recent_job_row.html.erb +26 -0
  44. data/app/views/solid_queue_monitor/overview/_recent_jobs.html.erb +31 -0
  45. data/app/views/solid_queue_monitor/overview/_stat_card.html.erb +4 -0
  46. data/app/views/solid_queue_monitor/overview/_stats.html.erb +11 -0
  47. data/app/views/solid_queue_monitor/overview/index.html.erb +9 -0
  48. data/app/views/solid_queue_monitor/queues/_job_row.html.erb +26 -0
  49. data/app/views/solid_queue_monitor/queues/_row.html.erb +33 -0
  50. data/app/views/solid_queue_monitor/queues/index.html.erb +18 -0
  51. data/app/views/solid_queue_monitor/queues/show.html.erb +63 -0
  52. data/app/views/solid_queue_monitor/ready_jobs/_row.html.erb +7 -0
  53. data/app/views/solid_queue_monitor/ready_jobs/index.html.erb +25 -0
  54. data/app/views/solid_queue_monitor/recurring_jobs/_row.html.erb +8 -0
  55. data/app/views/solid_queue_monitor/recurring_jobs/index.html.erb +26 -0
  56. data/app/views/solid_queue_monitor/scheduled_jobs/_row.html.erb +7 -0
  57. data/app/views/solid_queue_monitor/scheduled_jobs/index.html.erb +36 -0
  58. data/app/views/solid_queue_monitor/search/_completed_row.html.erb +6 -0
  59. data/app/views/solid_queue_monitor/search/_failed_row.html.erb +6 -0
  60. data/app/views/solid_queue_monitor/search/_job_row.html.erb +9 -0
  61. data/app/views/solid_queue_monitor/search/_recurring_row.html.erb +6 -0
  62. data/app/views/solid_queue_monitor/search/_section.html.erb +25 -0
  63. data/app/views/solid_queue_monitor/search/index.html.erb +23 -0
  64. data/app/views/solid_queue_monitor/shared/_filters.html.erb +48 -0
  65. data/app/views/solid_queue_monitor/shared/_flash.html.erb +17 -0
  66. data/app/views/solid_queue_monitor/shared/_footer.html.erb +3 -0
  67. data/app/views/solid_queue_monitor/shared/_header.html.erb +81 -0
  68. data/app/views/solid_queue_monitor/shared/_jobs_table.html.erb +20 -0
  69. data/app/views/solid_queue_monitor/shared/_pagination.html.erb +25 -0
  70. data/app/views/solid_queue_monitor/workers/_row.html.erb +22 -0
  71. data/app/views/solid_queue_monitor/workers/index.html.erb +82 -0
  72. data/config/routes.rb +6 -1
  73. data/lib/solid_queue_monitor/engine.rb +2 -0
  74. data/lib/solid_queue_monitor/version.rb +1 -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 -312
  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 -696
  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 -173
  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 -320
  89. data/app/services/solid_queue_monitor/chart_presenter.rb +0 -239
  90. data/app/services/solid_queue_monitor/html_generator.rb +0 -401
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueMonitor
4
+ module JobsHelper
5
+ def format_arguments(arguments)
6
+ return '-' if arguments.blank?
7
+
8
+ formatted = unwrap_arguments(arguments)
9
+ if formatted.length <= 50
10
+ tag.code(formatted, class: 'args-single-line')
11
+ else
12
+ tag.div(tag.code(formatted, class: 'args-content'), class: 'args-container')
13
+ end
14
+ end
15
+
16
+ def format_hash(hash)
17
+ return '-' if hash.blank?
18
+
19
+ parts = hash.map do |key, value|
20
+ safe_join([tag.strong("#{key}:"), ' ', truncate(value.to_s, length: 50)])
21
+ end
22
+ tag.code(safe_join(parts, ', '))
23
+ end
24
+
25
+ def job_status(job)
26
+ SolidQueueMonitor::StatusCalculator.new(job).calculate
27
+ end
28
+
29
+ def job_status_badge(job)
30
+ status = job_status(job)
31
+ tag.span(status, class: "status-badge status-#{status}")
32
+ end
33
+
34
+ def mini_job_status_badge(job)
35
+ status = mini_job_status(job)
36
+
37
+ labels = {
38
+ failed: 'Failed',
39
+ completed: 'Completed',
40
+ in_progress: 'In Progress',
41
+ scheduled: 'Scheduled',
42
+ ready: 'Ready',
43
+ pending: 'Pending'
44
+ }
45
+ css_status = status == :ready ? :pending : status
46
+ tag.span(labels[status], class: "mini-status-badge status-#{css_status}")
47
+ end
48
+
49
+ def failed_error_message(error)
50
+ parsed_failed_error(error)[:message].to_s
51
+ end
52
+
53
+ def parsed_failed_error(error)
54
+ return { type: 'Unknown', message: 'Unknown error', backtrace: [] } unless error
55
+
56
+ error_hash = deserialize_failed_error(error)
57
+ {
58
+ type: failed_error_type(error_hash),
59
+ message: failed_error_text(error_hash),
60
+ backtrace: failed_error_backtrace(error_hash)
61
+ }
62
+ end
63
+
64
+ private
65
+
66
+ def mini_job_status(job)
67
+ return :failed if job.respond_to?(:failed_execution) && job.failed_execution.present?
68
+ return :in_progress if job.respond_to?(:claimed_execution) && job.claimed_execution.present?
69
+ return :scheduled if job.respond_to?(:scheduled_execution) && job.scheduled_execution.present?
70
+ return :ready if job.respond_to?(:ready_execution) && job.ready_execution.present?
71
+ return :completed if job.finished_at
72
+
73
+ :pending
74
+ end
75
+
76
+ def failed_error_type(error_hash)
77
+ error_hash['exception_class'] || error_hash[:exception_class] ||
78
+ error_hash['error_class'] || error_hash[:error_class] ||
79
+ error_hash['class'] || error_hash[:class] || 'Error'
80
+ end
81
+
82
+ def failed_error_text(error_hash)
83
+ error_hash['message'] || error_hash[:message] ||
84
+ error_hash['error'] || error_hash[:error] || 'Unknown error'
85
+ end
86
+
87
+ def failed_error_backtrace(error_hash)
88
+ Array(error_hash['backtrace'] || error_hash[:backtrace] || error_hash['stack_trace'] || error_hash[:stack_trace])
89
+ end
90
+
91
+ def deserialize_failed_error(error)
92
+ return error if error.is_a?(Hash)
93
+ return { 'message' => error.to_s } unless error.is_a?(String)
94
+
95
+ JSON.parse(error)
96
+ rescue JSON::ParserError
97
+ { 'message' => error }
98
+ end
99
+
100
+ def unwrap_arguments(arguments)
101
+ payload = if arguments.is_a?(Hash) && arguments['arguments'].present?
102
+ format_job_arguments(arguments)
103
+ elsif wrapped_job_arguments?(arguments)
104
+ format_job_arguments(arguments.first)
105
+ else
106
+ arguments.inspect
107
+ end
108
+ payload.to_s
109
+ end
110
+
111
+ def wrapped_job_arguments?(arguments)
112
+ arguments.is_a?(Array) &&
113
+ arguments.length == 1 &&
114
+ arguments.first.is_a?(Hash) &&
115
+ arguments.first['arguments'].present?
116
+ end
117
+
118
+ def format_job_arguments(job_data)
119
+ args = if ruby2_keywords_payload?(job_data)
120
+ job_data['arguments'].first.except('_aj_ruby2_keywords')
121
+ else
122
+ job_data['arguments']
123
+ end
124
+
125
+ args.inspect
126
+ end
127
+
128
+ def ruby2_keywords_payload?(job_data)
129
+ job_data['arguments'].is_a?(Array) &&
130
+ job_data['arguments'].first.is_a?(Hash) &&
131
+ job_data['arguments'].first['_aj_ruby2_keywords'].present?
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueMonitor
4
+ module PaginationHelper
5
+ def visible_pages(current_page, total_pages)
6
+ return (1..total_pages).to_a if total_pages <= 7
7
+
8
+ case current_page
9
+ when 1..3
10
+ [1, 2, 3, 4, :gap, total_pages]
11
+ when (total_pages - 2)..total_pages
12
+ [1, :gap, total_pages - 3, total_pages - 2, total_pages - 1, total_pages]
13
+ else
14
+ [1, :gap, current_page - 1, current_page, current_page + 1, :gap, total_pages]
15
+ end
16
+ end
17
+
18
+ def pagination_href(page, extra_params = {})
19
+ query = request.query_parameters.merge(extra_params).merge(page: page)
20
+ "?#{query.to_query}"
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueMonitor
4
+ module SortHelper
5
+ def sortable_header(column, label, sort:, filters: {})
6
+ return tag.th(label) unless sort
7
+
8
+ column_str = column.to_s
9
+ active = sort[:sort_by] == column_str
10
+ next_dir = active && sort[:sort_direction] == 'asc' ? 'desc' : 'asc'
11
+ query = filters.compact.merge(sort_by: column_str, sort_direction: next_dir)
12
+
13
+ tag.th(
14
+ link_to(
15
+ safe_join([label, sort_arrow(active, sort[:sort_direction])]),
16
+ "?#{query.to_query}",
17
+ class: class_names('sortable-header', active: active)
18
+ )
19
+ )
20
+ end
21
+
22
+ private
23
+
24
+ def sort_arrow(active, direction)
25
+ return ' &udarr;'.html_safe unless active
26
+
27
+ direction == 'asc' ? ' &uarr;'.html_safe : ' &darr;'.html_safe
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueMonitor
4
+ module WorkersHelper
5
+ HEARTBEAT_STALE_THRESHOLD = 5.minutes
6
+ HEARTBEAT_DEAD_THRESHOLD = 10.minutes
7
+
8
+ def worker_status(process)
9
+ return :dead unless process.last_heartbeat_at
10
+
11
+ time_since_heartbeat = Time.current - process.last_heartbeat_at
12
+ return :dead if time_since_heartbeat > HEARTBEAT_DEAD_THRESHOLD
13
+ return :stale if time_since_heartbeat > HEARTBEAT_STALE_THRESHOLD
14
+
15
+ :healthy
16
+ end
17
+
18
+ def worker_row_class(process)
19
+ case worker_status(process)
20
+ when :dead then 'worker-dead'
21
+ when :stale then 'worker-stale'
22
+ else ''
23
+ end
24
+ end
25
+
26
+ def worker_kind_badge(kind)
27
+ badge_class = case kind
28
+ when 'Worker' then 'kind-worker'
29
+ when 'Dispatcher' then 'kind-dispatcher'
30
+ when 'Scheduler' then 'kind-scheduler'
31
+ else 'kind-other'
32
+ end
33
+ tag.span(kind, class: class_names('kind-badge', badge_class))
34
+ end
35
+
36
+ def worker_hostname(process)
37
+ process.hostname || worker_metadata(process)['hostname'] || '-'
38
+ end
39
+
40
+ def worker_queues(process)
41
+ queues = worker_metadata(process)['queues']
42
+ return '-' if queues.nil?
43
+
44
+ return tag.code(queues == '*' ? 'All Queues' : queues, class: 'queue-tag') if queues.is_a?(String)
45
+ return '-' if queues.empty?
46
+
47
+ if queues.length <= 3
48
+ safe_join(queues.map { |queue| tag.code(queue, class: 'queue-tag') }, ' ')
49
+ else
50
+ visible = safe_join(queues.first(2).map { |queue| tag.code(queue, class: 'queue-tag') }, ' ')
51
+ safe_join([visible, tag.span("+#{queues.length - 2} more", class: 'queue-more')], ' ')
52
+ end
53
+ end
54
+
55
+ def worker_heartbeat(heartbeat_at)
56
+ return '-' unless heartbeat_at
57
+
58
+ tag.span("#{time_ago_in_words(heartbeat_at)} ago", title: heartbeat_at.strftime('%Y-%m-%d %H:%M:%S'))
59
+ end
60
+
61
+ def worker_status_badge(status)
62
+ tag.span(status.to_s.capitalize, class: "status-badge status-#{status}")
63
+ end
64
+
65
+ def worker_jobs_processing(process, claimed_counts:, claimed_jobs:)
66
+ count = claimed_counts[process.id] || 0
67
+ return tag.span('Idle', class: 'jobs-idle') if count.zero?
68
+
69
+ jobs = claimed_jobs[process.id] || []
70
+ job_names = jobs.map(&:class_name).uniq.first(3)
71
+ tooltip = jobs.first(10).map { |job| "#{job.class_name} (ID: #{job.id})" }.join("\n")
72
+ label = "#{count} job#{'s' if count > 1}"
73
+ names = "(#{job_names.join(', ')}#{'...' if jobs.length > 3})"
74
+
75
+ tag.span(class: 'jobs-processing', title: tooltip) do
76
+ safe_join([label, tag.span(names, class: 'job-names')], ' ')
77
+ end
78
+ end
79
+
80
+ def worker_metadata(process)
81
+ return {} unless process.metadata
82
+
83
+ process.metadata.is_a?(String) ? JSON.parse(process.metadata) : process.metadata
84
+ rescue JSON::ParserError
85
+ {}
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module SolidQueueMonitor
6
+ class AssetCache
7
+ ASSET_ROOT = SolidQueueMonitor::Engine.root.join('app/assets').freeze
8
+ SUBDIRS_BY_EXT = { '.css' => 'stylesheets', '.js' => 'javascripts' }.freeze
9
+ MUTEX = Mutex.new
10
+
11
+ @entries = {}
12
+
13
+ class << self
14
+ def fetch_by_name(file_name)
15
+ path = path_for(file_name)
16
+ return nil unless path&.file?
17
+
18
+ cached = @entries[path.to_s]
19
+ return cached if cached && cached[:mtime] == path.mtime
20
+
21
+ MUTEX.synchronize do
22
+ cached = @entries[path.to_s]
23
+ return cached if cached && cached[:mtime] == path.mtime
24
+
25
+ content = path.read
26
+ @entries[path.to_s] = {
27
+ content: content,
28
+ mtime: path.mtime,
29
+ etag: Digest::SHA256.hexdigest(content)[0, 16]
30
+ }
31
+ end
32
+ end
33
+
34
+ def fingerprint_for(file_name)
35
+ fetch_by_name(file_name)&.dig(:etag)
36
+ end
37
+
38
+ def clear!
39
+ MUTEX.synchronize { @entries = {} }
40
+ end
41
+
42
+ private
43
+
44
+ def path_for(file_name)
45
+ ext = File.extname(file_name)
46
+ subdir = SUBDIRS_BY_EXT[ext]
47
+ return nil unless subdir
48
+
49
+ candidate = ASSET_ROOT.join(subdir, 'solid_queue_monitor', file_name).expand_path
50
+ return nil unless candidate.to_s.start_with?(ASSET_ROOT.to_s)
51
+
52
+ candidate
53
+ end
54
+ end
55
+ end
56
+ end
@@ -86,9 +86,9 @@ module SolidQueueMonitor
86
86
  if adapter?('sqlite')
87
87
  "CAST((CAST(strftime('%s', #{column}) AS INTEGER) - #{start_epoch}) / #{interval_seconds} AS INTEGER)"
88
88
  elsif adapter?('mysql') || adapter?('trilogy')
89
- "CAST((UNIX_TIMESTAMP(#{column}) - #{start_epoch}) / #{interval_seconds} AS SIGNED)"
89
+ "FLOOR((UNIX_TIMESTAMP(#{column}) - #{start_epoch}) / #{interval_seconds})"
90
90
  else
91
- "CAST((EXTRACT(EPOCH FROM #{column}) - #{start_epoch}) / #{interval_seconds} AS INTEGER)"
91
+ "FLOOR((EXTRACT(EPOCH FROM #{column}) - #{start_epoch}) / #{interval_seconds})::integer"
92
92
  end
93
93
  end
94
94
 
@@ -0,0 +1,25 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Solid Queue Monitor - <%= content_for?(:title) ? yield(:title) : 'Dashboard' %></title>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <%= stylesheet_link_tag asset_url_for('application.css'), nonce: content_security_policy_nonce %>
8
+ </head>
9
+ <body class="solid_queue_monitor"
10
+ data-auto-refresh-enabled="<%= SolidQueueMonitor.auto_refresh_enabled %>"
11
+ data-auto-refresh-interval="<%= SolidQueueMonitor.auto_refresh_interval %>">
12
+ <%= render 'solid_queue_monitor/shared/flash' %>
13
+ <div class="container">
14
+ <%= render 'solid_queue_monitor/shared/header' %>
15
+ <div class="section">
16
+ <% if content_for?(:title) %>
17
+ <h2><%= yield :title %></h2>
18
+ <% end %>
19
+ <%= yield %>
20
+ </div>
21
+ <%= render 'solid_queue_monitor/shared/footer' %>
22
+ </div>
23
+ <%= javascript_include_tag asset_url_for('application.js'), nonce: content_security_policy_nonce %>
24
+ </body>
25
+ </html>
@@ -0,0 +1,26 @@
1
+ <tr>
2
+ <td><input type="checkbox" class="job-checkbox" value="<%= job.id %>"></td>
3
+ <td>
4
+ <div class="job-class"><%= link_to job.job.class_name, job_path(job.job), class: 'job-class-link' %></div>
5
+ <div class="job-meta">
6
+ <span class="job-timestamp">Queued at: <%= format_datetime(job.job.created_at) %></span>
7
+ </div>
8
+ </td>
9
+ <td><div class="job-queue"><%= queue_link(job.job.queue_name) %></div></td>
10
+ <td><div class="error-message"><%= truncate(failed_error_message(job.error), length: 100) %></div></td>
11
+ <td><%= format_arguments(job.job.arguments) %></td>
12
+ <td><%= format_datetime(job.created_at) %></td>
13
+ <td class="actions-cell">
14
+ <div class="job-actions">
15
+ <form method="post" action="<%= retry_failed_job_path(id: job.id) %>" class="inline-form">
16
+ <button type="submit" class="action-button retry-button">Retry</button>
17
+ </form>
18
+ <form method="post"
19
+ action="<%= discard_failed_job_path(id: job.id) %>"
20
+ class="inline-form"
21
+ data-confirm="Are you sure you want to discard this job?">
22
+ <button type="submit" class="action-button discard-button">Discard</button>
23
+ </form>
24
+ </div>
25
+ </td>
26
+ </tr>
@@ -0,0 +1,38 @@
1
+ <% content_for :title, 'Failed Jobs' %>
2
+
3
+ <%= render 'solid_queue_monitor/shared/filters',
4
+ action_path: @action_path,
5
+ filters: @filters,
6
+ show_status: false %>
7
+
8
+ <div class="bulk-actions-bar">
9
+ <button type="button" class="action-button retry-button" id="retry-selected-top" data-action-url="<%= retry_failed_jobs_path %>" disabled>
10
+ Retry Selected
11
+ </button>
12
+ <button type="button" class="action-button discard-button" id="discard-selected-top" data-action-url="<%= discard_failed_jobs_path %>" disabled>
13
+ Discard Selected
14
+ </button>
15
+ </div>
16
+
17
+ <form method="post" id="failed-jobs-form">
18
+ <% columns = [
19
+ { sort_key: nil, label: tag.input(type: 'checkbox', id: 'select-all', class: 'select-all-checkbox') },
20
+ { sort_key: :class_name, label: 'Job' },
21
+ { sort_key: :queue_name, label: 'Queue' },
22
+ { sort_key: nil, label: 'Error' },
23
+ { sort_key: nil, label: 'Arguments' },
24
+ { sort_key: :created_at, label: 'Failed At' },
25
+ { sort_key: nil, label: 'Actions' }
26
+ ] %>
27
+
28
+ <%= render 'solid_queue_monitor/shared/jobs_table',
29
+ jobs: @failed_jobs[:records],
30
+ columns: columns,
31
+ sort: @sort,
32
+ filters: @filters,
33
+ row_partial: 'solid_queue_monitor/failed_jobs/row' %>
34
+ </form>
35
+
36
+ <%= render 'solid_queue_monitor/shared/pagination',
37
+ current_page: @failed_jobs[:current_page],
38
+ total_pages: @failed_jobs[:total_pages] %>
@@ -0,0 +1,13 @@
1
+ <% solid_queue_job = job.job %>
2
+ <tr>
3
+ <td>
4
+ <div class="job-class"><%= link_to solid_queue_job.class_name, job_path(solid_queue_job), class: 'job-class-link' %></div>
5
+ <div class="job-meta">
6
+ <span class="job-timestamp">Queued at: <%= format_datetime(solid_queue_job.created_at) %></span>
7
+ </div>
8
+ </td>
9
+ <td><%= queue_link(solid_queue_job.queue_name) %></td>
10
+ <td><%= format_arguments(solid_queue_job.arguments) %></td>
11
+ <td><%= format_datetime(job.created_at) %></td>
12
+ <td><%= job.process_id %></td>
13
+ </tr>
@@ -0,0 +1,25 @@
1
+ <% content_for :title, 'In Progress Jobs' %>
2
+
3
+ <%= render 'solid_queue_monitor/shared/filters',
4
+ action_path: @action_path,
5
+ filters: @filters,
6
+ show_status: false %>
7
+
8
+ <% columns = [
9
+ { sort_key: :class_name, label: 'Job' },
10
+ { sort_key: :queue_name, label: 'Queue' },
11
+ { sort_key: nil, label: 'Arguments' },
12
+ { sort_key: :created_at, label: 'Started At' },
13
+ { sort_key: nil, label: 'Process ID' }
14
+ ] %>
15
+
16
+ <%= render 'solid_queue_monitor/shared/jobs_table',
17
+ jobs: @in_progress_jobs[:records],
18
+ columns: columns,
19
+ sort: @sort,
20
+ filters: @filters,
21
+ row_partial: 'solid_queue_monitor/in_progress_jobs/row' %>
22
+
23
+ <%= render 'solid_queue_monitor/shared/pagination',
24
+ current_page: @in_progress_jobs[:current_page],
25
+ total_pages: @in_progress_jobs[:total_pages] %>
@@ -0,0 +1,9 @@
1
+ <div class="job-section">
2
+ <div class="section-header">
3
+ <h3 class="section-title">Arguments</h3>
4
+ <div class="section-actions">
5
+ <button class="copy-button" data-action="copy" data-target="arguments-content">Copy</button>
6
+ </div>
7
+ </div>
8
+ <pre class="arguments-content" id="arguments-content"><%= pretty_arguments(@job.arguments) %></pre>
9
+ </div>
@@ -0,0 +1,26 @@
1
+ <% error = parsed_failed_error(@failed_execution.error) %>
2
+ <div class="job-section error-section">
3
+ <div class="section-header">
4
+ <h3 class="section-title">Error</h3>
5
+ <button class="copy-button" data-action="copy" data-target="error-content">Copy</button>
6
+ </div>
7
+ <div id="error-content">
8
+ <div class="error-type"><%= error[:type] %></div>
9
+ <div class="error-message-box"><%= error[:message] %></div>
10
+ </div>
11
+ <% if error[:backtrace].present? %>
12
+ <% lines = error[:backtrace] %>
13
+ <% app_lines = lines.select { |line| line.include?('/app/') || line.include?('/lib/') } %>
14
+ <div class="backtrace-section">
15
+ <div class="backtrace-header">
16
+ <span class="backtrace-title">Backtrace</span>
17
+ <div class="backtrace-toggle">
18
+ <button class="toggle-btn active" data-action="show-backtrace" data-backtrace="app">App Only</button>
19
+ <button class="toggle-btn" data-action="show-backtrace" data-backtrace="full">Full</button>
20
+ </div>
21
+ </div>
22
+ <pre class="backtrace-content" id="app-backtrace"><%= safe_join((app_lines.presence || lines.first(5)).map.with_index { |line, index| tag.span("#{index + 1}. #{line.to_s.strip}", class: 'backtrace-line') }, "\n") %></pre>
23
+ <pre class="backtrace-content is-hidden" id="full-backtrace"><%= safe_join(lines.map.with_index { |line, index| tag.span("#{index + 1}. #{line.to_s.strip}", class: 'backtrace-line') }, "\n") %></pre>
24
+ </div>
25
+ <% end %>
26
+ </div>
@@ -0,0 +1,25 @@
1
+ <% if @recent_executions.any? %>
2
+ <div class="job-section">
3
+ <div class="section-header">
4
+ <h3 class="section-title">Recent Executions</h3>
5
+ <span class="section-subtitle">Other <%= @job.class_name %> jobs</span>
6
+ </div>
7
+ <div class="table-container">
8
+ <table class="recent-executions-table">
9
+ <thead>
10
+ <tr><th>Status</th><th>Arguments</th><th>Created</th><th>Duration</th></tr>
11
+ </thead>
12
+ <tbody>
13
+ <% @recent_executions.each do |recent_job| %>
14
+ <tr>
15
+ <td><%= mini_job_status_badge(recent_job) %></td>
16
+ <td class="args-preview"><%= link_to truncate(recent_job.arguments.inspect, length: 60), job_path(recent_job) %></td>
17
+ <td><%= time_ago_in_words(recent_job.created_at) %> ago</td>
18
+ <td><%= recent_job_duration(recent_job) %></td>
19
+ </tr>
20
+ <% end %>
21
+ </tbody>
22
+ </table>
23
+ </div>
24
+ </div>
25
+ <% end %>
@@ -0,0 +1,37 @@
1
+ <% status = detail_job_status(job: @job, failed_execution: @failed_execution, claimed_execution: @claimed_execution, scheduled_execution: @scheduled_execution) %>
2
+ <div class="job-header">
3
+ <div class="job-header-main">
4
+ <h1 class="job-title"><%= @job.class_name %></h1>
5
+ <span class="job-status-badge <%= detail_status_class(status) %>"><%= detail_status_label(status) %></span>
6
+ </div>
7
+ <div class="job-header-meta">
8
+ <span class="job-queue"><%= queue_link(@job.queue_name) %></span>
9
+ <span class="job-separator">.</span>
10
+ <span class="job-priority">Priority <%= @job.priority %></span>
11
+ <span class="job-separator">.</span>
12
+ <span class="job-id">Job #<%= @job.id %></span>
13
+ </div>
14
+ <% if @failed_execution || @scheduled_execution %>
15
+ <div class="job-actions">
16
+ <% if @failed_execution %>
17
+ <form action="<%= retry_failed_job_path(id: @failed_execution.id) %>" method="post" class="inline-form">
18
+ <input type="hidden" name="redirect_to" value="<%= job_path(@job) %>">
19
+ <button type="submit" class="action-button retry-button">Retry</button>
20
+ </form>
21
+ <form action="<%= discard_failed_job_path(id: @failed_execution.id) %>"
22
+ method="post"
23
+ class="inline-form"
24
+ data-confirm="Are you sure you want to discard this job?">
25
+ <input type="hidden" name="redirect_to" value="<%= failed_jobs_path %>">
26
+ <button type="submit" class="action-button discard-button">Discard</button>
27
+ </form>
28
+ <% end %>
29
+ <% if @scheduled_execution %>
30
+ <form action="<%= execute_scheduled_job_path(id: @scheduled_execution.id) %>" method="post" class="inline-form">
31
+ <input type="hidden" name="redirect_to" value="<%= scheduled_jobs_path %>">
32
+ <button type="submit" class="action-button retry-button">Execute Now</button>
33
+ </form>
34
+ <% end %>
35
+ </div>
36
+ <% end %>
37
+ </div>
@@ -0,0 +1,22 @@
1
+ <div class="job-section">
2
+ <h3 class="section-title">Job Details</h3>
3
+ <div class="details-grid">
4
+ <div class="detail-row"><span class="detail-label">Class</span><span class="detail-value"><%= @job.class_name %></span></div>
5
+ <div class="detail-row"><span class="detail-label">Queue</span><span class="detail-value"><%= queue_link(@job.queue_name, css_class: 'queue-badge') %></span></div>
6
+ <div class="detail-row"><span class="detail-label">Priority</span><span class="detail-value"><%= @job.priority %></span></div>
7
+ <div class="detail-row"><span class="detail-label">Active Job ID</span><span class="detail-value detail-mono"><%= @job.active_job_id || '-' %></span></div>
8
+ <% if @job.concurrency_key.present? %>
9
+ <div class="detail-row"><span class="detail-label">Concurrency Key</span><span class="detail-value detail-mono"><%= @job.concurrency_key %></span></div>
10
+ <% end %>
11
+ <div class="detail-row"><span class="detail-label">Created At</span><span class="detail-value"><%= format_datetime(@job.created_at) %></span></div>
12
+ <% if @scheduled_execution || @job.scheduled_at %>
13
+ <div class="detail-row"><span class="detail-label">Scheduled At</span><span class="detail-value"><%= format_datetime(@job.scheduled_at || @scheduled_execution&.scheduled_at) %></span></div>
14
+ <% end %>
15
+ <% if @job.finished_at %>
16
+ <div class="detail-row"><span class="detail-label">Finished At</span><span class="detail-value"><%= format_datetime(@job.finished_at) %></span></div>
17
+ <% end %>
18
+ <% if @failed_execution %>
19
+ <div class="detail-row"><span class="detail-label">Failed At</span><span class="detail-value"><%= format_datetime(@failed_execution.created_at) %></span></div>
20
+ <% end %>
21
+ </div>
22
+ </div>
@@ -0,0 +1,11 @@
1
+ <div class="job-section collapsible-section">
2
+ <div class="section-header collapsible-header" data-action="toggle-section">
3
+ <div class="collapsible-title">
4
+ <h3 class="section-title">Raw Data</h3>
5
+ </div>
6
+ <button class="copy-button" data-action="copy" data-target="raw-data-content" data-stop-propagation="true">Copy</button>
7
+ </div>
8
+ <div class="collapsible-content">
9
+ <pre class="raw-data-content" id="raw-data-content"><%= JSON.pretty_generate(@job.attributes) %></pre>
10
+ </div>
11
+ </div>
@@ -0,0 +1,29 @@
1
+ <% events = [] %>
2
+ <% status = detail_job_status(job: @job, failed_execution: @failed_execution, claimed_execution: @claimed_execution, scheduled_execution: @scheduled_execution) %>
3
+ <% events << { label: 'Created', time: @job.created_at, status: :done } if @job.created_at %>
4
+ <% scheduled_at = @job.scheduled_at || @scheduled_execution&.scheduled_at %>
5
+ <% events << { label: 'Scheduled', time: scheduled_at, status: :done } if scheduled_at && scheduled_at != @job.created_at %>
6
+ <% events << { label: 'Started', time: @claimed_execution.created_at, status: :done } if @claimed_execution %>
7
+ <% events << { label: 'Completed', time: @job.finished_at, status: :success } if status == :completed %>
8
+ <% events << { label: 'Failed', time: @failed_execution.created_at, status: :failed } if status == :failed %>
9
+ <% events << { label: 'Running...', time: nil, status: :active } if status == :in_progress %>
10
+
11
+ <% if events.size >= 2 %>
12
+ <div class="job-section">
13
+ <h3 class="section-title">Timeline</h3>
14
+ <div class="job-timeline">
15
+ <div class="timeline-track">
16
+ <% events.each_with_index do |event, index| %>
17
+ <div class="timeline-event timeline-<%= event[:status] %>">
18
+ <div class="timeline-dot"></div>
19
+ <%= tag.div(class: 'timeline-line') unless index == events.size - 1 %>
20
+ <div class="timeline-content">
21
+ <div class="timeline-label"><%= event[:label] %></div>
22
+ <div class="timeline-time"><%= format_datetime(event[:time]) unless event[:time].nil? %></div>
23
+ </div>
24
+ </div>
25
+ <% end %>
26
+ </div>
27
+ </div>
28
+ </div>
29
+ <% end %>
@@ -0,0 +1,9 @@
1
+ <% timing = detail_timing(job: @job, claimed_execution: @claimed_execution, failed_execution: @failed_execution) %>
2
+ <div class="timing-cards">
3
+ <% { 'Queue Wait' => timing[:queue_wait], 'Execution' => timing[:execution], 'Total Time' => timing[:total] }.each do |label, duration| %>
4
+ <div class="timing-card">
5
+ <div class="timing-value"><%= detail_duration(duration) %></div>
6
+ <div class="timing-label"><%= label %></div>
7
+ </div>
8
+ <% end %>
9
+ </div>