diff --git a/Gemfile b/Gemfile index 8a3070c..f5c7f7c 100644 --- a/Gemfile +++ b/Gemfile @@ -4,6 +4,7 @@ source "https://rubygems.org" gemspec gem "puma" +gem "propshaft" gem "sqlite3" diff --git a/Gemfile.lock b/Gemfile.lock index de9d966..92c4383 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,6 +2,7 @@ PATH remote: . specs: solid_queue_web (0.4.0) + importmap-rails (>= 1.2) pagy (>= 43.0) rails (>= 8.1.3) solid_queue (>= 1.0) @@ -106,6 +107,10 @@ GEM activesupport (>= 6.1) i18n (1.14.8) concurrent-ruby (~> 1.0) + importmap-rails (2.2.3) + actionpack (>= 6.0.0) + activesupport (>= 6.0.0) + railties (>= 6.0.0) io-console (0.8.2) irb (1.18.0) pp (>= 0.6.0) @@ -156,6 +161,10 @@ GEM prettyprint prettyprint (0.2.0) prism (1.9.0) + propshaft (1.3.2) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack psych (5.3.1) date stringio @@ -301,6 +310,7 @@ PLATFORMS x86_64-linux DEPENDENCIES + propshaft puma rspec-rails rubocop-rails-omakase @@ -339,6 +349,7 @@ CHECKSUMS fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68 globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 + importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 json (2.19.5) sha256=218a18553e4801d579ca7e0f5bc72bafd776d7397238a1fb4e74db5b0a812c59 @@ -363,6 +374,7 @@ CHECKSUMS pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974 puma (8.0.1) sha256=7b94e50c07655718c1fb8ae41a11fc06c7d61293208b3aa608ff71a46d3ad37c raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882 diff --git a/app/assets/stylesheets/solid_queue_web/application.css b/app/assets/stylesheets/solid_queue_web/application.css index 78fca7e..24a7cd3 100644 --- a/app/assets/stylesheets/solid_queue_web/application.css +++ b/app/assets/stylesheets/solid_queue_web/application.css @@ -302,6 +302,36 @@ tbody tr:hover { background: var(--bg); } .sqd-row-actions { white-space: nowrap; text-align: right; width: 1%; } .sqd-row-actions form { display: inline; margin-left: 0.25rem; } +/* Search */ +.sqd-search { + display: flex; + gap: 0.5rem; + align-items: center; + margin-bottom: 1rem; +} + +.sqd-search__input { + width: 280px; + padding: 0.35rem 0.75rem; + border: 1px solid var(--border); + border-radius: 5px; + font-size: 13px; + background: var(--surface); + color: var(--text); + line-height: 1.5; +} + +.sqd-search__input:focus { + outline: 2px solid var(--primary); + outline-offset: -1px; + border-color: var(--primary); +} + +@media (max-width: 640px) { + .sqd-search { flex-wrap: wrap; } + .sqd-search__input { width: 100%; } +} + /* Filters */ .sqd-filters { display: flex; diff --git a/app/controllers/solid_queue_web/jobs_controller.rb b/app/controllers/solid_queue_web/jobs_controller.rb index aea74c4..c6d8669 100644 --- a/app/controllers/solid_queue_web/jobs_controller.rb +++ b/app/controllers/solid_queue_web/jobs_controller.rb @@ -1,10 +1,9 @@ module SolidQueueWeb class JobsController < ApplicationController - STATUSES = %w[ready scheduled claimed blocked failed].freeze - DISCARDABLE = %w[ready scheduled blocked].freeze - before_action :set_status_and_queue, only: [ :destroy, :discard_all ] + STATUSES = %w[ready scheduled claimed blocked failed].freeze + DISCARDABLE = %w[ready scheduled blocked].freeze EXECUTION_MODELS = { "ready" => SolidQueue::ReadyExecution, "scheduled" => SolidQueue::ScheduledExecution, @@ -16,8 +15,10 @@ class JobsController < ApplicationController def index @status = params[:status].presence_in(STATUSES) || "ready" @queue = params[:queue].presence + @search = params[:q].presence @jobs = EXECUTION_MODELS[@status].includes(:job) @jobs = @jobs.where(jobs: { queue_name: @queue }) if @queue.present? + @jobs = @jobs.references(:job).where("solid_queue_jobs.class_name LIKE ?", "%#{@search}%") if @search.present? @pagy, @jobs = pagy(@jobs.order(created_at: :desc)) end diff --git a/app/javascript/solid_queue_web/application.js b/app/javascript/solid_queue_web/application.js new file mode 100644 index 0000000..ad1349d --- /dev/null +++ b/app/javascript/solid_queue_web/application.js @@ -0,0 +1,6 @@ +import "@hotwired/turbo" +import { Application } from "@hotwired/stimulus" +import SearchController from "solid_queue_web/search_controller" + +const application = Application.start() +application.register("search", SearchController) diff --git a/app/javascript/solid_queue_web/search_controller.js b/app/javascript/solid_queue_web/search_controller.js new file mode 100644 index 0000000..69a7a52 --- /dev/null +++ b/app/javascript/solid_queue_web/search_controller.js @@ -0,0 +1,11 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + filter({ target }) { + clearTimeout(this._timer) + const len = target.value.length + if (len >= 4 || len === 0) { + this._timer = setTimeout(() => target.form.requestSubmit(), 300) + } + } +} \ 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 fdefcb9..59b29ff 100644 --- a/app/views/layouts/solid_queue_web/application.html.erb +++ b/app/views/layouts/solid_queue_web/application.html.erb @@ -7,6 +7,7 @@ <%= csrf_meta_tags %> <%= csp_meta_tag %> <%= inline_styles %> + <%= javascript_importmap_tags "solid_queue_web" %> diff --git a/app/views/solid_queue_web/jobs/index.html.erb b/app/views/solid_queue_web/jobs/index.html.erb index f7f6089..f647347 100644 --- a/app/views/solid_queue_web/jobs/index.html.erb +++ b/app/views/solid_queue_web/jobs/index.html.erb @@ -1,15 +1,15 @@

Jobs

-<%= turbo_frame_tag "jobs-table" do %> +<%= turbo_frame_tag "jobs-table", data: { turbo_action: "advance" } do %> <% discardable = SolidQueueWeb::JobsController::DISCARDABLE.include?(@status) %>
- <%= link_to "Ready", jobs_path(status: "ready", queue: @queue), class: @status == "ready" ? "active" : "" %> - <%= link_to "Scheduled", jobs_path(status: "scheduled", queue: @queue), class: @status == "scheduled" ? "active" : "" %> - <%= link_to "Running", jobs_path(status: "claimed", queue: @queue), class: @status == "claimed" ? "active" : "" %> - <%= link_to "Blocked", jobs_path(status: "blocked", queue: @queue), class: @status == "blocked" ? "active" : "" %> - <%= link_to "Failed", jobs_path(status: "failed", queue: @queue), class: @status == "failed" ? "active" : "" %> + <%= link_to "Ready", jobs_path(status: "ready", queue: @queue, q: @search), class: @status == "ready" ? "active" : "" %> + <%= link_to "Scheduled", jobs_path(status: "scheduled", queue: @queue, q: @search), class: @status == "scheduled" ? "active" : "" %> + <%= link_to "Running", jobs_path(status: "claimed", queue: @queue, q: @search), class: @status == "claimed" ? "active" : "" %> + <%= link_to "Blocked", jobs_path(status: "blocked", queue: @queue, q: @search), class: @status == "blocked" ? "active" : "" %> + <%= link_to "Failed", jobs_path(status: "failed", queue: @queue, q: @search), class: @status == "failed" ? "active" : "" %>
<% if discardable && @jobs.any? %>
@@ -22,6 +22,20 @@ <% end %>
+ +
<% if @jobs.empty? %>
No <%= @status %> jobs.
diff --git a/config/importmap.rb b/config/importmap.rb new file mode 100644 index 0000000..75bc253 --- /dev/null +++ b/config/importmap.rb @@ -0,0 +1,2 @@ +pin "solid_queue_web", to: "solid_queue_web/application.js" +pin "solid_queue_web/search_controller", to: "solid_queue_web/search_controller.js" diff --git a/lib/solid_queue_web.rb b/lib/solid_queue_web.rb index 7e46fc8..ab32d15 100644 --- a/lib/solid_queue_web.rb +++ b/lib/solid_queue_web.rb @@ -1,4 +1,5 @@ require "solid_queue_web/version" +require "importmap-rails" require "solid_queue_web/engine" module SolidQueueWeb diff --git a/lib/solid_queue_web/engine.rb b/lib/solid_queue_web/engine.rb index fbe79cc..28e9b63 100644 --- a/lib/solid_queue_web/engine.rb +++ b/lib/solid_queue_web/engine.rb @@ -9,6 +9,19 @@ class Engine < ::Rails::Engine config.i18n.load_path += Gem.find_files("pagy/locales/en.yml") + initializer "solid_queue_web.assets" do |app| + if app.config.respond_to?(:assets) + app.config.assets.paths << root.join("app/javascript") + end + end + + initializer "solid_queue_web.importmap", before: "importmap" do |app| + if app.config.respond_to?(:importmap) + app.config.importmap.paths << root.join("config/importmap.rb") + app.config.importmap.cache_sweepers << root.join("app/javascript") + end + end + initializer "solid_queue_web.pagy" do Pagy::OPTIONS[:limit] = 25 end diff --git a/solid_queue_web.gemspec b/solid_queue_web.gemspec index 2da1d34..b089ef0 100644 --- a/solid_queue_web.gemspec +++ b/solid_queue_web.gemspec @@ -27,4 +27,5 @@ Gem::Specification.new do |spec| spec.add_dependency "solid_queue", ">= 1.0" spec.add_dependency "pagy", ">= 43.0" spec.add_dependency "turbo-rails", ">= 2.0" + spec.add_dependency "importmap-rails", ">= 1.2" end diff --git a/spec/dummy/config/importmap.rb b/spec/dummy/config/importmap.rb new file mode 100644 index 0000000..256d812 --- /dev/null +++ b/spec/dummy/config/importmap.rb @@ -0,0 +1,2 @@ +pin "@hotwired/turbo", to: "https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.23/dist/turbo.es2017-esm.js" +pin "@hotwired/stimulus", to: "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.js" diff --git a/spec/requests/solid_queue_web/jobs_spec.rb b/spec/requests/solid_queue_web/jobs_spec.rb index 95ac5bc..ed5a243 100644 --- a/spec/requests/solid_queue_web/jobs_spec.rb +++ b/spec/requests/solid_queue_web/jobs_spec.rb @@ -66,6 +66,43 @@ end end + describe "GET /jobs/list?q= (class name search)" do + let!(:other_job) do + SolidQueue::Job.create!( + queue_name: "default", + class_name: "MailerJob", + arguments: {}, + active_job_id: SecureRandom.uuid + ) + end + + it "returns only jobs matching the search term" do + get "/jobs/list", params: { q: "Test" } + expect(response.body).to include("TestJob") + expect(response.body).not_to include("MailerJob") + end + + it "is case-insensitive" do + get "/jobs/list", params: { q: "test" } + expect(response.body).to include("TestJob") + end + + it "shows empty state when search matches nothing" do + get "/jobs/list", params: { q: "NoSuchJob" } + expect(response.body).to include("No ready jobs") + end + + it "renders a clear link when search is active" do + get "/jobs/list", params: { q: "Test" } + expect(response.body).to include("Clear") + end + + it "persists search term across status tab links" do + get "/jobs/list", params: { q: "Test" } + expect(response.body).to include("q=Test") + end + end + describe "DELETE /jobs/list/:id (discard single)" do it "discards the job and redirects (HTML)" do delete "/jobs/list/#{ready_execution.id}", params: { status: "ready" }