Skip to content

Commit 2eb48e3

Browse files
committed
feat: add global search functionality
Add unified search feature that allows users to search across all job types from a single search box in the header. Features: - Search box in header (accessible from any page) - Searches across Ready, Scheduled, Failed, In Progress, Completed jobs - Also searches Recurring Tasks - Searchable fields: class name, queue name, arguments, job ID, error messages - Case-insensitive search with SQL injection protection - Results grouped by category with counts - Clickable header title links to overview page New files: - SearchService: Core search logic - SearchController: Handles /search route - SearchResultsPresenter: Renders grouped results - Comprehensive test coverage (53 new tests)
1 parent 5ef738d commit 2eb48e3

10 files changed

Lines changed: 1064 additions & 4 deletions

File tree

app/controllers/solid_queue_monitor/base_controller.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ def paginate(relation)
66
PaginationService.new(relation, current_page, per_page).paginate
77
end
88

9-
def render_page(title, content)
9+
def render_page(title, content, search_query: nil)
1010
# Get flash message from instance variable (set by set_flash_message) or session
1111
message = @flash_message
1212
message_type = @flash_type
@@ -27,7 +27,8 @@ def render_page(title, content)
2727
title: title,
2828
content: content,
2929
message: message,
30-
message_type: message_type
30+
message_type: message_type,
31+
search_query: search_query
3132
).generate
3233

3334
render html: html.html_safe
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# frozen_string_literal: true
2+
3+
module SolidQueueMonitor
4+
class SearchController < BaseController
5+
def index
6+
query = params[:q]
7+
results = SearchService.new(query).search
8+
9+
render_page('Search', SearchResultsPresenter.new(query, results).render, search_query: query)
10+
end
11+
end
12+
end
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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)
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 { |item| yield(item) }.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('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;').gsub('"', '&quot;')
188+
end
189+
end
190+
end

app/services/solid_queue_monitor/html_generator.rb

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ class HtmlGenerator
55
include Rails.application.routes.url_helpers
66
include SolidQueueMonitor::Engine.routes.url_helpers
77

8-
def initialize(title:, content:, message: nil, message_type: nil)
8+
def initialize(title:, content:, message: nil, message_type: nil, search_query: nil)
99
@title = title
1010
@content = content
1111
@message = message
1212
@message_type = message_type
13+
@search_query = search_query
1314
end
1415

1516
def generate
@@ -107,7 +108,8 @@ def generate_header
107108
<<-HTML
108109
<header>
109110
<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}
111113
<div class="header-controls">
112114
#{generate_auto_refresh_controls}
113115
#{generate_theme_toggle}
@@ -128,6 +130,25 @@ def generate_footer
128130
HTML
129131
end
130132

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('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;').gsub('"', '&quot;')
150+
end
151+
131152
def generate_auto_refresh_controls
132153
return '' unless SolidQueueMonitor.auto_refresh_enabled
133154

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

0 commit comments

Comments
 (0)