diff --git a/CHANGELOG.md b/CHANGELOG.md index f5ca589..e8c6b6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Job detail page — `jobs/:id` show view with full arguments (pretty-printed JSON), queue, priority, enqueued time, status badge, Active Job ID, and status-specific metadata (scheduled_at, concurrency key, blocked-until); job class in the list is now a link to the detail page; Discard button available on the detail page for ready, scheduled, and blocked jobs - Job filtering — filter the jobs list by queue name, job class (substring), priority, and time period (1h / 24h / 7d / all) via query-param driven scopes; active filters are preserved across status tabs - Job filter Turbo Frame — filter form and results table wrapped in a `` so applying filters reloads only the table without a full page refresh; `data-turbo-action="advance"` keeps the URL in sync; Turbo JS loaded from esm.sh CDN in the engine layout diff --git a/Gemfile.lock b/Gemfile.lock index af147e3..812b68d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,6 +7,7 @@ PATH solid_cable (>= 1.0) solid_cache (>= 1.0) solid_queue (>= 1.0) + turbo-rails (>= 2.0) GEM remote: https://rubygems.org/ @@ -296,6 +297,9 @@ GEM thor (1.5.0) timeout (0.6.1) tsort (0.2.0) + turbo-rails (2.0.23) + actionpack (>= 7.1.0) + railties (>= 7.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (3.2.0) @@ -424,6 +428,7 @@ CHECKSUMS thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f diff --git a/README.md b/README.md index d69649a..fa9cfb8 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,9 @@ A mountable Rails engine that provides a unified web dashboard for the full [Sol ## Features -- **Overview dashboard** with live counts across all three Solid Stack components +- **Overview dashboard** with live counts across all three Solid Stack components; cards are clickable and link directly to each section - **Solid Queue** — browse jobs by status (ready, scheduled, claimed, blocked) with filtering by job class, queue name, priority, and time period; manage failed jobs (retry / discard), pause/resume queues, and inspect worker processes +- **Job detail page** — drill into any job to see full arguments (pretty-printed JSON), queue, priority, enqueued time, Active Job ID, concurrency key, scheduled/blocked-until metadata, and a Discard button - **Solid Cache** — entry count and total byte size at a glance - **Solid Cable** — active message count and distinct channel count - **Turbo Stream** job discard — removes the row inline without a full page reload @@ -80,6 +81,7 @@ The `authenticate` block is evaluated in the context of each request's controlle - [solid_queue](https://github.com/rails/solid_queue) >= 1.0 - [solid_cache](https://github.com/rails/solid_cache) >= 1.0 - [solid_cable](https://github.com/rails/solid_cable) >= 1.0 +- [turbo-rails](https://github.com/hotwired/turbo-rails) >= 2.0 ## Contributing diff --git a/ROADMAP.md b/ROADMAP.md index 1e68d52..8abd422 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,7 +11,6 @@ The path to v1.0.0 is staged: first achieve feature parity with `solid_queue_das > _Make the job management layer genuinely useful for operators._ ### Added -- **Job detail page** — `jobs/:id` show view with full arguments, queue, priority, enqueued time, and execution metadata - **Bulk selection** — checkbox-driven multi-select on the jobs and failed-jobs lists - **Bulk discard** — discard all selected jobs in a single request - **Bulk retry (failed jobs)** — retry selected failed jobs with optional stagger interval (5 s / 10 s / 30 s / 1 m) to avoid thundering-herd restarts diff --git a/app/assets/stylesheets/solid_stack_web/_07_dashboard.css b/app/assets/stylesheets/solid_stack_web/_07_dashboard.css index 98c9766..b11f821 100644 --- a/app/assets/stylesheets/solid_stack_web/_07_dashboard.css +++ b/app/assets/stylesheets/solid_stack_web/_07_dashboard.css @@ -12,7 +12,10 @@ border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; + position: relative; + transition: box-shadow 0.15s; } +.sqw-gem-card:hover { box-shadow: 0 3px 8px rgba(0,0,0,.12); } .sqw-gem-card--queue { border-top-color: var(--primary); } .sqw-gem-card--cache { border-top-color: var(--purple); } @@ -41,6 +44,16 @@ } .sqw-gem-card__link:hover { color: var(--primary); text-decoration: none; } +/* Stretch the header link to cover the whole card */ +.sqw-gem-card__link::after { + content: ""; + position: absolute; + inset: 0; +} + +/* Stat links and other interactive elements sit above the overlay */ +.sqw-inline-stat { position: relative; z-index: 1; } + .sqw-gem-card__body { display: grid; grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); diff --git a/app/assets/stylesheets/solid_stack_web/_09_detail.css b/app/assets/stylesheets/solid_stack_web/_09_detail.css new file mode 100644 index 0000000..0320ddb --- /dev/null +++ b/app/assets/stylesheets/solid_stack_web/_09_detail.css @@ -0,0 +1,70 @@ +.sqw-detail-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; +} + +.sqw-breadcrumb { + font-size: 12px; + color: var(--muted); + margin-bottom: 0.25rem; +} +.sqw-breadcrumb a { color: var(--muted); text-decoration: none; } +.sqw-breadcrumb a:hover { color: var(--text); } + +.sqw-page-header--split { + display: flex; + align-items: center; + justify-content: space-between; +} + +.sqw-detail-actions { + display: flex; + gap: 0.5rem; +} + +/* Two-column detail layout: Details card | Arguments card */ +.sqw-detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; +} + +.sqw-detail-section { + padding: 1.25rem; +} + +.sqw-section-title { + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .05em; + color: var(--muted); + margin-bottom: 0.75rem; +} + +/* Definition list — dt naturally sized, dd takes the rest */ +.sqw-dl { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.5rem 1.5rem; + font-size: 13px; +} +.sqw-dl dt { color: var(--muted); white-space: nowrap; } +.sqw-dl dd { word-break: break-all; } + +.sqw-code-block { + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.75rem; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; + max-height: 400px; + overflow-y: auto; +} \ No newline at end of file diff --git a/app/controllers/solid_stack_web/jobs_controller.rb b/app/controllers/solid_stack_web/jobs_controller.rb index c86fb25..2a4d0e6 100644 --- a/app/controllers/solid_stack_web/jobs_controller.rb +++ b/app/controllers/solid_stack_web/jobs_controller.rb @@ -11,9 +11,17 @@ def index @pagy, @executions = pagy(filtered_scope) end + def show + @execution = Job::EXECUTION_MODELS[@status].includes(:job).find(params[:id]) + @job = @execution.job + @arguments = JSON.parse(@job.arguments) if @job.arguments.present? + rescue JSON::ParserError + @arguments = nil + end + def destroy - execution = Job::EXECUTION_MODELS[@status].find(params[:id]) - execution.job.destroy! + @execution = Job::EXECUTION_MODELS[@status].find(params[:id]) + @execution.job.destroy! @executions_remain = Job::EXECUTION_MODELS[@status].exists? respond_to do |format| diff --git a/app/views/solid_stack_web/jobs/destroy.turbo_stream.erb b/app/views/solid_stack_web/jobs/destroy.turbo_stream.erb index 7f9e414..927b463 100644 --- a/app/views/solid_stack_web/jobs/destroy.turbo_stream.erb +++ b/app/views/solid_stack_web/jobs/destroy.turbo_stream.erb @@ -1,7 +1,7 @@ <% if @executions_remain %> - <%= turbo_stream.remove "execution_#{params[:id]}" %> + <%= turbo_stream.remove "execution_#{@execution.id}" %> <% else %> <%= turbo_stream.replace "sqw-jobs-table" do %> <%= render "empty" %> <% end %> -<% end %> +<% end %> \ No newline at end of file diff --git a/app/views/solid_stack_web/jobs/index.html.erb b/app/views/solid_stack_web/jobs/index.html.erb index e61a5f6..3795e9d 100644 --- a/app/views/solid_stack_web/jobs/index.html.erb +++ b/app/views/solid_stack_web/jobs/index.html.erb @@ -63,7 +63,7 @@ <% @executions.each do |execution| %> - <%= execution.job.class_name %> + <%= link_to execution.job.class_name, job_path(execution.id, status: @status), data: { turbo_frame: "_top" } %> <%= execution.job.queue_name %> <%= execution.job.priority %> <%= execution.created_at.strftime("%b %d %H:%M") %> diff --git a/app/views/solid_stack_web/jobs/show.html.erb b/app/views/solid_stack_web/jobs/show.html.erb new file mode 100644 index 0000000..92f08dd --- /dev/null +++ b/app/views/solid_stack_web/jobs/show.html.erb @@ -0,0 +1,57 @@ +
+
+
+ <%= link_to "Jobs", jobs_path(status: params[:status]) %> › Detail +
+

