diff --git a/CHANGELOG.md b/CHANGELOG.md index 385f438..dc1a119 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Job detail page showing status, queue, priority, arguments (pretty-printed JSON), and full error backtrace for failed jobs +- Retry/Discard action buttons on the detail page based on job status +- Job class names on the jobs and failed jobs index pages link to the detail page - Retry and discard actions on individual failed jobs - Bulk "Retry All" and "Discard All" actions for failed jobs - Discard action on individual ready, scheduled, and blocked jobs - Bulk "Discard All" action for ready, scheduled, and blocked jobs (scoped to current queue filter) -- Roadmap section added to README +- Roadmap section added to README with planned features and contribution guidelines ### Fixed diff --git a/app/assets/stylesheets/solid_queue_web/application.css b/app/assets/stylesheets/solid_queue_web/application.css index 2143b22..6858c30 100644 --- a/app/assets/stylesheets/solid_queue_web/application.css +++ b/app/assets/stylesheets/solid_queue_web/application.css @@ -307,4 +307,71 @@ nav.pagy a { nav.pagy a:hover:not([aria-disabled="true"]) { background: var(--bg); } nav.pagy a[aria-current="page"] { background: var(--primary); color: #fff; border-color: var(--primary); } nav.pagy a[role="separator"], -nav.pagy a[aria-disabled="true"] { color: var(--muted); cursor: default; } \ No newline at end of file +nav.pagy a[aria-disabled="true"] { color: var(--muted); cursor: default; } + +/* Job detail page */ +.sqd-breadcrumb { + font-size: 12px; + color: var(--muted); + margin-bottom: 0.25rem; +} + +.sqd-breadcrumb a { color: var(--muted); text-decoration: none; } +.sqd-breadcrumb a:hover { color: var(--text); } + +.sqd-detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; +} + +@media (max-width: 768px) { + .sqd-detail-grid { grid-template-columns: 1fr; } +} + +.sqd-detail-section { padding: 1.25rem; } + +.sqd-section-title { + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); + margin-bottom: 1rem; +} + +.sqd-section-title--danger { color: var(--danger); } + +.sqd-dl { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.5rem 1.5rem; + font-size: 13px; +} + +.sqd-dl dt { color: var(--muted); white-space: nowrap; } +.sqd-dl dd { word-break: break-all; } + +.sqd-pre { + font-family: monospace; + font-size: 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 5px; + padding: 0.75rem; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; + max-height: 400px; + overflow-y: auto; +} + +.sqd-pre--muted { color: var(--muted); } + +.sqd-error-header { + font-size: 13px; + padding: 0.5rem 0.75rem; + background: #f8d7da; + color: #842029; + border-radius: 5px; +} \ No newline at end of file diff --git a/app/controllers/solid_queue_web/jobs_controller.rb b/app/controllers/solid_queue_web/jobs_controller.rb index a5a5b44..3b7f468 100644 --- a/app/controllers/solid_queue_web/jobs_controller.rb +++ b/app/controllers/solid_queue_web/jobs_controller.rb @@ -19,6 +19,14 @@ def index @jobs = @jobs.order(created_at: :desc).limit(100) end + def show + @job = SolidQueue::Job + .includes(:ready_execution, :scheduled_execution, :claimed_execution, :blocked_execution, :failed_execution) + .find(params[:id]) + @failed_execution = @job.failed_execution + @execution_status = derive_status(@job) + end + def destroy execution = execution_model_for!(params[:status]).find(params[:id]) execution.discard @@ -45,6 +53,15 @@ def discard_all private + def derive_status(job) + return "failed" if job.failed_execution.present? + return "claimed" if job.claimed_execution.present? + return "blocked" if job.blocked_execution.present? + return "ready" if job.ready_execution.present? + return "scheduled" if job.scheduled_execution.present? + "finished" + end + def execution_model_for!(status) case status when "ready" then SolidQueue::ReadyExecution diff --git a/app/views/solid_queue_web/failed_jobs/index.html.erb b/app/views/solid_queue_web/failed_jobs/index.html.erb index 8b28000..8b97231 100644 --- a/app/views/solid_queue_web/failed_jobs/index.html.erb +++ b/app/views/solid_queue_web/failed_jobs/index.html.erb @@ -28,7 +28,7 @@ <% @failed_jobs.each do |execution| %> <% job = execution.job %> - <%= job.class_name %> + <%= link_to job.class_name, job_path(job) %> <%= job.queue_name %> <% if execution.exception_class.present? %> diff --git a/app/views/solid_queue_web/jobs/index.html.erb b/app/views/solid_queue_web/jobs/index.html.erb index d4cb25f..e08b881 100644 --- a/app/views/solid_queue_web/jobs/index.html.erb +++ b/app/views/solid_queue_web/jobs/index.html.erb @@ -42,7 +42,7 @@ <%= @status %> - <%= job.class_name %> + <%= link_to job.class_name, job_path(job), style: "margin-left: 0.5rem;" %> <%= link_to job.queue_name, jobs_path(status: @status, queue: job.queue_name), diff --git a/app/views/solid_queue_web/jobs/show.html.erb b/app/views/solid_queue_web/jobs/show.html.erb new file mode 100644 index 0000000..c6328cc --- /dev/null +++ b/app/views/solid_queue_web/jobs/show.html.erb @@ -0,0 +1,75 @@ +
+
+
+ <%= link_to "Jobs", jobs_path(status: @execution_status) %> › Detail +
+

