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 c548fb7 commit ca5fb4c
Show file tree
Hide file tree
Showing 20 changed files with 432 additions and 99 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();
});
49 changes: 49 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,49 @@
/*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}]`);
const showables = form.querySelectorAll(`[data-checkbox-toggle-show=${keyName}]`);

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

showables.forEach(function (showable) {
showable.classList.toggle("d-none", !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;

showables.forEach(function (showable) {
showable.classList.toggle("d-none", !allChecked);
});
});
});
}
45 changes: 44 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,41 @@ def index
@filter = JobsFilter.new(params)
end

def batch
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 = if params[:all_job_ids]
ActiveJobJob.all
else
job_ids = params.fetch(:job_ids, [])
ActiveJobJob.where(active_job_id: job_ids)
end

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 +60,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: 3 additions & 0 deletions engine/app/filters/good_job/base_filter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ def filtered_query
raise NotImplementedError
end

# def filtered_query_count
delegate :count, to: :filtered_query, prefix: true

private

def default_base_query
Expand Down
7 changes: 5 additions & 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 All @@ -40,6 +39,10 @@ def filtered_query
query
end

def filtered_query_count
filtered_query.unscope(:select).count
end

private

def default_base_query
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
163 changes: 101 additions & 62 deletions engine/app/views/good_job/jobs/_table.erb
Original file line number Diff line number Diff line change
@@ -1,72 +1,111 @@
<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>
<tr class="d-none" data-checkbox-toggle-show="job_ids">
<td class="text-center table-warning" colspan="10">
<% all_jobs_count = local_assigns[:all_jobs_count] %>
<label>
<%= check_box_tag "all_job_ids" %>
Apply to all <%= all_jobs_count.present? ? all_jobs_count.to_s(:delimited) : "" %> <%= "job".pluralize(all_jobs_count || 99) %>.
<em>This could be a lot.</em>
</label>
</td>
</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>
2 changes: 1 addition & 1 deletion engine/app/views/good_job/jobs/index.html.erb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<%= render 'good_job/shared/filter', title: "Jobs", filter: @filter %>
<%= render 'good_job/shared/chart', chart_data: GoodJob::ScheduledByQueueChart.new(@filter).data %>
<%= render 'good_job/jobs/table', jobs: @filter.records %>
<%= render 'good_job/jobs/table', jobs: @filter.records, all_jobs_count: @filter.filtered_query_count %>

<% if @filter.records.present? %>
<nav aria-label="Job pagination" class="mt-3" data-gj-poll-replace id="jobs-pagination">
Expand Down
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
Loading

0 comments on commit ca5fb4c

Please sign in to comment.