<%= @job.class_name %>

+
+ + <% if SolidStackWeb::Job::DISCARDABLE.include?(@status) %> +
+ <%= button_to "Discard Job", job_path(@execution.id, status: @status), + method: :delete, class: "sqw-btn sqw-btn--danger", + data: { turbo_confirm: "Discard this job?" } %> +
+ <% end %> +
+ +
+
+

Details

+
+
Status
+
<%= SolidStackWeb::Job::TAB_LABELS[@status] %>
+ +
Queue
+
<%= @job.queue_name %>
+ +
Priority
+
<%= @job.priority %>
+ +
Active Job ID
+
<%= @job.active_job_id.presence || "—" %>
+ +
Concurrency Key
+
<%= @job.concurrency_key.presence || "—" %>
+ + <% if @status == "blocked" %> +
Blocked Until
+
<%= @execution.expires_at ? @execution.expires_at.strftime("%Y-%m-%d %H:%M:%S UTC") : "—" %>
+ <% end %> + +
Enqueued At
+
<%= @job.created_at.strftime("%Y-%m-%d %H:%M:%S UTC") %>
+ +
Scheduled At
+
<%= @job.scheduled_at ? @job.scheduled_at.strftime("%Y-%m-%d %H:%M:%S UTC") : "—" %>
+ +
Finished At
+
<%= @job.finished_at ? @job.finished_at.strftime("%Y-%m-%d %H:%M:%S UTC") : "—" %>
+
+
+ +
+

