diff --git a/app/assets/stylesheets/solid_stack_web/_09_detail.css b/app/assets/stylesheets/solid_stack_web/_09_detail.css index e897dda..dda42a3 100644 --- a/app/assets/stylesheets/solid_stack_web/_09_detail.css +++ b/app/assets/stylesheets/solid_stack_web/_09_detail.css @@ -68,4 +68,18 @@ word-break: break-word; max-height: 400px; overflow-y: auto; +} + +.sqw-code-input { + 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; + width: 100%; + resize: vertical; + color: var(--text); + line-height: 1.5; + box-sizing: border-box; } \ No newline at end of file diff --git a/app/controllers/solid_stack_web/failed_jobs/arguments_controller.rb b/app/controllers/solid_stack_web/failed_jobs/arguments_controller.rb new file mode 100644 index 0000000..56d0851 --- /dev/null +++ b/app/controllers/solid_stack_web/failed_jobs/arguments_controller.rb @@ -0,0 +1,17 @@ +module SolidStackWeb + module FailedJobs + class ArgumentsController < ApplicationController + def update + @execution = SolidQueue::FailedExecution.includes(:job).find(params[:failed_job_id]) + new_arguments = JSON.parse(params[:arguments]) + @execution.job.update!(arguments: new_arguments) + @execution.retry + redirect_to failed_jobs_path, notice: "Arguments updated and job queued for retry." + rescue JSON::ParserError + redirect_to failed_job_path(@execution), alert: "Invalid JSON — arguments were not saved." + rescue => e + redirect_to failed_jobs_path, alert: "Could not update job: #{e.message}" + end + end + end +end diff --git a/app/controllers/solid_stack_web/failed_jobs_controller.rb b/app/controllers/solid_stack_web/failed_jobs_controller.rb index 1982a86..36d7015 100644 --- a/app/controllers/solid_stack_web/failed_jobs_controller.rb +++ b/app/controllers/solid_stack_web/failed_jobs_controller.rb @@ -14,6 +14,13 @@ def index end end + def show + @execution = ::SolidQueue::FailedExecution.includes(:job).find(params[:id]) + @arguments = JSON.pretty_generate(@execution.job.arguments) if @execution.job.arguments.present? + rescue JSON::GeneratorError + @arguments = @execution.job.arguments.to_s + end + def destroy @execution = ::SolidQueue::FailedExecution.find(params[:id]) @execution.job.destroy! diff --git a/app/views/solid_stack_web/failed_jobs/index.html.erb b/app/views/solid_stack_web/failed_jobs/index.html.erb index 2b77d0e..74bbbd4 100644 --- a/app/views/solid_stack_web/failed_jobs/index.html.erb +++ b/app/views/solid_stack_web/failed_jobs/index.html.erb @@ -47,7 +47,7 @@ aria-label="Select <%= execution.job.class_name %>" data-selection-target="checkbox" data-action="change->selection#toggle"> - <%= execution.job.class_name %> + <%= link_to execution.job.class_name, failed_job_path(execution) %> <%= execution.job.queue_name %> <%= execution.exception_class %> <%= execution.created_at.strftime("%b %d %H:%M") %> diff --git a/app/views/solid_stack_web/failed_jobs/show.html.erb b/app/views/solid_stack_web/failed_jobs/show.html.erb new file mode 100644 index 0000000..744925b --- /dev/null +++ b/app/views/solid_stack_web/failed_jobs/show.html.erb @@ -0,0 +1,58 @@ +
+
+
+ <%= link_to "Failed Jobs", failed_jobs_path %> › Detail +
+

<%= @execution.job.class_name %>

+
+ +
+ <%= button_to "Retry", retry_failed_job_path(@execution), + method: :post, class: "sqw-btn sqw-btn--sm" %> + <%= button_to "Discard", failed_job_path(@execution), + method: :delete, class: "sqw-btn sqw-btn--danger", + data: { turbo_confirm: "Discard this job?" } %> +
+
+ +
+
+

Details

+
+
Queue
+
<%= @execution.job.queue_name %>
+ +
Priority
+
<%= @execution.job.priority %>
+ +
Active Job ID
+
<%= @execution.job.active_job_id.presence || "—" %>
+ +
Failed At
+
<%= @execution.created_at.strftime("%Y-%m-%d %H:%M:%S UTC") %>
+ +
Error
+
<%= @execution.exception_class %>
+ +
Message
+
<%= @execution.message %>
+
+
+ +
+

Backtrace

+
<%= Array(@execution.backtrace).first(10).join("\n").presence || "—" %>
+
+
+ +
+

Arguments