<%= @job.class_name %>

+
+ +
+ <% if @execution_status == "failed" && @failed_execution %> + <%= button_to "Retry", retry_failed_job_path(@failed_execution), method: :post, + class: "sqd-btn sqd-btn--primary" %> + <%= button_to "Discard", failed_job_path(@failed_execution), method: :delete, + class: "sqd-btn sqd-btn--danger", + data: { confirm: "Discard this job?" } %> + <% elsif SolidQueueWeb::JobsController::DISCARDABLE.include?(@execution_status) %> + <% execution = @job.public_send("#{@execution_status}_execution") %> + <% if execution %> + <%= button_to "Discard", job_path(execution), + method: :delete, + params: { status: @execution_status }, + class: "sqd-btn sqd-btn--danger", + data: { confirm: "Discard this job?" } %> + <% end %> + <% end %> +
+
+ +
+
+

Details

+
+
Status
+
<%= @execution_status %>
+ +
Queue
+
<%= @job.queue_name %>
+ +
Priority
+
<%= @job.priority %>
+ +
Active Job ID
+
<%= @job.active_job_id %>
+ +
Concurrency Key
+
<%= @job.concurrency_key.presence || "—" %>
+ +
Enqueued At
+
<%= @job.created_at.strftime("%Y-%m-%d %H:%M:%S %Z") %>
+ +
Scheduled At
+
<%= @job.scheduled_at ? @job.scheduled_at.strftime("%Y-%m-%d %H:%M:%S %Z") : "—" %>
+ +
Finished At
+
<%= @job.finished_at ? @job.finished_at.strftime("%Y-%m-%d %H:%M:%S %Z") : "—" %>
+
+
+ +
+

Arguments

+
<%= JSON.pretty_generate(@job.arguments) rescue @job.arguments.inspect %>
+
+
+ +<% if @failed_execution %> +
+

Error

+

+ <%= @failed_execution.exception_class %>: <%= @failed_execution.message %> +

+ <% if @failed_execution.backtrace.present? %> +
<%= Array(@failed_execution.backtrace).join("\n") %>
+ <% end %> +
+<% end %> diff --git a/config/routes.rb b/config/routes.rb index c1c2828..6922aa4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,7 +2,7 @@ root to: "dashboard#index" resources :queues, only: [ :index ] - resources :jobs, only: [ :index, :destroy ] do + resources :jobs, only: [ :index, :show, :destroy ] do collection do post :discard_all end diff --git a/spec/requests/solid_queue_web/jobs_spec.rb b/spec/requests/solid_queue_web/jobs_spec.rb index 39bb41a..ee6362f 100644 --- a/spec/requests/solid_queue_web/jobs_spec.rb +++ b/spec/requests/solid_queue_web/jobs_spec.rb @@ -12,6 +12,30 @@ let(:ready_execution) { ready_job.ready_execution } + describe "GET /jobs/jobs/:id (detail)" do + it "returns HTTP success" do + get "/jobs/jobs/#{ready_job.id}" + expect(response).to have_http_status(:ok) + end + + it "displays job class name and details" do + get "/jobs/jobs/#{ready_job.id}" + expect(response.body).to include("TestJob") + expect(response.body).to include("default") + end + + it "shows error section for failed jobs" do + ready_job.ready_execution&.destroy + SolidQueue::FailedExecution.create!( + job: ready_job, + error: { exception_class: "RuntimeError", message: "boom", backtrace: [ "app/jobs/test_job.rb:1" ] } + ) + get "/jobs/jobs/#{ready_job.id}" + expect(response.body).to include("RuntimeError") + expect(response.body).to include("app/jobs/test_job.rb:1") + end + end + describe "GET /jobs/jobs" do it "returns HTTP success" do get "/jobs/jobs"