Skip to content

Commit

Permalink
Add batch operations for jobs to Dashboard
Browse files Browse the repository at this point in the history
  • Loading branch information
bensheldon committed Apr 25, 2022
1 parent 0998bac commit 0df7d08
Show file tree
Hide file tree
Showing 18 changed files with 378 additions and 98 deletions.
4 changes: 4 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,9 @@ indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true

[*.js]
indent_size = 2
indent_style = space

[*.md]
indent_size = 4
4 changes: 3 additions & 1 deletion engine/app/assets/good_job/modules/application.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
/*jshint esversion: 6, strict: false */

import renderCharts from "charts";
import checkboxToggle from "checkbox_toggle";
import documentReady from "document_ready";
import showToasts from "toasts";
import renderCharts from "charts";
import Poller from "poller";

documentReady(function() {
renderCharts();
showToasts();
checkboxToggle();
Poller.start();
});
40 changes: 40 additions & 0 deletions engine/app/assets/good_job/modules/checkbox_toggle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*jshint esversion: 6, strict: false */

// How to use:
//<form data-checkbox-toggle="{key}">
// <input type="checkbox" data-checkbox-toggle-all="{key}" />
//
// <input type="checkbox" data-checkbox-toggle-each="{key}" />
// <input type="checkbox" data-checkbox-toggle-each="{key}" />
// ...

export default function checkboxToggle() {
document.querySelectorAll("form[data-checkbox-toggle]").forEach(function (form) {
const keyName = form.dataset.checkboxToggle;
const checkboxToggle = form.querySelector(`input[type=checkbox][data-checkbox-toggle-all=${keyName}]`);
const checkboxes = form.querySelectorAll(`input[type=checkbox][data-checkbox-toggle-each=${keyName}]`);

// Check or uncheck all checkboxes
checkboxToggle.addEventListener("change", function (event) {
checkboxes.forEach(function (checkbox) {
checkbox.checked = checkboxToggle.checked;
});
});

// check or uncheck the "all" checkbox when all checkboxes are checked or unchecked
form.addEventListener("change", function (event) {
if (!event.target.matches(`input[type=checkbox][data-checkbox-toggle-each=${keyName}]`)) {
return;
}
const checkedCount = Array.from(checkboxes).filter(function (checkbox) {
return checkbox.checked;
}).length;

const allChecked = checkedCount === checkboxes.length;
const indeterminateChecked = !allChecked && checkedCount > 0;

checkboxToggle.checked = allChecked;
checkboxToggle.indeterminate = indeterminateChecked;
});
});
}
41 changes: 40 additions & 1 deletion engine/app/controllers/good_job/jobs_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
# frozen_string_literal: true
module GoodJob
class JobsController < GoodJob::ApplicationController
DISCARD_MESSAGE = "Discarded through dashboard"

ACTIONS = {
discard: "discarded",
reschedule: "rescheduled",
retry: "retried",
}.freeze

rescue_from GoodJob::ActiveJobJob::AdapterNotGoodJobError,
GoodJob::ActiveJobJob::ActionForStateMismatchError,
with: :redirect_on_error
Expand All @@ -9,6 +17,37 @@ def index
@filter = JobsFilter.new(params)
end

def batch
job_ids = params.fetch(:job_ids, [])
batch_action = params.fetch(:batch_action, "").to_sym

raise ActionController::BadRequest, "#{batch_action} is not a valid batch action" unless batch_action.in?(ACTIONS.keys)

jobs = ActiveJobJob.where(active_job_id: job_ids)
processed_jobs = jobs.map do |job|
case batch_action
when :discard
job.discard_job(DISCARD_MESSAGE)
when :reschedule
job.reschedule_job
when :retry
job.retry_job
end

job
rescue GoodJob::ActiveJobJob::ActionForStateMismatchError
nil
end.compact

notice = if processed_jobs.any?
"Successfully #{ACTIONS[batch_action]} #{processed_jobs.count} #{'job'.pluralize(processed_jobs.count)}"
else
"No jobs were #{ACTIONS[batch_action]}"
end

redirect_to jobs_path, notice: notice
end