+ <%= form_with url: failed_job_arguments_path(@execution), method: :patch do |f| %> + <%= f.text_area :arguments, value: @arguments, rows: 12, + class: "sqw-code-input", aria: { label: "Job arguments JSON" }, + spellcheck: false %> +
+ <%= f.submit "Update & Retry".html_safe, class: "sqw-btn sqw-btn--sm" %> +
+ <% end %> +
\ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 7317639..66336fb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -10,8 +10,9 @@ end end - resources :failed_jobs, only: [:index, :destroy] do + resources :failed_jobs, only: [:index, :show, :destroy] do member { post :retry } + resource :arguments, only: [:update], controller: "failed_jobs/arguments" end resources :queues, only: [:index] do diff --git a/spec/requests/solid_stack_web/failed_job_selections_spec.rb b/spec/requests/solid_stack_web/failed_job_selections_spec.rb index d82da7e..bd66c05 100644 --- a/spec/requests/solid_stack_web/failed_job_selections_spec.rb +++ b/spec/requests/solid_stack_web/failed_job_selections_spec.rb @@ -19,6 +19,17 @@ def create_failed(class_name: "FailingJob", queue_name: "default") end describe "POST /failed_jobs/selection (bulk retry)" do + it "redirects with alert when retry raises" do + execution = create_failed + allow_any_instance_of(SolidQueue::FailedExecution).to receive(:retry).and_raise(RuntimeError, "db error") + + post "#{engine_root}/failed_jobs/selection", + params: { job_ids: [execution.id] } + + expect(response).to redirect_to("#{engine_root}/failed_jobs") + expect(flash[:alert]).to eq("Could not retry jobs: db error") + end + it "retries only the selected jobs" do exec_a = create_failed(class_name: "JobA") exec_b = create_failed(class_name: "JobB") @@ -59,6 +70,17 @@ def create_failed(class_name: "FailingJob", queue_name: "default") end describe "DELETE /failed_jobs/selection (bulk discard)" do + it "redirects with alert when discard raises" do + execution = create_failed + allow(SolidQueue::Job).to receive(:where).and_raise(RuntimeError, "db error") + + delete "#{engine_root}/failed_jobs/selection", + params: { job_ids: [execution.id] } + + expect(response).to redirect_to("#{engine_root}/failed_jobs") + expect(flash[:alert]).to eq("Could not discard jobs: db error") + end + it "discards only the selected jobs" do exec_a = create_failed(class_name: "JobA") exec_b = create_failed(class_name: "JobB") diff --git a/spec/requests/solid_stack_web/failed_jobs_spec.rb b/spec/requests/solid_stack_web/failed_jobs_spec.rb index 9aa3f5e..8b6fa1a 100644 --- a/spec/requests/solid_stack_web/failed_jobs_spec.rb +++ b/spec/requests/solid_stack_web/failed_jobs_spec.rb @@ -106,6 +106,86 @@ def create_failed(class_name: "FailingJob", queue_name: "default") end end + describe "GET /failed_jobs/:id" do + it "returns 200 and shows the job class" do + execution = create_failed(class_name: "BrokenJob") + get "#{engine_root}/failed_jobs/#{execution.id}" + expect(response).to have_http_status(:ok) + expect(response.body).to include("BrokenJob") + end + + it "shows the error class and message" do + execution = create_failed + get "#{engine_root}/failed_jobs/#{execution.id}" + expect(response.body).to include("RuntimeError") + expect(response.body).to include("something went wrong") + end + + it "shows a breadcrumb back to failed jobs" do + execution = create_failed + get "#{engine_root}/failed_jobs/#{execution.id}" + expect(response.body).to include("sqw-breadcrumb") + expect(response.body).to include("Failed Jobs") + end + + it "renders the argument editor form" do + execution = create_failed + get "#{engine_root}/failed_jobs/#{execution.id}" + expect(response.body).to include("sqw-code-input") + expect(response.body).to include("Update") + end + + it "shows Retry and Discard buttons" do + execution = create_failed + get "#{engine_root}/failed_jobs/#{execution.id}" + expect(response.body).to include("Retry") + expect(response.body).to include("Discard") + end + + it "falls back to raw arguments string when JSON generation fails" do + execution = create_failed + allow(JSON).to receive(:pretty_generate).and_raise(JSON::GeneratorError, "NaN") + + get "#{engine_root}/failed_jobs/#{execution.id}" + + expect(response).to have_http_status(:ok) + end + end + + describe "PATCH /failed_jobs/:id/arguments" do + it "updates arguments and retries the job" do + execution = create_failed + new_args = { "executions" => 0, "exception_executions" => {}, "user_id" => 99 }.to_json + + patch "#{engine_root}/failed_jobs/#{execution.id}/arguments", + params: { arguments: new_args } + + expect(SolidQueue::FailedExecution.exists?(execution.id)).to be false + expect(response).to redirect_to("#{engine_root}/failed_jobs") + end + + it "redirects back with alert on invalid JSON" do + execution = create_failed + + patch "#{engine_root}/failed_jobs/#{execution.id}/arguments", + params: { arguments: "not valid json {{" } + + expect(SolidQueue::FailedExecution.exists?(execution.id)).to be true + expect(response).to redirect_to("#{engine_root}/failed_jobs/#{execution.id}") + end + + it "redirects with alert when update raises" do + execution = create_failed + allow_any_instance_of(SolidQueue::Job).to receive(:update!).and_raise(RuntimeError, "db error") + + patch "#{engine_root}/failed_jobs/#{execution.id}/arguments", + params: { arguments: { "executions" => 0, "exception_executions" => {} }.to_json } + + expect(response).to redirect_to("#{engine_root}/failed_jobs") + expect(flash[:alert]).to eq("Could not update job: db error") + end + end + describe "POST /failed_jobs/:id/retry" do it "re-enqueues the job and redirects" do execution = create_failed diff --git a/spec/requests/solid_stack_web/jobs_spec.rb b/spec/requests/solid_stack_web/jobs_spec.rb index 294b44e..e5f52a0 100644 --- a/spec/requests/solid_stack_web/jobs_spec.rb +++ b/spec/requests/solid_stack_web/jobs_spec.rb @@ -298,6 +298,12 @@ def create_ready(class_name: "MyJob", queue_name: "default", priority: 0) expect(response).to redirect_to("#{engine_root}/jobs?status=claimed") end + + it "sets an alert when status is not discardable" do + delete "#{engine_root}/jobs/selection", params: { status: "claimed" } + + expect(flash[:alert]).to eq("Cannot discard claimed jobs.") + end end describe "POST /jobs/discard_all" do