solid_queue_monitor 1.0.1 → 1.2.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 +16 -1
- data/app/controllers/solid_queue_monitor/base_controller.rb +49 -33
- data/app/controllers/solid_queue_monitor/failed_jobs_controller.rb +7 -3
- data/app/controllers/solid_queue_monitor/in_progress_jobs_controller.rb +9 -7
- data/app/controllers/solid_queue_monitor/overview_controller.rb +13 -9
- data/app/controllers/solid_queue_monitor/queues_controller.rb +39 -16
- data/app/controllers/solid_queue_monitor/ready_jobs_controller.rb +7 -3
- data/app/controllers/solid_queue_monitor/recurring_jobs_controller.rb +7 -3
- data/app/controllers/solid_queue_monitor/scheduled_jobs_controller.rb +7 -3
- data/app/controllers/solid_queue_monitor/search_controller.rb +12 -0
- data/app/controllers/solid_queue_monitor/workers_controller.rb +7 -4
- data/app/presenters/solid_queue_monitor/base_presenter.rb +47 -5
- data/app/presenters/solid_queue_monitor/failed_jobs_presenter.rb +6 -6
- data/app/presenters/solid_queue_monitor/in_progress_jobs_presenter.rb +5 -4
- data/app/presenters/solid_queue_monitor/jobs_presenter.rb +5 -4
- data/app/presenters/solid_queue_monitor/queue_details_presenter.rb +4 -3
- data/app/presenters/solid_queue_monitor/queues_presenter.rb +10 -22
- data/app/presenters/solid_queue_monitor/ready_jobs_presenter.rb +6 -5
- data/app/presenters/solid_queue_monitor/recurring_jobs_presenter.rb +6 -5
- data/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb +5 -4
- data/app/presenters/solid_queue_monitor/search_results_presenter.rb +190 -0
- data/app/presenters/solid_queue_monitor/stats_presenter.rb +1 -2
- data/app/presenters/solid_queue_monitor/workers_presenter.rb +4 -3
- data/app/services/solid_queue_monitor/chart_data_service.rb +53 -57
- data/app/services/solid_queue_monitor/html_generator.rb +23 -2
- data/app/services/solid_queue_monitor/search_service.rb +126 -0
- data/app/services/solid_queue_monitor/stats_calculator.rb +12 -8
- data/app/services/solid_queue_monitor/stylesheet_generator.rb +118 -0
- data/config/routes.rb +1 -0
- data/lib/generators/solid_queue_monitor/templates/initializer.rb +3 -0
- data/lib/solid_queue_monitor/version.rb +1 -1
- data/lib/solid_queue_monitor.rb +2 -1
- metadata +5 -2
|
@@ -5,11 +5,12 @@ module SolidQueueMonitor
|
|
|
5
5
|
include Rails.application.routes.url_helpers
|
|
6
6
|
include SolidQueueMonitor::Engine.routes.url_helpers
|
|
7
7
|
|
|
8
|
-
def initialize(jobs, current_page: 1, total_pages: 1, filters: {})
|
|
8
|
+
def initialize(jobs, current_page: 1, total_pages: 1, filters: {}, sort: {})
|
|
9
9
|
@jobs = jobs
|
|
10
10
|
@current_page = current_page
|
|
11
11
|
@total_pages = total_pages
|
|
12
12
|
@filters = filters
|
|
13
|
+
@sort = sort
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def render
|
|
@@ -73,11 +74,11 @@ module SolidQueueMonitor
|
|
|
73
74
|
<thead>
|
|
74
75
|
<tr>
|
|
75
76
|
<th>ID</th>
|
|
76
|
-
|
|
77
|
-
|
|
77
|
+
#{sortable_header('class_name', 'Job')}
|
|
78
|
+
#{sortable_header('queue_name', 'Queue')}
|
|
78
79
|
<th>Arguments</th>
|
|
79
80
|
<th>Status</th>
|
|
80
|
-
|
|
81
|
+
#{sortable_header('created_at', 'Created At')}
|
|
81
82
|
<th>Actions</th>
|
|
82
83
|
</tr>
|
|
83
84
|
</thead>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module SolidQueueMonitor
|
|
4
4
|
class QueueDetailsPresenter < BasePresenter
|
|
5
|
-
def initialize(queue_name:, paused:, jobs:, counts:, current_page: 1, total_pages: 1, filters: {})
|
|
5
|
+
def initialize(queue_name:, paused:, jobs:, counts:, current_page: 1, total_pages: 1, filters: {}, sort: {})
|
|
6
6
|
@queue_name = queue_name
|
|
7
7
|
@paused = paused
|
|
8
8
|
@jobs = jobs
|
|
@@ -10,6 +10,7 @@ module SolidQueueMonitor
|
|
|
10
10
|
@current_page = current_page
|
|
11
11
|
@total_pages = total_pages
|
|
12
12
|
@filters = filters
|
|
13
|
+
@sort = sort
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def render
|
|
@@ -129,10 +130,10 @@ module SolidQueueMonitor
|
|
|
129
130
|
<thead>
|
|
130
131
|
<tr>
|
|
131
132
|
<th>ID</th>
|
|
132
|
-
|
|
133
|
+
#{sortable_header('class_name', 'Job')}
|
|
133
134
|
<th>Arguments</th>
|
|
134
135
|
<th>Status</th>
|
|
135
|
-
|
|
136
|
+
#{sortable_header('created_at', 'Created At')}
|
|
136
137
|
<th>Actions</th>
|
|
137
138
|
</tr>
|
|
138
139
|
</thead>
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module SolidQueueMonitor
|
|
4
4
|
class QueuesPresenter < BasePresenter
|
|
5
|
-
def initialize(records, paused_queues = [])
|
|
6
|
-
@records
|
|
5
|
+
def initialize(records, paused_queues = [], sort: {}, queue_stats: {})
|
|
6
|
+
@records = records
|
|
7
7
|
@paused_queues = paused_queues
|
|
8
|
+
@sort = sort
|
|
9
|
+
@queue_stats = queue_stats
|
|
8
10
|
end
|
|
9
11
|
|
|
10
12
|
def render
|
|
@@ -19,9 +21,9 @@ module SolidQueueMonitor
|
|
|
19
21
|
<table>
|
|
20
22
|
<thead>
|
|
21
23
|
<tr>
|
|
22
|
-
|
|
24
|
+
#{sortable_header('queue_name', 'Queue Name')}
|
|
23
25
|
<th>Status</th>
|
|
24
|
-
|
|
26
|
+
#{sortable_header('job_count', 'Total Jobs')}
|
|
25
27
|
<th>Ready Jobs</th>
|
|
26
28
|
<th>Scheduled Jobs</th>
|
|
27
29
|
<th>Failed Jobs</th>
|
|
@@ -38,16 +40,16 @@ module SolidQueueMonitor
|
|
|
38
40
|
|
|
39
41
|
def generate_row(queue)
|
|
40
42
|
queue_name = queue.queue_name || 'default'
|
|
41
|
-
paused
|
|
43
|
+
paused = @paused_queues.include?(queue_name)
|
|
42
44
|
|
|
43
45
|
<<-HTML
|
|
44
46
|
<tr class="#{paused ? 'queue-paused' : ''}">
|
|
45
47
|
<td>#{queue_link(queue_name)}</td>
|
|
46
48
|
<td>#{status_badge(paused)}</td>
|
|
47
49
|
<td>#{queue.job_count}</td>
|
|
48
|
-
<td>#{
|
|
49
|
-
<td>#{
|
|
50
|
-
<td>#{
|
|
50
|
+
<td>#{@queue_stats.dig(:ready, queue_name) || 0}</td>
|
|
51
|
+
<td>#{@queue_stats.dig(:scheduled, queue_name) || 0}</td>
|
|
52
|
+
<td>#{@queue_stats.dig(:failed, queue_name) || 0}</td>
|
|
51
53
|
<td class="actions-cell">#{action_button(queue_name, paused)}</td>
|
|
52
54
|
</tr>
|
|
53
55
|
HTML
|
|
@@ -83,19 +85,5 @@ module SolidQueueMonitor
|
|
|
83
85
|
HTML
|
|
84
86
|
end
|
|
85
87
|
end
|
|
86
|
-
|
|
87
|
-
def ready_jobs_count(queue_name)
|
|
88
|
-
SolidQueue::ReadyExecution.where(queue_name: queue_name).count
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def scheduled_jobs_count(queue_name)
|
|
92
|
-
SolidQueue::ScheduledExecution.where(queue_name: queue_name).count
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def failed_jobs_count(queue_name)
|
|
96
|
-
SolidQueue::FailedExecution.joins(:job)
|
|
97
|
-
.where(solid_queue_jobs: { queue_name: queue_name })
|
|
98
|
-
.count
|
|
99
|
-
end
|
|
100
88
|
end
|
|
101
89
|
end
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module SolidQueueMonitor
|
|
4
4
|
class ReadyJobsPresenter < BasePresenter
|
|
5
|
-
def initialize(jobs, current_page: 1, total_pages: 1, filters: {})
|
|
5
|
+
def initialize(jobs, current_page: 1, total_pages: 1, filters: {}, sort: {})
|
|
6
6
|
@jobs = jobs
|
|
7
7
|
@current_page = current_page
|
|
8
8
|
@total_pages = total_pages
|
|
9
9
|
@filters = filters
|
|
10
|
+
@sort = sort
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
def render
|
|
@@ -50,11 +51,11 @@ module SolidQueueMonitor
|
|
|
50
51
|
<table>
|
|
51
52
|
<thead>
|
|
52
53
|
<tr>
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
#{sortable_header('class_name', 'Job')}
|
|
55
|
+
#{sortable_header('queue_name', 'Queue')}
|
|
56
|
+
#{sortable_header('priority', 'Priority')}
|
|
56
57
|
<th>Arguments</th>
|
|
57
|
-
|
|
58
|
+
#{sortable_header('created_at', 'Created At')}
|
|
58
59
|
</tr>
|
|
59
60
|
</thead>
|
|
60
61
|
<tbody>
|
|
@@ -5,11 +5,12 @@ module SolidQueueMonitor
|
|
|
5
5
|
include Rails.application.routes.url_helpers
|
|
6
6
|
include SolidQueueMonitor::Engine.routes.url_helpers
|
|
7
7
|
|
|
8
|
-
def initialize(jobs, current_page: 1, total_pages: 1, filters: {})
|
|
8
|
+
def initialize(jobs, current_page: 1, total_pages: 1, filters: {}, sort: {})
|
|
9
9
|
@jobs = jobs
|
|
10
10
|
@current_page = current_page
|
|
11
11
|
@total_pages = total_pages
|
|
12
12
|
@filters = filters
|
|
13
|
+
@sort = sort
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def render
|
|
@@ -48,11 +49,11 @@ module SolidQueueMonitor
|
|
|
48
49
|
<table>
|
|
49
50
|
<thead>
|
|
50
51
|
<tr>
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
#{sortable_header('key', 'Key')}
|
|
53
|
+
#{sortable_header('class_name', 'Job')}
|
|
53
54
|
<th>Schedule</th>
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
#{sortable_header('queue_name', 'Queue')}
|
|
56
|
+
#{sortable_header('priority', 'Priority')}
|
|
56
57
|
<th>Last Updated</th>
|
|
57
58
|
</tr>
|
|
58
59
|
</thead>
|
|
@@ -5,11 +5,12 @@ module SolidQueueMonitor
|
|
|
5
5
|
include Rails.application.routes.url_helpers
|
|
6
6
|
include SolidQueueMonitor::Engine.routes.url_helpers
|
|
7
7
|
|
|
8
|
-
def initialize(jobs, current_page: 1, total_pages: 1, filters: {})
|
|
8
|
+
def initialize(jobs, current_page: 1, total_pages: 1, filters: {}, sort: {})
|
|
9
9
|
@jobs = jobs
|
|
10
10
|
@current_page = current_page
|
|
11
11
|
@total_pages = total_pages
|
|
12
12
|
@filters = filters
|
|
13
|
+
@sort = sort
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def render
|
|
@@ -140,9 +141,9 @@ module SolidQueueMonitor
|
|
|
140
141
|
<thead>
|
|
141
142
|
<tr>
|
|
142
143
|
<th width="50"><input type="checkbox"></th>
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
#{sortable_header('class_name', 'Job')}
|
|
145
|
+
#{sortable_header('queue_name', 'Queue')}
|
|
146
|
+
#{sortable_header('scheduled_at', 'Scheduled At')}
|
|
146
147
|
<th>Arguments</th>
|
|
147
148
|
</tr>
|
|
148
149
|
</thead>
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidQueueMonitor
|
|
4
|
+
class SearchResultsPresenter < BasePresenter
|
|
5
|
+
def initialize(query, results)
|
|
6
|
+
@query = query
|
|
7
|
+
@results = results
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def render
|
|
11
|
+
section_wrapper('Search Results', generate_content)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def generate_content
|
|
17
|
+
if @query.blank?
|
|
18
|
+
generate_empty_query_message
|
|
19
|
+
elsif total_count.zero?
|
|
20
|
+
generate_no_results_message
|
|
21
|
+
else
|
|
22
|
+
generate_results_summary + generate_all_sections
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def generate_empty_query_message
|
|
27
|
+
<<-HTML
|
|
28
|
+
<div class="empty-state">
|
|
29
|
+
<p>Enter a search term in the header to find jobs across all categories.</p>
|
|
30
|
+
</div>
|
|
31
|
+
HTML
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def generate_no_results_message
|
|
35
|
+
<<-HTML
|
|
36
|
+
<div class="empty-state">
|
|
37
|
+
<p>No results found for "#{escape_html(@query)}"</p>
|
|
38
|
+
<p class="results-summary">0 results</p>
|
|
39
|
+
</div>
|
|
40
|
+
HTML
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def generate_results_summary
|
|
44
|
+
<<-HTML
|
|
45
|
+
<div class="results-summary">
|
|
46
|
+
<p>Found #{total_count} #{total_count == 1 ? 'result' : 'results'} for "#{escape_html(@query)}"</p>
|
|
47
|
+
</div>
|
|
48
|
+
HTML
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def generate_all_sections
|
|
52
|
+
sections = []
|
|
53
|
+
sections << generate_ready_section if @results[:ready].any?
|
|
54
|
+
sections << generate_scheduled_section if @results[:scheduled].any?
|
|
55
|
+
sections << generate_failed_section if @results[:failed].any?
|
|
56
|
+
sections << generate_in_progress_section if @results[:in_progress].any?
|
|
57
|
+
sections << generate_completed_section if @results[:completed].any?
|
|
58
|
+
sections << generate_recurring_section if @results[:recurring].any?
|
|
59
|
+
sections.join
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def generate_ready_section
|
|
63
|
+
generate_section('Ready Jobs', @results[:ready]) do |execution|
|
|
64
|
+
generate_job_row(execution.job, execution.queue_name, execution.created_at)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def generate_scheduled_section
|
|
69
|
+
generate_section('Scheduled Jobs', @results[:scheduled]) do |execution|
|
|
70
|
+
generate_job_row(execution.job, execution.queue_name, execution.scheduled_at, 'Scheduled for')
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def generate_failed_section
|
|
75
|
+
generate_section('Failed Jobs', @results[:failed]) do |execution|
|
|
76
|
+
generate_failed_row(execution)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def generate_in_progress_section
|
|
81
|
+
generate_section('In Progress Jobs', @results[:in_progress]) do |execution|
|
|
82
|
+
generate_job_row(execution.job, execution.job.queue_name, execution.created_at, 'Started at')
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def generate_completed_section
|
|
87
|
+
generate_section('Completed Jobs', @results[:completed]) do |job|
|
|
88
|
+
generate_completed_row(job)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def generate_recurring_section
|
|
93
|
+
generate_section('Recurring Tasks', @results[:recurring]) do |task|
|
|
94
|
+
generate_recurring_row(task)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def generate_section(title, items, &block)
|
|
99
|
+
<<-HTML
|
|
100
|
+
<div class="search-results-section">
|
|
101
|
+
<h3>#{title} (#{items.size})</h3>
|
|
102
|
+
<div class="table-container">
|
|
103
|
+
<table>
|
|
104
|
+
<thead>
|
|
105
|
+
<tr>
|
|
106
|
+
#{section_headers(title)}
|
|
107
|
+
</tr>
|
|
108
|
+
</thead>
|
|
109
|
+
<tbody>
|
|
110
|
+
#{items.map(&block).join}
|
|
111
|
+
</tbody>
|
|
112
|
+
</table>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
HTML
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def section_headers(title)
|
|
119
|
+
case title
|
|
120
|
+
when 'Recurring Tasks'
|
|
121
|
+
'<th>Key</th><th>Class</th><th>Schedule</th><th>Queue</th>'
|
|
122
|
+
when 'Failed Jobs'
|
|
123
|
+
'<th>Job</th><th>Queue</th><th>Error</th><th>Failed At</th>'
|
|
124
|
+
when 'Completed Jobs'
|
|
125
|
+
'<th>Job</th><th>Queue</th><th>Arguments</th><th>Completed At</th>'
|
|
126
|
+
else
|
|
127
|
+
'<th>Job</th><th>Queue</th><th>Arguments</th><th>Time</th>'
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def generate_job_row(job, queue_name, time, time_label = 'Created at')
|
|
132
|
+
<<-HTML
|
|
133
|
+
<tr>
|
|
134
|
+
<td><a href="#{job_path(job)}" class="job-class-link">#{job.class_name}</a></td>
|
|
135
|
+
<td>#{queue_link(queue_name)}</td>
|
|
136
|
+
<td>#{format_arguments(job.arguments)}</td>
|
|
137
|
+
<td>
|
|
138
|
+
<span class="job-timestamp">#{time_label}: #{format_datetime(time)}</span>
|
|
139
|
+
</td>
|
|
140
|
+
</tr>
|
|
141
|
+
HTML
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def generate_failed_row(execution)
|
|
145
|
+
job = execution.job
|
|
146
|
+
<<-HTML
|
|
147
|
+
<tr>
|
|
148
|
+
<td><a href="#{job_path(job)}" class="job-class-link">#{job.class_name}</a></td>
|
|
149
|
+
<td>#{queue_link(job.queue_name)}</td>
|
|
150
|
+
<td><div class="error-message">#{escape_html(execution.error.to_s.truncate(100))}</div></td>
|
|
151
|
+
<td>
|
|
152
|
+
<span class="job-timestamp">#{format_datetime(execution.created_at)}</span>
|
|
153
|
+
</td>
|
|
154
|
+
</tr>
|
|
155
|
+
HTML
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def generate_completed_row(job)
|
|
159
|
+
<<-HTML
|
|
160
|
+
<tr>
|
|
161
|
+
<td><a href="#{job_path(job)}" class="job-class-link">#{job.class_name}</a></td>
|
|
162
|
+
<td>#{queue_link(job.queue_name)}</td>
|
|
163
|
+
<td>#{format_arguments(job.arguments)}</td>
|
|
164
|
+
<td>
|
|
165
|
+
<span class="job-timestamp">#{format_datetime(job.finished_at)}</span>
|
|
166
|
+
</td>
|
|
167
|
+
</tr>
|
|
168
|
+
HTML
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def generate_recurring_row(task)
|
|
172
|
+
<<-HTML
|
|
173
|
+
<tr>
|
|
174
|
+
<td><strong>#{task.key}</strong></td>
|
|
175
|
+
<td>#{task.class_name || '-'}</td>
|
|
176
|
+
<td><code>#{task.schedule}</code></td>
|
|
177
|
+
<td>#{queue_link(task.queue_name)}</td>
|
|
178
|
+
</tr>
|
|
179
|
+
HTML
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def total_count
|
|
183
|
+
@total_count ||= @results.values.sum(&:size)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def escape_html(text)
|
|
187
|
+
text.to_s.gsub('&', '&').gsub('<', '<').gsub('>', '>').gsub('"', '"')
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -11,13 +11,12 @@ module SolidQueueMonitor
|
|
|
11
11
|
<div class="stats-container">
|
|
12
12
|
<h3>Queue Statistics</h3>
|
|
13
13
|
<div class="stats">
|
|
14
|
-
#{generate_stat_card('
|
|
14
|
+
#{generate_stat_card('Active Jobs', @stats[:active_jobs])}
|
|
15
15
|
#{generate_stat_card('Ready', @stats[:ready])}
|
|
16
16
|
#{generate_stat_card('In Progress', @stats[:in_progress])}
|
|
17
17
|
#{generate_stat_card('Scheduled', @stats[:scheduled])}
|
|
18
18
|
#{generate_stat_card('Recurring', @stats[:recurring])}
|
|
19
19
|
#{generate_stat_card('Failed', @stats[:failed])}
|
|
20
|
-
#{generate_stat_card('Completed', @stats[:completed])}
|
|
21
20
|
</div>
|
|
22
21
|
</div>
|
|
23
22
|
HTML
|
|
@@ -5,11 +5,12 @@ module SolidQueueMonitor
|
|
|
5
5
|
HEARTBEAT_STALE_THRESHOLD = 5.minutes
|
|
6
6
|
HEARTBEAT_DEAD_THRESHOLD = 10.minutes
|
|
7
7
|
|
|
8
|
-
def initialize(processes, current_page: 1, total_pages: 1, filters: {})
|
|
8
|
+
def initialize(processes, current_page: 1, total_pages: 1, filters: {}, sort: {})
|
|
9
9
|
@processes = processes.to_a # Load records once to avoid multiple queries
|
|
10
10
|
@current_page = current_page
|
|
11
11
|
@total_pages = total_pages
|
|
12
12
|
@filters = filters
|
|
13
|
+
@sort = sort
|
|
13
14
|
preload_claimed_data
|
|
14
15
|
calculate_summary_stats
|
|
15
16
|
end
|
|
@@ -140,10 +141,10 @@ module SolidQueueMonitor
|
|
|
140
141
|
<thead>
|
|
141
142
|
<tr>
|
|
142
143
|
<th>Kind</th>
|
|
143
|
-
|
|
144
|
+
#{sortable_header('hostname', 'Hostname')}
|
|
144
145
|
<th>PID</th>
|
|
145
146
|
<th>Queues</th>
|
|
146
|
-
|
|
147
|
+
#{sortable_header('last_heartbeat_at', 'Last Heartbeat')}
|
|
147
148
|
<th>Status</th>
|
|
148
149
|
<th>Jobs Processing</th>
|
|
149
150
|
<th>Actions</th>
|
|
@@ -5,47 +5,42 @@ module SolidQueueMonitor
|
|
|
5
5
|
TIME_RANGES = {
|
|
6
6
|
'15m' => { duration: 15.minutes, buckets: 15, label_format: '%H:%M', label: 'Last 15 minutes' },
|
|
7
7
|
'30m' => { duration: 30.minutes, buckets: 15, label_format: '%H:%M', label: 'Last 30 minutes' },
|
|
8
|
-
'1h' => { duration: 1.hour,
|
|
9
|
-
'3h' => { duration: 3.hours,
|
|
10
|
-
'6h' => { duration: 6.hours,
|
|
8
|
+
'1h' => { duration: 1.hour, buckets: 12, label_format: '%H:%M', label: 'Last 1 hour' },
|
|
9
|
+
'3h' => { duration: 3.hours, buckets: 18, label_format: '%H:%M', label: 'Last 3 hours' },
|
|
10
|
+
'6h' => { duration: 6.hours, buckets: 24, label_format: '%H:%M', label: 'Last 6 hours' },
|
|
11
11
|
'12h' => { duration: 12.hours, buckets: 24, label_format: '%H:%M', label: 'Last 12 hours' },
|
|
12
|
-
'1d' => { duration: 1.day,
|
|
13
|
-
'3d' => { duration: 3.days,
|
|
14
|
-
'1w' => { duration: 7.days,
|
|
12
|
+
'1d' => { duration: 1.day, buckets: 24, label_format: '%H:%M', label: 'Last 24 hours' },
|
|
13
|
+
'3d' => { duration: 3.days, buckets: 36, label_format: '%m/%d %H:%M', label: 'Last 3 days' },
|
|
14
|
+
'1w' => { duration: 7.days, buckets: 28, label_format: '%m/%d', label: 'Last 7 days' }
|
|
15
15
|
}.freeze
|
|
16
16
|
|
|
17
17
|
DEFAULT_TIME_RANGE = '1d'
|
|
18
18
|
|
|
19
19
|
def initialize(time_range: DEFAULT_TIME_RANGE)
|
|
20
20
|
@time_range = TIME_RANGES.key?(time_range) ? time_range : DEFAULT_TIME_RANGE
|
|
21
|
-
@config
|
|
21
|
+
@config = TIME_RANGES[@time_range]
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def calculate
|
|
25
|
-
end_time
|
|
26
|
-
start_time
|
|
27
|
-
|
|
25
|
+
end_time = Time.current
|
|
26
|
+
start_time = end_time - @config[:duration]
|
|
27
|
+
bucket_seconds = (@config[:duration] / @config[:buckets]).to_i
|
|
28
|
+
buckets = build_buckets(start_time, bucket_seconds)
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
created_data = bucket_counts(SolidQueue::Job, :created_at, start_time, end_time, bucket_seconds)
|
|
31
|
+
completed_data = bucket_counts(SolidQueue::Job, :finished_at, start_time, end_time, bucket_seconds, exclude_nil: true)
|
|
32
|
+
failed_data = bucket_counts(SolidQueue::FailedExecution, :created_at, start_time, end_time, bucket_seconds)
|
|
30
33
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
created_data = assign_to_buckets(created_counts, buckets, bucket_duration)
|
|
36
|
-
completed_data = assign_to_buckets(completed_counts, buckets, bucket_duration)
|
|
37
|
-
failed_data = assign_to_buckets(failed_counts, buckets, bucket_duration)
|
|
34
|
+
created_arr = fill_buckets(buckets, created_data)
|
|
35
|
+
completed_arr = fill_buckets(buckets, completed_data)
|
|
36
|
+
failed_arr = fill_buckets(buckets, failed_data)
|
|
38
37
|
|
|
39
38
|
{
|
|
40
39
|
labels: buckets.map { |b| b[:label] }, # rubocop:disable Rails/Pluck
|
|
41
|
-
created:
|
|
42
|
-
completed:
|
|
43
|
-
failed:
|
|
44
|
-
totals: {
|
|
45
|
-
created: created_data.sum,
|
|
46
|
-
completed: completed_data.sum,
|
|
47
|
-
failed: failed_data.sum
|
|
48
|
-
},
|
|
40
|
+
created: created_arr,
|
|
41
|
+
completed: completed_arr,
|
|
42
|
+
failed: failed_arr,
|
|
43
|
+
totals: { created: created_arr.sum, completed: completed_arr.sum, failed: failed_arr.sum },
|
|
49
44
|
time_range: @time_range,
|
|
50
45
|
time_range_label: @config[:label],
|
|
51
46
|
available_ranges: TIME_RANGES.transform_values { |v| v[:label] }
|
|
@@ -54,47 +49,48 @@ module SolidQueueMonitor
|
|
|
54
49
|
|
|
55
50
|
private
|
|
56
51
|
|
|
57
|
-
def build_buckets(start_time,
|
|
52
|
+
def build_buckets(start_time, bucket_seconds)
|
|
58
53
|
@config[:buckets].times.map do |i|
|
|
59
|
-
bucket_start = start_time + (i *
|
|
60
|
-
{
|
|
61
|
-
start: bucket_start,
|
|
62
|
-
end: bucket_start + bucket_duration,
|
|
63
|
-
label: bucket_start.strftime(@config[:label_format])
|
|
64
|
-
}
|
|
54
|
+
bucket_start = start_time + (i * bucket_seconds)
|
|
55
|
+
{ index: i, start: bucket_start, label: bucket_start.strftime(@config[:label_format]) }
|
|
65
56
|
end
|
|
66
57
|
end
|
|
67
58
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
59
|
+
# Returns a Hash of { bucket_index => count } using SQL GROUP BY.
|
|
60
|
+
# The bucket index is computed as: (epoch(column) - epoch(start_time)) / interval
|
|
61
|
+
# This works identically on PostgreSQL and SQLite.
|
|
62
|
+
def bucket_counts(model, column, start_time, end_time, interval, exclude_nil: false)
|
|
63
|
+
start_epoch = start_time.to_i
|
|
64
|
+
expr = bucket_index_expr(column, start_epoch, interval)
|
|
65
|
+
|
|
66
|
+
scope = model.where(column => start_time..end_time)
|
|
67
|
+
scope = scope.where.not(column => nil) if exclude_nil
|
|
68
|
+
|
|
69
|
+
# rubocop:disable Style/HashTransformKeys -- pluck returns Array<Array>, not Hash
|
|
70
|
+
scope
|
|
71
|
+
.group(Arel.sql(expr))
|
|
72
|
+
.pluck(Arel.sql("#{expr} AS bucket_idx, COUNT(*) AS cnt"))
|
|
73
|
+
.to_h { |idx, cnt| [idx.to_i, cnt] }
|
|
74
|
+
# rubocop:enable Style/HashTransformKeys
|
|
79
75
|
end
|
|
80
76
|
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
.where(created_at: start_time..end_time)
|
|
84
|
-
.pluck(:created_at)
|
|
77
|
+
def fill_buckets(buckets, index_counts)
|
|
78
|
+
buckets.map { |b| index_counts.fetch(b[:index], 0) }
|
|
85
79
|
end
|
|
86
80
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
81
|
+
# Cross-DB bucket index expression.
|
|
82
|
+
# PostgreSQL: CAST((EXTRACT(EPOCH FROM col) - start) / interval AS INTEGER)
|
|
83
|
+
# SQLite: CAST((CAST(strftime('%s', col) AS INTEGER) - start) / interval AS INTEGER)
|
|
84
|
+
def bucket_index_expr(column, start_epoch, interval_seconds)
|
|
85
|
+
if sqlite?
|
|
86
|
+
"CAST((CAST(strftime('%s', #{column}) AS INTEGER) - #{start_epoch}) / #{interval_seconds} AS INTEGER)"
|
|
87
|
+
else
|
|
88
|
+
"CAST((EXTRACT(EPOCH FROM #{column}) - #{start_epoch}) / #{interval_seconds} AS INTEGER)"
|
|
95
89
|
end
|
|
90
|
+
end
|
|
96
91
|
|
|
97
|
-
|
|
92
|
+
def sqlite?
|
|
93
|
+
ActiveRecord::Base.connection.adapter_name.downcase.include?('sqlite')
|
|
98
94
|
end
|
|
99
95
|
end
|
|
100
96
|
end
|
|
@@ -5,11 +5,12 @@ module SolidQueueMonitor
|
|
|
5
5
|
include Rails.application.routes.url_helpers
|
|
6
6
|
include SolidQueueMonitor::Engine.routes.url_helpers
|
|
7
7
|
|
|
8
|
-
def initialize(title:, content:, message: nil, message_type: nil)
|
|
8
|
+
def initialize(title:, content:, message: nil, message_type: nil, search_query: nil)
|
|
9
9
|
@title = title
|
|
10
10
|
@content = content
|
|
11
11
|
@message = message
|
|
12
12
|
@message_type = message_type
|
|
13
|
+
@search_query = search_query
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def generate
|
|
@@ -107,7 +108,8 @@ module SolidQueueMonitor
|
|
|
107
108
|
<<-HTML
|
|
108
109
|
<header>
|
|
109
110
|
<div class="header-top">
|
|
110
|
-
<h1>Solid Queue Monitor</h1>
|
|
111
|
+
<h1><a href="#{root_path}" class="header-title-link">Solid Queue Monitor</a></h1>
|
|
112
|
+
#{generate_search_box}
|
|
111
113
|
<div class="header-controls">
|
|
112
114
|
#{generate_auto_refresh_controls}
|
|
113
115
|
#{generate_theme_toggle}
|
|
@@ -128,6 +130,25 @@ module SolidQueueMonitor
|
|
|
128
130
|
HTML
|
|
129
131
|
end
|
|
130
132
|
|
|
133
|
+
def generate_search_box
|
|
134
|
+
search_value = @search_query ? escape_html(@search_query) : ''
|
|
135
|
+
<<-HTML
|
|
136
|
+
<form method="get" action="#{search_path}" class="header-search-form">
|
|
137
|
+
<input type="text" name="q" value="#{search_value}" placeholder="Search by class, queue, job ID, or error..." class="header-search-input">
|
|
138
|
+
<button type="submit" class="header-search-button" title="Search">
|
|
139
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
140
|
+
<circle cx="11" cy="11" r="8"></circle>
|
|
141
|
+
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
|
142
|
+
</svg>
|
|
143
|
+
</button>
|
|
144
|
+
</form>
|
|
145
|
+
HTML
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def escape_html(text)
|
|
149
|
+
text.to_s.gsub('&', '&').gsub('<', '<').gsub('>', '>').gsub('"', '"')
|
|
150
|
+
end
|
|
151
|
+
|
|
131
152
|
def generate_auto_refresh_controls
|
|
132
153
|
return '' unless SolidQueueMonitor.auto_refresh_enabled
|
|
133
154
|
|