Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions app/assets/stylesheets/solid_queue_web/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,29 @@ tbody tr:hover { background: var(--bg); }
.sqd-search__input { width: 100%; }
}

.sqd-search--global { margin-bottom: 2rem; }

.sqd-search__input--lg {
width: 420px;
font-size: 15px;
padding: 0.5rem 1rem;
}

@media (max-width: 640px) {
.sqd-search__input--lg { width: 100%; }
}

.sqd-search-group {
margin-bottom: 2rem;
}

.sqd-search-group__header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}

/* Filters */
.sqd-filters {
display: flex;
Expand Down
23 changes: 23 additions & 0 deletions app/controllers/solid_queue_web/search_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
module SolidQueueWeb
class SearchController < ApplicationController
LIMIT = 25

def index
@query = params[:q].presence
@job_classes = SolidQueue::Job.distinct.order(:class_name).pluck(:class_name)
@results = {}

return unless @query

Job::EXECUTION_MODELS.each do |status, model|
scope = model.includes(:job)
.references(:job)
.where("solid_queue_jobs.class_name LIKE ?", "%#{@query}%")
.order(created_at: :desc)
total = scope.count
executions = scope.limit(LIMIT).to_a
@results[status] = { executions: executions, total: total } unless executions.empty?
end
end
end
end
5 changes: 5 additions & 0 deletions app/javascript/solid_queue_web/search_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,9 @@ export default class extends Controller {
this._timer = setTimeout(() => target.form.requestSubmit(), 300)
}
}

select({ target }) {
clearTimeout(this._timer)
target.form.requestSubmit()
}
}
1 change: 1 addition & 0 deletions app/views/layouts/solid_queue_web/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
<li><%= link_to "Failed", failed_jobs_path, class: current_page?(failed_jobs_path) ? "active" : "", aria: { current: current_page?(failed_jobs_path) ? "page" : nil } %></li>
<li><%= link_to "Recurring", recurring_tasks_path, class: current_page?(recurring_tasks_path) ? "active" : "", aria: { current: current_page?(recurring_tasks_path) ? "page" : nil } %></li>
<li><%= link_to "Processes", processes_path, class: current_page?(processes_path) ? "active" : "", aria: { current: current_page?(processes_path) ? "page" : nil } %></li>
<li><%= link_to "Search", search_path, class: current_page?(search_path) ? "active" : "", aria: { current: current_page?(search_path) ? "page" : nil } %></li>
</ul>
</nav>
</div>
Expand Down
64 changes: 64 additions & 0 deletions app/views/solid_queue_web/search/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<h1 class="sqd-page-title" style="margin-bottom: 1.5rem;">Search Jobs</h1>

<datalist id="job-class-list">
<% @job_classes.each do |klass| %>
<option value="<%= klass %>">
<% end %>
</datalist>

<form class="sqd-search sqd-search--global" action="<%= search_path %>" method="get" data-controller="search">
<input class="sqd-search__input sqd-search__input--lg" type="search" name="q"
value="<%= @query %>" placeholder="Search by job class name…"
list="job-class-list" autocomplete="off"
aria-label="Search all jobs by class name"
data-action="change->search#select">
<% if @query.present? %>
<%= link_to "Clear", search_path, class: "sqd-btn sqd-btn--muted" %>
<% end %>
</form>