def show
@executions = GoodJob::Execution.active_job_id(params[:id])
.order(Arel.sql("COALESCE(scheduled_at, created_at) DESC"))
Expand All @@ -17,7 +56,7 @@ def show

def discard
@job = ActiveJobJob.find(params[:id])
@job.discard_job("Discarded through dashboard")
@job.discard_job(DISCARD_MESSAGE)
redirect_back(fallback_location: jobs_path, notice: "Job has been discarded")
end

Expand Down
3 changes: 1 addition & 2 deletions engine/app/filters/good_job/jobs_filter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ def states
end

def filtered_query
query = base_query.includes(:executions)
.joins_advisory_locks.select("#{GoodJob::ActiveJobJob.table_name}.*", 'pg_locks.locktype AS locktype')
query = base_query.includes(:executions).includes_advisory_locks

query = query.job_class(params[:job_class]) if params[:job_class].present?
query = query.where(queue_name: params[:queue_name]) if params[:queue_name].present?
Expand Down
6 changes: 6 additions & 0 deletions engine/app/helpers/good_job/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,11 @@ def status_badge(status)

content_tag :span, status.to_s, class: classes
end

def render_icon(name)
# workaround to render svg icons without all of the log messages
partial = lookup_context.find_template("good_job/shared/icons/#{name}", [], true)
partial.render(self, {})
end
end
end
2 changes: 1 addition & 1 deletion engine/app/views/good_job/executions/_table.erb
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
</td>
<td>
<%= button_to execution_path(execution.id), method: :delete, class: "btn btn-sm btn-outline-danger", title: "Delete execution", data: { confirm: "Confirm delete" } do %>
<%= render "good_job/shared/icons/trash" %>
<%= render_icon "trash" %>
<% end %>
</td>
</tr>
Expand Down
153 changes: 91 additions & 62 deletions engine/app/views/good_job/jobs/_table.erb
Original file line number Diff line number Diff line change
@@ -1,72 +1,101 @@
<div class="my-3" data-gj-poll-replace id="jobs-table">
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<thead>
<tr>
<th>ActiveJob ID</th>
<th>State</th>
<th>Job Class</th>
<th>Queue</th>
<th>Scheduled At</th>
<th>Executions</th>
<th>Error</th>
<th>
ActiveJob Params&nbsp;
<%= tag.button "Toggle", type: "button", class: "btn btn-sm btn-outline-primary", role: "button",
data: { bs_toggle: "collapse", bs_target: ".job-params" },
aria: { expanded: false, controls: jobs.map { |job| "##{dom_id(job, "params")}" }.join(" ") }
%>
</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<% if jobs.present? %>
<% jobs.each do |job| %>
<tr class="<%= dom_class(job) %>" id="<%= dom_id(job) %>">
<td>
<%= link_to job_path(job.id) do %>
<code><%= job.id %></code>
<%= form_with(url: batch_jobs_path, method: :put, local: true, data: { "checkbox-toggle": "job_ids" }) do |form| %>
<table class="table table-hover table-sm mb-0">
<thead>
<tr>
<th><%= check_box_tag('toggle_job_ids', "1", false, data: { "checkbox-toggle-all": "job_ids" }) %></th>
<th>ActiveJob ID</th>
<th>State</th>
<th>Job Class</th>
<th>Queue</th>
<th>Scheduled At</th>
<th>Executions</th>
<th>Error</th>
<th>
ActiveJob Params&nbsp;
<%= tag.button "Toggle", type: "button", class: "btn btn-sm btn-outline-primary", role: "button",
data: { bs_toggle: "collapse", bs_target: ".job-params" },
aria: { expanded: false, controls: jobs.map { |job| "##{dom_id(job, "params")}" }.join(" ") }
%>
</th>
<th>
Actions<br>

