Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add mass update operations for jobs to Dashboard #578

Merged
merged 1 commit into from
Apr 26, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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();
});
51 changes: 51 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,51 @@
/*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);
showable.disabled = ! 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);
showable.disabled = !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 mass_update
mass_action = params.fetch(:mass_action, "").to_sym
raise ActionController::BadRequest, "#{mass_action} is not a valid mass action" unless mass_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 mass_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[mass_action]} #{processed_jobs.count} #{'job'.pluralize(processed_jobs.count)}"
else
"No jobs were #{ACTIONS[mass_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: mass_update_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: 'mass_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: 'mass_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: 'mass_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", 1, false, disabled: true, data: { "checkbox-toggle-show": "job_ids"} %>
Apply to all <%= all_jobs_count.present? ? number_with_delimiter(all_jobs_count) : "" %> <%= "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="10" 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 :mass_update, to: redirect(path: 'jobs')
put :mass_update
end

member do
put :discard
put :reschedule
Expand Down
Loading