solid_queue_monitor 1.0.0 → 1.1.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 +3 -1
- data/app/controllers/solid_queue_monitor/base_controller.rb +34 -2
- data/app/controllers/solid_queue_monitor/failed_jobs_controller.rb +7 -3
- data/app/controllers/solid_queue_monitor/in_progress_jobs_controller.rb +7 -3
- data/app/controllers/solid_queue_monitor/overview_controller.rb +7 -3
- data/app/controllers/solid_queue_monitor/queues_controller.rb +21 -8
- 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 +4 -3
- 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/workers_presenter.rb +4 -3
- 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/stylesheet_generator.rb +614 -0
- data/config/routes.rb +1 -0
- data/lib/solid_queue_monitor/version.rb +1 -1
- metadata +5 -2
|
@@ -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
|
|
@@ -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,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
|
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidQueueMonitor
|
|
4
|
+
class SearchService
|
|
5
|
+
RESULTS_LIMIT = 25
|
|
6
|
+
|
|
7
|
+
def initialize(query)
|
|
8
|
+
@query = query
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def search
|
|
12
|
+
return empty_results if @query.blank?
|
|
13
|
+
|
|
14
|
+
term = "%#{sanitize_query(@query)}%"
|
|
15
|
+
|
|
16
|
+
{
|
|
17
|
+
ready: search_ready_jobs(term),
|
|
18
|
+
scheduled: search_scheduled_jobs(term),
|
|
19
|
+
failed: search_failed_jobs(term),
|
|
20
|
+
in_progress: search_in_progress_jobs(term),
|
|
21
|
+
completed: search_completed_jobs(term),
|
|
22
|
+
recurring: search_recurring_tasks(term)
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def empty_results
|
|
29
|
+
{
|
|
30
|
+
ready: [],
|
|
31
|
+
scheduled: [],
|
|
32
|
+
failed: [],
|
|
33
|
+
in_progress: [],
|
|
34
|
+
completed: [],
|
|
35
|
+
recurring: []
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def sanitize_query(query)
|
|
40
|
+
# Escape % to prevent LIKE pattern injection
|
|
41
|
+
# We don't escape _ because it requires database-specific ESCAPE clauses
|
|
42
|
+
query.to_s.gsub('%', '\%')
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def search_ready_jobs(term)
|
|
46
|
+
SolidQueue::ReadyExecution
|
|
47
|
+
.joins(:job)
|
|
48
|
+
.where(job_search_conditions, term: term)
|
|
49
|
+
.includes(:job)
|
|
50
|
+
.limit(RESULTS_LIMIT)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def search_scheduled_jobs(term)
|
|
54
|
+
SolidQueue::ScheduledExecution
|
|
55
|
+
.joins(:job)
|
|
56
|
+
.where(job_search_conditions, term: term)
|
|
57
|
+
.includes(:job)
|
|
58
|
+
.limit(RESULTS_LIMIT)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def search_failed_jobs(term)
|
|
62
|
+
SolidQueue::FailedExecution
|
|
63
|
+
.joins(:job)
|
|
64
|
+
.where(failed_job_search_conditions, term: term)
|
|
65
|
+
.includes(:job)
|
|
66
|
+
.limit(RESULTS_LIMIT)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def search_in_progress_jobs(term)
|
|
70
|
+
SolidQueue::ClaimedExecution
|
|
71
|
+
.joins(:job)
|
|
72
|
+
.where(job_search_conditions, term: term)
|
|
73
|
+
.includes(:job)
|
|
74
|
+
.limit(RESULTS_LIMIT)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def search_completed_jobs(term)
|
|
78
|
+
SolidQueue::Job
|
|
79
|
+
.where.not(finished_at: nil)
|
|
80
|
+
.where(completed_job_search_conditions, term: term)
|
|
81
|
+
.order(finished_at: :desc)
|
|
82
|
+
.limit(RESULTS_LIMIT)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def search_recurring_tasks(term)
|
|
86
|
+
SolidQueue::RecurringTask
|
|
87
|
+
.where(recurring_task_search_conditions, term: term)
|
|
88
|
+
.limit(RESULTS_LIMIT)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def job_search_conditions
|
|
92
|
+
<<~SQL.squish
|
|
93
|
+
solid_queue_jobs.class_name LIKE :term
|
|
94
|
+
OR solid_queue_jobs.queue_name LIKE :term
|
|
95
|
+
OR solid_queue_jobs.arguments LIKE :term
|
|
96
|
+
OR solid_queue_jobs.active_job_id LIKE :term
|
|
97
|
+
SQL
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def failed_job_search_conditions
|
|
101
|
+
<<~SQL.squish
|
|
102
|
+
solid_queue_jobs.class_name LIKE :term
|
|
103
|
+
OR solid_queue_jobs.queue_name LIKE :term
|
|
104
|
+
OR solid_queue_jobs.arguments LIKE :term
|
|
105
|
+
OR solid_queue_jobs.active_job_id LIKE :term
|
|
106
|
+
OR solid_queue_failed_executions.error LIKE :term
|
|
107
|
+
SQL
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def completed_job_search_conditions
|
|
111
|
+
<<~SQL.squish
|
|
112
|
+
class_name LIKE :term
|
|
113
|
+
OR queue_name LIKE :term
|
|
114
|
+
OR arguments LIKE :term
|
|
115
|
+
OR active_job_id LIKE :term
|
|
116
|
+
SQL
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def recurring_task_search_conditions
|
|
120
|
+
<<~SQL.squish
|
|
121
|
+
solid_queue_recurring_tasks.key LIKE :term
|
|
122
|
+
OR solid_queue_recurring_tasks.class_name LIKE :term
|
|
123
|
+
SQL
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|