diff --git a/app/assets/stylesheets/solid_queue_web/application.css b/app/assets/stylesheets/solid_queue_web/application.css index 5a06bea..e6fa75f 100644 --- a/app/assets/stylesheets/solid_queue_web/application.css +++ b/app/assets/stylesheets/solid_queue_web/application.css @@ -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; diff --git a/app/controllers/solid_queue_web/search_controller.rb b/app/controllers/solid_queue_web/search_controller.rb new file mode 100644 index 0000000..5b3d46e --- /dev/null +++ b/app/controllers/solid_queue_web/search_controller.rb @@ -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 diff --git a/app/javascript/solid_queue_web/search_controller.js b/app/javascript/solid_queue_web/search_controller.js index 69a7a52..59de917 100644 --- a/app/javascript/solid_queue_web/search_controller.js +++ b/app/javascript/solid_queue_web/search_controller.js @@ -8,4 +8,9 @@ export default class extends Controller { this._timer = setTimeout(() => target.form.requestSubmit(), 300) } } + + select({ target }) { + clearTimeout(this._timer) + target.form.requestSubmit() + } } \ No newline at end of file diff --git a/app/views/layouts/solid_queue_web/application.html.erb b/app/views/layouts/solid_queue_web/application.html.erb index 59b29ff..f7ad321 100644 --- a/app/views/layouts/solid_queue_web/application.html.erb +++ b/app/views/layouts/solid_queue_web/application.html.erb @@ -29,6 +29,7 @@
  • <%= link_to "Failed", failed_jobs_path, class: current_page?(failed_jobs_path) ? "active" : "", aria: { current: current_page?(failed_jobs_path) ? "page" : nil } %>
  • <%= link_to "Recurring", recurring_tasks_path, class: current_page?(recurring_tasks_path) ? "active" : "", aria: { current: current_page?(recurring_tasks_path) ? "page" : nil } %>
  • <%= link_to "Processes", processes_path, class: current_page?(processes_path) ? "active" : "", aria: { current: current_page?(processes_path) ? "page" : nil } %>
  • +
  • <%= link_to "Search", search_path, class: current_page?(search_path) ? "active" : "", aria: { current: current_page?(search_path) ? "page" : nil } %>
  • diff --git a/app/views/solid_queue_web/search/index.html.erb b/app/views/solid_queue_web/search/index.html.erb new file mode 100644 index 0000000..b02a616 --- /dev/null +++ b/app/views/solid_queue_web/search/index.html.erb @@ -0,0 +1,64 @@ +

    Search Jobs

    + + + <% @job_classes.each do |klass| %> + + + + +<% if @query.present? %> + <% if @results.empty? %> +
    No jobs found matching “<%= @query %>”.
    + <% else %> + <% @results.each do |status, data| %> +
    +
    + <%= status %> + + <%= pluralize(data[:total], "match", "matches") %> + <% if data[:total] > SolidQueueWeb::SearchController::LIMIT %> + — showing first <%= SolidQueueWeb::SearchController::LIMIT %> + <% end %> + + <% 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 %> +
    +
    + + + + + + + + + + <% data[:executions].each do |execution| %> + <% job = execution.job %> + + + + + + <% end %> + +
    Job ClassQueueEnqueued At
    <%= link_to job.class_name, job_path(job) %><%= job.queue_name %><%= job.created_at.strftime("%Y-%m-%d %H:%M:%S") %>
    +
    +
    + <% end %> + <% end %> +<% end %> \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 72dc845..4ec1be0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/spec/requests/solid_queue_web/search_spec.rb b/spec/requests/solid_queue_web/search_spec.rb new file mode 100644 index 0000000..dd8195c --- /dev/null +++ b/spec/requests/solid_queue_web/search_spec.rb @@ -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") + 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