<div class="d-inline text-nowrap">
<%= form.button type: 'submit', name: 'batch_action', value: 'reschedule', class: 'btn btn-sm btn-outline-primary', title: "Reschedule all", data: { confirm: "Confirm reschedule all", disable: true } do %>
<%= render_icon "skip_forward" %> All
<% end %>
</td>
<td><%= status_badge(job.status) %></td>
<td><%= job.job_class %></td>
<td><%= job.queue_name %></td>
<td><%= relative_time(job.scheduled_at || job.created_at) %></td>
<td><%= job.executions_count %></td>
<td class="text-break"><%= truncate(job.recent_error, length: 1_000) %></td>
<td>
<%= tag.button "Preview", type: "button", class: "btn btn-sm btn-outline-primary", role: "button",
data: { bs_toggle: "collapse", bs_target: "##{dom_id(job, 'params')}" },
aria: { expanded: false, controls: dom_id(job, "params") }
%>
<%= tag.pre JSON.pretty_generate(job.serialized_params), id: dom_id(job, "params"), class: "collapse job-params" %>
</td>
<td>
<div class="text-nowrap">
<% job_reschedulable = job.status.in? [:scheduled, :retried, :queued] %>
<%= button_to reschedule_job_path(job.id), method: :put, class: "btn btn-sm #{job_reschedulable ? 'btn-outline-primary' : 'btn-outline-secondary'}", form_class: "d-inline-block", disabled: !job_reschedulable, aria: { label: "Reschedule job" }, title: "Reschedule job", data: { confirm: "Confirm reschedule" } do %>
<%= render "good_job/shared/icons/skip_forward" %>
<% end %>
<% job_discardable = job.status.in? [:scheduled, :retried, :queued] %>
<%= button_to discard_job_path(job.id), method: :put, class: "btn btn-sm #{job_discardable ? 'btn-outline-primary' : 'btn-outline-secondary'}", form_class: "d-inline-block", disabled: !job_discardable, aria: { label: "Discard job" }, title: "Discard job", data: { confirm: "Confirm discard" } do %>
<%= render "good_job/shared/icons/stop" %>
<% end %>
<%= form.button type: 'submit', name: 'batch_action', value: 'discard', class: 'btn btn-sm btn-outline-primary', title: "Discard all", data: { confirm: "Confirm discard all", disable: true } do %>
<%= render_icon "stop" %> All
<% end %>
<%= button_to retry_job_path(job.id), method: :put, class: "btn btn-sm #{job.status == :discarded ? 'btn-outline-primary' : 'btn-outline-secondary'}", form_class: "d-inline-block", disabled: job.status != :discarded, aria: { label: "Retry job" }, title: "Retry job", data: { confirm: "Confirm retry" } do %>
<%= render "good_job/shared/icons/arrow_clockwise" %>
<%= form.button type: 'submit', name: 'batch_action', value: 'retry', class: 'btn btn-sm btn-outline-primary', title: "Retry all", data: { confirm: "Confirm retry all", disable: true } do %>
<%= render_icon "arrow_clockwise" %> All
<% end %>
</div>
</tr>
</thead>
<tbody>
<% if jobs.present? %>
<% jobs.each do |job| %>
<tr class="<%= dom_class(job) %>" id="<%= dom_id(job) %>">
<td><%= check_box_tag 'job_ids[]', job.id, false, data: { "checkbox-toggle-each": "job_ids" } %></td>
<td>
<%= link_to job_path(job.id) do %>
<code><%= job.id %></code>
<% end %>
</div>
</td>
</td>
<td><%= status_badge(job.status) %></td>
<td><%= job.job_class %></td>
<td><%= job.queue_name %></td>
<td><%= relative_time(job.scheduled_at || job.created_at) %></td>
<td><%= job.executions_count %></td>
<td class="text-break"><%= truncate(job.recent_error, length: 1_000) %></td>
<td>
<%= tag.button "Preview", type: "button", class: "btn btn-sm btn-outline-primary", role: "button",
data: { bs_toggle: "collapse", bs_target: "##{dom_id(job, 'params')}" },
aria: { expanded: false, controls: dom_id(job, "params") }
%>
<%= tag.pre JSON.pretty_generate(job.serialized_params), id: dom_id(job, "params"), class: "collapse job-params" %>
</td>
<td>
<div class="text-nowrap">
<% if job.status.in? [:scheduled, :retried, :queued] %>
<%= link_to reschedule_job_path(job.id), method: :put, class: "btn btn-sm btn-outline-primary", title: "Reschedule job", data: { confirm: "Confirm reschedule", disable: true } do %>
<%= render_icon "skip_forward" %>
<% end %>
<% else %>
<button class="btn btn-sm btn-outline-secondary" disabled><%= render_icon "skip_forward" %></button>
<% end %>
<% if job.status.in? [:scheduled, :retried, :queued] %>
<%= link_to discard_job_path(job.id), method: :put, class: "btn btn-sm btn-outline-primary", title: "Discard job", data: { confirm: "Confirm discard", disable: true } do %>
<%= render_icon "stop" %>
<% end %>
<% else %>
<button class="btn btn-sm btn-outline-secondary" disabled><%= render_icon "stop" %></button>
<% end %>
<% if job.status == :discarded %>
<%= link_to retry_job_path(job.id), method: :put, class: "btn btn-sm btn-outline-primary", title: "Retry job", data: { confirm: "Confirm retry", disable: true } do %>
<%= render_icon "arrow_clockwise" %>
<% end %>
<% else %>
<button class="btn btn-sm btn-outline-secondary" disabled><%= render_icon "arrow_clockwise" %></button>
<% end %>
</div>
</td>
</tr>
<% end %>
<% else %>
<tr>
<td colspan="8" class="py-2 text-center text-muted">No jobs found.</td>
</tr>
<% end %>
<% else %>
<tr>
<td colspan="8" class="py-2 text-center text-muted">No jobs found.</td>
</tr>
<% end %>
</tbody>
</table>
</tbody>
</table>
<% end %>
</div>
</div>
5 changes: 5 additions & 0 deletions engine/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
resources :executions, only: %i[destroy]