Arguments

+
<%= @arguments ? JSON.pretty_generate(@arguments) : (@job.arguments || "—") %>
+
+
\ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index d4f5406..4b724db 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,7 +1,7 @@ SolidStackWeb::Engine.routes.draw do root to: "dashboard#index" - resources :jobs, only: [:index, :destroy] + resources :jobs, only: [:index, :show, :destroy] resources :failed_jobs, only: [:index, :destroy] do member { post :retry } diff --git a/lib/solid_stack_web/engine.rb b/lib/solid_stack_web/engine.rb index 4cb7414..60c8fad 100644 --- a/lib/solid_stack_web/engine.rb +++ b/lib/solid_stack_web/engine.rb @@ -3,6 +3,7 @@ require "solid_queue" require "solid_cache" require "solid_cable" +require "turbo-rails" module SolidStackWeb class Engine < ::Rails::Engine diff --git a/solid_stack_web.gemspec b/solid_stack_web.gemspec index 2548f32..2241689 100644 --- a/solid_stack_web.gemspec +++ b/solid_stack_web.gemspec @@ -27,4 +27,5 @@ Gem::Specification.new do |spec| spec.add_dependency "solid_queue", ">= 1.0" spec.add_dependency "solid_cache", ">= 1.0" spec.add_dependency "solid_cable", ">= 1.0" + spec.add_dependency "turbo-rails", ">= 2.0" end diff --git a/spec/requests/solid_stack_web/jobs_spec.rb b/spec/requests/solid_stack_web/jobs_spec.rb index 426c26e..0e100e2 100644 --- a/spec/requests/solid_stack_web/jobs_spec.rb +++ b/spec/requests/solid_stack_web/jobs_spec.rb @@ -130,6 +130,64 @@ def create_ready(class_name: "MyJob", queue_name: "default", priority: 0) end end + describe "GET /jobs/:id" do + it "returns 200 and shows the job class" do + job = create_ready(class_name: "ReportJob") + get "#{engine_root}/jobs/#{job.ready_execution.id}", params: { status: "ready" } + + expect(response).to have_http_status(:ok) + expect(response.body).to include("ReportJob") + end + + it "shows queue and priority" do + job = create_ready(queue_name: "critical", priority: 5) + get "#{engine_root}/jobs/#{job.ready_execution.id}", params: { status: "ready" } + + expect(response.body).to include("critical") + expect(response.body).to include("5") + end + + it "pretty-prints JSON arguments" do + job = SolidQueue::Job.create!(class_name: "MyJob", queue_name: "default", priority: 0, + arguments: [{ user_id: 42 }].to_json) + get "#{engine_root}/jobs/#{job.ready_execution.id}", params: { status: "ready" } + + expect(response.body).to include("user_id") + expect(response.body).to include("42") + end + + it "shows a discard button for ready jobs" do + job = create_ready + get "#{engine_root}/jobs/#{job.ready_execution.id}", params: { status: "ready" } + + expect(response.body).to include("Discard Job") + end + + it "does not show a discard button for claimed jobs" do + SolidQueue::Job.skip_callback(:create, :after, :prepare_for_execution) + job = SolidQueue::Job.create!(class_name: "WorkJob", queue_name: "default", priority: 0) + process = SolidQueue::Process.create!( + kind: "Worker", name: "worker-spec", pid: 99_999, + hostname: "test", last_heartbeat_at: Time.current + ) + execution = SolidQueue::ClaimedExecution.create!(job: job, process_id: process.id) + SolidQueue::Job.set_callback(:create, :after, :prepare_for_execution) + + get "#{engine_root}/jobs/#{execution.id}", params: { status: "claimed" } + + expect(response).to have_http_status(:ok) + expect(response.body).not_to include("Discard Job") + end + + it "shows a breadcrumb link to the jobs list" do + job = create_ready + get "#{engine_root}/jobs/#{job.ready_execution.id}", params: { status: "ready" } + + expect(response.body).to include("sqw-breadcrumb") + expect(response.body).to include("Jobs") + end + end + describe "combined filters" do it "applies class and queue filters together" do create_ready(class_name: "ReportJob", queue_name: "reports")