<% if @query.present? %>
<% if @results.empty? %>
<div class="sqd-empty" style="margin-top: 1rem;">No jobs found matching &ldquo;<%= @query %>&rdquo;.</div>
<% else %>
<% @results.each do |status, data| %>
<div class="sqd-search-group">
<div class="sqd-search-group__header">
<span class="sqd-badge sqd-badge--<%= status %>"><%= status %></span>
<span class="sqd-muted-text">
<%= pluralize(data[:total], "match", "matches") %>
<% if data[:total] > SolidQueueWeb::SearchController::LIMIT %>
&mdash; showing first <%= SolidQueueWeb::SearchController::LIMIT %>
<% end %>
</span>
<% if status == "failed" %>
<%= link_to "View all →", failed_jobs_path(q: @query), class: "sqd-btn sqd-btn--muted sqd-btn--sm" %>
<% else %>
<%= link_to "View all →", jobs_path(status: status, q: @query), class: "sqd-btn sqd-btn--muted sqd-btn--sm" %>
<% end %>
</div>
<div class="sqd-card">
<table>
<thead>
<tr>
<th scope="col">Job Class</th>
<th scope="col">Queue</th>
<th scope="col">Enqueued At</th>
</tr>
</thead>
<tbody>
<% data[:executions].each do |execution| %>
<% job = execution.job %>
<tr>
<td><%= link_to job.class_name, job_path(job) %></td>
<td class="sqd-mono"><%= job.queue_name %></td>
<td class="sqd-mono"><%= job.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
<% end %>
<% end %>
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
SolidQueueWeb::Engine.routes.draw do
root to: "dashboard#index"

get "search", to: "search#index", as: :search

resources :recurring_tasks, only: [ :index ]
resources :processes, only: [ :index ]
resources :queues, only: [ :index ], param: :name do
Expand Down
102 changes: 102 additions & 0 deletions spec/requests/solid_queue_web/search_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
require "rails_helper"

RSpec.describe "Search", type: :request do
let!(:ready_job) do
SolidQueue::Job.create!(
queue_name: "default",
class_name: "MyWorkerJob",
arguments: {},
active_job_id: SecureRandom.uuid
)
end

let!(:failed_job) do
j = SolidQueue::Job.create!(
queue_name: "mailers",
class_name: "MyMailerJob",
arguments: {},
active_job_id: SecureRandom.uuid
)
j.ready_execution&.destroy
SolidQueue::FailedExecution.create!(
job: j,
error: { exception_class: "RuntimeError", message: "boom", backtrace: [] }
)
j
end

describe "GET /jobs/search" do
it "returns HTTP success" do
get "/jobs/search"
expect(response).to have_http_status(:ok)
end

it "renders the search form" do
get "/jobs/search"
expect(response.body).to include("Search by job class name")
end

it "renders a datalist with known job class names" do
get "/jobs/search"
expect(response.body).to include("job-class-list")
expect(response.body).to include("MyWorkerJob")
expect(response.body).to include("MyMailerJob")
end

it "shows no results section when query is blank" do
get "/jobs/search"
expect(response.body).not_to include('class="sqd-search-group"')
end
end

describe "GET /jobs/search?q=" do
it "returns matches across statuses" do
get "/jobs/search", params: { q: "My" }
expect(response.body).to include("MyWorkerJob")
expect(response.body).to include("MyMailerJob")
end

it "groups results by status" do
get "/jobs/search", params: { q: "My" }
expect(response.body).to include("ready")
expect(response.body).to include("failed")
end

it "is case-insensitive" do
get "/jobs/search", params: { q: "myworker" }
expect(response.body).to include("MyWorkerJob")
end

it "shows only matching jobs" do
get "/jobs/search", params: { q: "Worker" }
expect(response.body).to include("MyWorkerJob")
# MyMailerJob appears in the datalist but must not appear in a result row
expect(response.body).not_to include("MyMailerJob</a>")
end

it "shows empty state when nothing matches" do
get "/jobs/search", params: { q: "NoSuchJob" }
expect(response.body).to include("No jobs found")
end

it "links to the filtered jobs index for non-failed statuses" do
get "/jobs/search", params: { q: "Worker" }
expect(response.body).to include("status=ready")
end

it "links to the failed jobs page for failed results" do
get "/jobs/search", params: { q: "Mailer" }
expect(response.body).to include("failed_jobs")
end

it "links each job class name to its detail page" do
get "/jobs/search", params: { q: "Worker" }
expect(response.body).to include("/jobs/list/#{ready_job.id}")
end

it "renders a clear link when search is active" do
get "/jobs/search", params: { q: "Worker" }
expect(response.body).to include("Clear")
end
end
end
Loading