resources :jobs, only: %i[index show] do
collection do
get :batch, to: redirect(path: 'jobs')
put :batch
end

member do
put :discard
put :reschedule
Expand Down
16 changes: 9 additions & 7 deletions lib/good_job/active_job_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ def recent_error
# Tests whether the job is being executed right now.
# @return [Boolean]
def running?
# Avoid N+1 Query: `.joins_advisory_locks.select('good_jobs.*', 'pg_locks.locktype AS locktype')`
# Avoid N+1 Query: `.includes_advisory_locks`
if has_attribute?(:locktype)
self['locktype'].present?
else
Expand All @@ -157,15 +157,15 @@ def running?
end

# Retry a job that has errored and been discarded.
# This action will create a new job {Execution} record.
# This action will create a new {Execution} record for the job.
# @return [ActiveJob::Base]
def retry_job
with_advisory_lock do
execution = head_execution(reload: true)
active_job = execution.active_job

raise AdapterNotGoodJobError unless active_job.class.queue_adapter.is_a? GoodJob::Adapter
raise ActionForStateMismatchError unless status == :discarded
raise ActionForStateMismatchError if execution.finished_at.blank? || execution.error.blank?

# Update the executions count because the previous execution will not have been preserved
# Do not update `exception_executions` because that comes from rescue_from's arguments
Expand All @@ -176,7 +176,7 @@ def retry_job
current_thread.execution = execution

execution.class.transaction(joinable: false, requires_new: true) do
new_active_job = active_job.retry_job(wait: 0, error: error)
new_active_job = active_job.retry_job(wait: 0, error: execution.error)
execution.save
end
end
Expand All @@ -189,11 +189,11 @@ def retry_job
# @return [void]
def discard_job(message)
with_advisory_lock do
raise ActionForStateMismatchError unless status.in? [:scheduled, :queued, :retried]

execution = head_execution(reload: true)
active_job = execution.active_job

raise ActionForStateMismatchError if execution.finished_at.present?

job_error = GoodJob::ActiveJobJob::DiscardJobError.new(message)

update_execution = proc do
Expand All @@ -216,7 +216,9 @@ def discard_job(message)
# @return [void]
def reschedule_job(scheduled_at = Time.current)
with_advisory_lock do
raise ActionForStateMismatchError unless status.in? [:scheduled, :queued, :retried]
execution = head_execution(reload: true)

raise ActionForStateMismatchError if execution.finished_at.present?

execution = head_execution(reload: true)
execution.update(scheduled_at: scheduled_at)
Expand Down
Loading

0 comments on commit 0df7d08

Please sign in to comment.