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.
- checksums.yaml +4 -4
- data/README.md +36 -1
- data/app/assets/javascripts/solid_queue_monitor/application.js +393 -0
- data/app/{services/solid_queue_monitor/stylesheet_generator.rb → assets/stylesheets/solid_queue_monitor/application.css} +80 -12
- data/app/controllers/solid_queue_monitor/application_controller.rb +2 -2
- data/app/controllers/solid_queue_monitor/assets_controller.rb +52 -0
- data/app/controllers/solid_queue_monitor/base_controller.rb +0 -28
- data/app/controllers/solid_queue_monitor/failed_jobs_controller.rb +3 -6
- data/app/controllers/solid_queue_monitor/in_progress_jobs_controller.rb +3 -6
- data/app/controllers/solid_queue_monitor/jobs_controller.rb +3 -6
- data/app/controllers/solid_queue_monitor/overview_controller.rb +3 -12
- data/app/controllers/solid_queue_monitor/queues_controller.rb +4 -18
- data/app/controllers/solid_queue_monitor/ready_jobs_controller.rb +3 -6
- data/app/controllers/solid_queue_monitor/recurring_jobs_controller.rb +3 -6
- data/app/controllers/solid_queue_monitor/scheduled_jobs_controller.rb +3 -6
- data/app/controllers/solid_queue_monitor/search_controller.rb +3 -4
- data/app/controllers/solid_queue_monitor/workers_controller.rb +24 -8
- data/app/helpers/solid_queue_monitor/application_helper.rb +46 -0
- data/app/helpers/solid_queue_monitor/chart_helper.rb +293 -0
- data/app/helpers/solid_queue_monitor/job_details_helper.rb +66 -0
- data/app/helpers/solid_queue_monitor/jobs_helper.rb +134 -0
- data/app/helpers/solid_queue_monitor/pagination_helper.rb +23 -0
- data/app/helpers/solid_queue_monitor/sort_helper.rb +30 -0
- data/app/helpers/solid_queue_monitor/workers_helper.rb +88 -0
- data/app/services/solid_queue_monitor/asset_cache.rb +56 -0
- data/app/services/solid_queue_monitor/chart_data_service.rb +2 -2
- data/app/views/layouts/solid_queue_monitor/application.html.erb +25 -0
- data/app/views/solid_queue_monitor/failed_jobs/_row.html.erb +26 -0
- data/app/views/solid_queue_monitor/failed_jobs/index.html.erb +38 -0
- data/app/views/solid_queue_monitor/in_progress_jobs/_row.html.erb +13 -0
- data/app/views/solid_queue_monitor/in_progress_jobs/index.html.erb +25 -0
- data/app/views/solid_queue_monitor/jobs/_arguments.html.erb +9 -0
- data/app/views/solid_queue_monitor/jobs/_error.html.erb +26 -0
- data/app/views/solid_queue_monitor/jobs/_execution_history.html.erb +25 -0
- data/app/views/solid_queue_monitor/jobs/_header.html.erb +37 -0
- data/app/views/solid_queue_monitor/jobs/_metadata.html.erb +22 -0
- data/app/views/solid_queue_monitor/jobs/_raw_data.html.erb +11 -0
- data/app/views/solid_queue_monitor/jobs/_timeline.html.erb +29 -0
- data/app/views/solid_queue_monitor/jobs/_timing.html.erb +9 -0
- data/app/views/solid_queue_monitor/jobs/_worker.html.erb +12 -0
- data/app/views/solid_queue_monitor/jobs/show.html.erb +22 -0
- data/app/views/solid_queue_monitor/overview/_chart.html.erb +1 -0
- data/app/views/solid_queue_monitor/overview/_recent_job_row.html.erb +26 -0
- data/app/views/solid_queue_monitor/overview/_recent_jobs.html.erb +31 -0
- data/app/views/solid_queue_monitor/overview/_stat_card.html.erb +4 -0
- data/app/views/solid_queue_monitor/overview/_stats.html.erb +11 -0
- data/app/views/solid_queue_monitor/overview/index.html.erb +9 -0
- data/app/views/solid_queue_monitor/queues/_job_row.html.erb +26 -0
- data/app/views/solid_queue_monitor/queues/_row.html.erb +33 -0
- data/app/views/solid_queue_monitor/queues/index.html.erb +18 -0
- data/app/views/solid_queue_monitor/queues/show.html.erb +63 -0
- data/app/views/solid_queue_monitor/ready_jobs/_row.html.erb +7 -0
- data/app/views/solid_queue_monitor/ready_jobs/index.html.erb +25 -0
- data/app/views/solid_queue_monitor/recurring_jobs/_row.html.erb +8 -0
- data/app/views/solid_queue_monitor/recurring_jobs/index.html.erb +26 -0
- data/app/views/solid_queue_monitor/scheduled_jobs/_row.html.erb +7 -0
- data/app/views/solid_queue_monitor/scheduled_jobs/index.html.erb +36 -0
- data/app/views/solid_queue_monitor/search/_completed_row.html.erb +6 -0
- data/app/views/solid_queue_monitor/search/_failed_row.html.erb +6 -0
- data/app/views/solid_queue_monitor/search/_job_row.html.erb +9 -0
- data/app/views/solid_queue_monitor/search/_recurring_row.html.erb +6 -0
- data/app/views/solid_queue_monitor/search/_section.html.erb +25 -0
- data/app/views/solid_queue_monitor/search/index.html.erb +23 -0
- data/app/views/solid_queue_monitor/shared/_filters.html.erb +48 -0
- data/app/views/solid_queue_monitor/shared/_flash.html.erb +17 -0
- data/app/views/solid_queue_monitor/shared/_footer.html.erb +3 -0
- data/app/views/solid_queue_monitor/shared/_header.html.erb +81 -0
- data/app/views/solid_queue_monitor/shared/_jobs_table.html.erb +20 -0
- data/app/views/solid_queue_monitor/shared/_pagination.html.erb +25 -0
- data/app/views/solid_queue_monitor/workers/_row.html.erb +22 -0
- data/app/views/solid_queue_monitor/workers/index.html.erb +82 -0
- data/config/routes.rb +6 -1
- data/lib/solid_queue_monitor/engine.rb +2 -0
- data/lib/solid_queue_monitor/version.rb +1 -1
- metadata +57 -17
- data/app/presenters/solid_queue_monitor/base_presenter.rb +0 -211
- data/app/presenters/solid_queue_monitor/failed_jobs_presenter.rb +0 -312
- data/app/presenters/solid_queue_monitor/in_progress_jobs_presenter.rb +0 -84
- data/app/presenters/solid_queue_monitor/job_details_presenter.rb +0 -696
- data/app/presenters/solid_queue_monitor/jobs_presenter.rb +0 -144
- data/app/presenters/solid_queue_monitor/queue_details_presenter.rb +0 -195
- data/app/presenters/solid_queue_monitor/queues_presenter.rb +0 -89
- data/app/presenters/solid_queue_monitor/ready_jobs_presenter.rb +0 -81
- data/app/presenters/solid_queue_monitor/recurring_jobs_presenter.rb +0 -81
- data/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb +0 -173
- data/app/presenters/solid_queue_monitor/search_results_presenter.rb +0 -190
- data/app/presenters/solid_queue_monitor/stats_presenter.rb +0 -36
- data/app/presenters/solid_queue_monitor/workers_presenter.rb +0 -320
- data/app/services/solid_queue_monitor/chart_presenter.rb +0 -239
- 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 ' ⇅'.html_safe unless active
|
|
26
|
+
|
|
27
|
+
direction == 'asc' ? ' ↑'.html_safe : ' ↓'.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
|
-
"
|
|
89
|
+
"FLOOR((UNIX_TIMESTAMP(#{column}) - #{start_epoch}) / #{interval_seconds})"
|
|
90
90
|
else
|
|
91
|
-
"
|
|
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>
|