good_job 2.13.2 → 2.14.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2fe80504394d81f996c4d912ec4dc0b7c6750b5ecfd949af863a2d6087af68e8
4
- data.tar.gz: 4c19aab29e610f5a73fe4682ac9824a6d8894e9713bc2c12e2373153093558b1
3
+ metadata.gz: 723ecfed33656e11fcabed4f807c7b9735e474cf15701d97faa0d95c84dd4280
4
+ data.tar.gz: 210f4f8c8d420d46630292212c015d245c2035e8e79dc1b3148fcb40e1b8dbd0
5
5
  SHA512:
6
- metadata.gz: da255714957639ca3aafae09d0e8d4bba4e7e36ad1cd370b696b67170d88e16525f4e41751e306b5d671f74fffe36873283ad3f667a3183e532c86e255648353
7
- data.tar.gz: 0fdf220cc5e6856eac99aef51aa5cd906596f6359c688bcb831d594f7a2355c37346bf63637715fd3669351a952f188f89ce24a01a47c561bcdabb28abdc5f86
6
+ metadata.gz: 20f4b5844debd83589f2dc4f9cebe7be963f9dd7ec6749227db7fad00aca4366cdb87003776194695db84f2bc23739b65f866460d92622dbad8923ff690b9799
7
+ data.tar.gz: 3528895af9ce6f1d4d2e33dab0b6a2fa60cb255d3211a0cfc5b0e97d0f2444e7270c696872c47562dd18d822a95a550345068073a50f80d7ebdd0c9eac782bf8
data/CHANGELOG.md CHANGED
@@ -1,10 +1,52 @@
1
1
  # Changelog
2
2
 
3
+ ## [v2.14.2](https://github.com/bensheldon/good_job/tree/v2.14.2) (2022-05-01)
4
+
5
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v2.14.1...v2.14.2)
6
+
7
+ **Fixed bugs:**
8
+
9
+ - Reintroduce fixed "Apply to all" mass action [\#586](https://github.com/bensheldon/good_job/pull/586) ([bensheldon](https://github.com/bensheldon))
10
+
11
+ **Closed issues:**
12
+
13
+ - how to get the number of tasks in the queue and the size of the queue? [\#564](https://github.com/bensheldon/good_job/issues/564)
14
+ - GoodJob tells me to upgrade but migrations fail [\#544](https://github.com/bensheldon/good_job/issues/544)
15
+
16
+ **Merged pull requests:**
17
+
18
+ - Update development dependencies [\#584](https://github.com/bensheldon/good_job/pull/584) ([bensheldon](https://github.com/bensheldon))
19
+ - Refactor Dashboard Live Poll javascript [\#582](https://github.com/bensheldon/good_job/pull/582) ([bensheldon](https://github.com/bensheldon))
20
+
21
+ ## [v2.14.1](https://github.com/bensheldon/good_job/tree/v2.14.1) (2022-04-26)
22
+
23
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v2.14.0...v2.14.1)
24
+
25
+ **Fixed bugs:**
26
+
27
+ - Temporarily disable Mass Action "Apply to all" because the action is badly scoped [\#583](https://github.com/bensheldon/good_job/pull/583) ([bensheldon](https://github.com/bensheldon))
28
+
29
+ ## [v2.14.0](https://github.com/bensheldon/good_job/tree/v2.14.0) (2022-04-26)
30
+
31
+ [Full Changelog](https://github.com/bensheldon/good_job/compare/v2.13.2...v2.14.0)
32
+
33
+ **Implemented enhancements:**
34
+
35
+ - Add mass update operations for jobs to Dashboard [\#578](https://github.com/bensheldon/good_job/pull/578) ([bensheldon](https://github.com/bensheldon))
36
+
37
+ **Closed issues:**
38
+
39
+ - Allow "mass"-actions through Dashboard \(e.g. retry all\) [\#446](https://github.com/bensheldon/good_job/issues/446)
40
+
41
+ **Merged pull requests:**
42
+
43
+ - Track down incompatibility/race condition between JRuby and RSpec mocks in tests [\#581](https://github.com/bensheldon/good_job/pull/581) ([bensheldon](https://github.com/bensheldon))
44
+
3
45
  ## [v2.13.2](https://github.com/bensheldon/good_job/tree/v2.13.2) (2022-04-25)
4
46
 
5
47
  [Full Changelog](https://github.com/bensheldon/good_job/compare/v2.13.1...v2.13.2)
6
48
 
7
- **Merged pull requests:**
49
+ **Fixed bugs:**
8
50
 
9
51
  - Namespaces assets per Rails docs [\#580](https://github.com/bensheldon/good_job/pull/580) ([kylekthompson](https://github.com/kylekthompson))
10
52
 
@@ -12,6 +54,11 @@
12
54
 
13
55
  [Full Changelog](https://github.com/bensheldon/good_job/compare/v2.13.0...v2.13.1)
14
56
 
57
+ **Implemented enhancements:**
58
+
59
+ - Dashboard: Use toasts to show notices and alerts [\#577](https://github.com/bensheldon/good_job/pull/577) ([bkeepers](https://github.com/bkeepers))
60
+ - Remove executions from the dashboard [\#576](https://github.com/bensheldon/good_job/pull/576) ([bkeepers](https://github.com/bkeepers))
61
+
15
62
  **Fixed bugs:**
16
63
 
17
64
  - `ActionMailer::MailDeliveryJob` executing twice [\#329](https://github.com/bensheldon/good_job/issues/329)
@@ -26,8 +73,6 @@
26
73
 
27
74
  **Merged pull requests:**
28
75
 
29
- - Dashboard: Use toasts to show notices and alerts [\#577](https://github.com/bensheldon/good_job/pull/577) ([bkeepers](https://github.com/bkeepers))
30
- - Remove executions from the dashboard [\#576](https://github.com/bensheldon/good_job/pull/576) ([bkeepers](https://github.com/bkeepers))
31
76
  - Use javascript importmaps for Dashboard [\#574](https://github.com/bensheldon/good_job/pull/574) ([bensheldon](https://github.com/bensheldon))
32
77
 
33
78
  ## [v2.13.0](https://github.com/bensheldon/good_job/tree/v2.13.0) (2022-04-19)
@@ -50,10 +95,13 @@
50
95
 
51
96
  [Full Changelog](https://github.com/bensheldon/good_job/compare/v2.12.1...v2.12.2)
52
97
 
98
+ **Fixed bugs:**
99
+
100
+ - Un-deprecate Adapter's `execution_mode` argument [\#567](https://github.com/bensheldon/good_job/pull/567) ([bensheldon](https://github.com/bensheldon))
101
+
53
102
  **Merged pull requests:**
54
103
 
55
104
  - Dashboard: added NL translations [\#568](https://github.com/bensheldon/good_job/pull/568) ([eelcoj](https://github.com/eelcoj))
56
- - Un-deprecate Adapter's `execution_mode` argument [\#567](https://github.com/bensheldon/good_job/pull/567) ([bensheldon](https://github.com/bensheldon))
57
105
 
58
106
  ## [v2.12.1](https://github.com/bensheldon/good_job/tree/v2.12.1) (2022-04-18)
59
107
 
@@ -1,12 +1,16 @@
1
1
  /*jshint esversion: 6, strict: false */
2
2
 
3
+ import renderCharts from "charts";
4
+ import checkboxToggle from "checkbox_toggle";
3
5
  import documentReady from "document_ready";
4
6
  import showToasts from "toasts";
5
- import renderCharts from "charts";
6
- import Poller from "poller";
7
+ import LivePoll from "live_poll";
7
8
 
8
9
  documentReady(function() {
9
10
  renderCharts();
10
11
  showToasts();
11
- Poller.start();
12
+ checkboxToggle();
13
+
14
+ const livePoll = new LivePoll
15
+ livePoll.start();
12
16
  });
@@ -0,0 +1,51 @@
1
+ /*jshint esversion: 6, strict: false */
2
+
3
+ // How to use:
4
+ //<form data-checkbox-toggle="{key}">
5
+ // <input type="checkbox" data-checkbox-toggle-all="{key}" />
6
+ //
7
+ // <input type="checkbox" data-checkbox-toggle-each="{key}" />
8
+ // <input type="checkbox" data-checkbox-toggle-each="{key}" />
9
+ // ...
10
+
11
+ export default function checkboxToggle() {
12
+ document.querySelectorAll("form[data-checkbox-toggle]").forEach(function (form) {
13
+ const keyName = form.dataset.checkboxToggle;
14
+ const checkboxToggle = form.querySelector(`input[type=checkbox][data-checkbox-toggle-all=${keyName}]`);
15
+ const checkboxes = form.querySelectorAll(`input[type=checkbox][data-checkbox-toggle-each=${keyName}]`);
16
+ const showables = form.querySelectorAll(`[data-checkbox-toggle-show=${keyName}]`);
17
+
18
+ // Check or uncheck all checkboxes
19
+ checkboxToggle.addEventListener("change", function (event) {
20
+ checkboxes.forEach(function (checkbox) {
21
+ checkbox.checked = checkboxToggle.checked;
22
+ });
23
+
24
+ showables.forEach(function (showable) {
25
+ showable.classList.toggle("d-none", !checkboxToggle.checked);
26
+ showable.disabled = ! checkboxToggle.checked;
27
+ });
28
+ });
29
+
30
+ // check or uncheck the "all" checkbox when all checkboxes are checked or unchecked
31
+ form.addEventListener("change", function (event) {
32
+ if (!event.target.matches(`input[type=checkbox][data-checkbox-toggle-each=${keyName}]`)) {
33
+ return;
34
+ }
35
+ const checkedCount = Array.from(checkboxes).filter(function (checkbox) {
36
+ return checkbox.checked;
37
+ }).length;
38
+
39
+ const allChecked = checkedCount === checkboxes.length;
40
+ const indeterminateChecked = !allChecked && checkedCount > 0;
41
+
42
+ checkboxToggle.checked = allChecked;
43
+ checkboxToggle.indeterminate = indeterminateChecked;
44
+
45
+ showables.forEach(function (showable) {
46
+ showable.classList.toggle("d-none", !allChecked);
47
+ showable.disabled = !allChecked;
48
+ });
49
+ });
50
+ });
51
+ }
@@ -0,0 +1,81 @@
1
+ /*jshint esversion: 6, strict: false */
2
+ import renderCharts from "charts";
3
+
4
+ const MINIMUM_POLL_INTERVAL = 1;
5
+ const STORAGE_KEY = "live_poll";
6
+
7
+ function getStorage(key) {
8
+ const value = localStorage.getItem('good_job-' + key);
9
+
10
+ if (value === 'true') {
11
+ return true;
12
+ } else if (value === 'false') {
13
+ return false;
14
+ } else {
15
+ return value;
16
+ }
17
+ }
18
+
19
+ function setStorage(key, value) {
20
+ localStorage.setItem('good_job-' + key, value);
21
+ }
22
+
23
+ function removeStorage(key) {
24
+ localStorage.removeItem('good_job-' + key);
25
+ }
26
+
27
+ export default class LivePoll {
28
+ start() {
29
+ const checkbox = document.querySelector('input[name="live_poll"]');
30
+
31
+ if (!checkbox.checked && getStorage(STORAGE_KEY)) {
32
+ checkbox.checked = true;
33
+ checkbox.value = getStorage(STORAGE_KEY)
34
+ }
35
+
36
+ checkbox.addEventListener('change', () => {
37
+ this.togglePolling();
38
+ });
39
+
40
+ this.togglePolling();
41
+ }
42
+
43
+ togglePolling = () => {
44
+ const checkbox = document.querySelector('input[name="live_poll"]');
45
+ const enabled = checkbox.checked;
46
+ const pollIntervalMilliseconds = Math.max(parseInt(checkbox.value), MINIMUM_POLL_INTERVAL) * 1000;
47
+
48
+ if (this.interval) {
49
+ clearInterval(this.interval);
50
+ this.interval = null;
51
+ }
52
+
53
+ if (enabled) {
54
+ setStorage(STORAGE_KEY, checkbox.value);
55
+ this.interval = setInterval(LivePoll.refreshPage, pollIntervalMilliseconds);
56
+ } else {
57
+ removeStorage(STORAGE_KEY);
58
+ }
59
+ }
60
+
61
+ static refreshPage() {
62
+ fetch(window.location.href)
63
+ .then(resp => resp.text())
64
+ .then(LivePoll.updatePageContent);
65
+ }
66
+
67
+ static updatePageContent(newContent) {
68
+ const domParser = new DOMParser();
69
+ const newDom = domParser.parseFromString(newContent, "text/html");
70
+
71
+ const newElements = newDom.querySelectorAll('[data-live-poll-region]');
72
+ newElements.forEach((newElement) => {
73
+ const regionName = newElement.getAttribute('data-live-poll-region');
74
+ const originalElement = document.querySelector(`[data-live-poll-region="${regionName}"]`);
75
+
76
+ originalElement.replaceWith(newElement);
77
+ });
78
+
79
+ renderCharts(false);
80
+ }
81
+ }
@@ -6,12 +6,12 @@ module GoodJob
6
6
  end
7
7
 
8
8
  def show
9
- @cron_entry = CronEntry.find(params[:id])
9
+ @cron_entry = CronEntry.find(params[:cron_key])
10
10
  @jobs_filter = JobsFilter.new(params, @cron_entry.jobs)
11
11
  end
12
12
 
13
13
  def enqueue
14
- @cron_entry = CronEntry.find(params[:id])
14
+ @cron_entry = CronEntry.find(params[:cron_key])
15
15
  @cron_entry.enqueue(Time.current)
16
16
  redirect_back(fallback_location: cron_entries_path, notice: "Cron entry has been enqueued.")
17
17
  end
@@ -1,6 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
  module GoodJob
3
3
  class JobsController < GoodJob::ApplicationController
4
+ DISCARD_MESSAGE = "Discarded through dashboard"
5
+
6
+ ACTIONS = {
7
+ discard: "discarded",
8
+ reschedule: "rescheduled",
9
+ retry: "retried",
10
+ }.freeze
11
+
4
12
  rescue_from GoodJob::ActiveJobJob::AdapterNotGoodJobError,
5
13
  GoodJob::ActiveJobJob::ActionForStateMismatchError,
6
14
  with: :redirect_on_error
@@ -9,6 +17,41 @@ module GoodJob
9
17
  @filter = JobsFilter.new(params)
10
18
  end
11
19
 
20
+ def mass_update
21
+ mass_action = params.fetch(:mass_action, "").to_sym
22
+ raise ActionController::BadRequest, "#{mass_action} is not a valid mass action" unless mass_action.in?(ACTIONS.keys)
23
+
24
+ jobs = if params[:all_job_ids]
25
+ JobsFilter.new(params).filtered_query
26
+ else
27
+ job_ids = params.fetch(:job_ids, [])
28
+ ActiveJobJob.where(active_job_id: job_ids)
29
+ end
30
+
31
+ processed_jobs = jobs.map do |job|
32
+ case mass_action
33
+ when :discard
34
+ job.discard_job(DISCARD_MESSAGE)
35
+ when :reschedule
36
+ job.reschedule_job
37
+ when :retry
38
+ job.retry_job
39
+ end
40
+
41
+ job
42
+ rescue GoodJob::ActiveJobJob::ActionForStateMismatchError
43
+ nil
44
+ end.compact
45
+
46
+ notice = if processed_jobs.any?
47
+ "Successfully #{ACTIONS[mass_action]} #{processed_jobs.count} #{'job'.pluralize(processed_jobs.count)}"
48
+ else
49
+ "No jobs were #{ACTIONS[mass_action]}"
50
+ end
51
+
52
+ redirect_back(fallback_location: jobs_path, notice: notice)
53
+ end
54
+
12
55
  def show
13
56
  @executions = GoodJob::Execution.active_job_id(params[:id])
14
57
  .order(Arel.sql("COALESCE(scheduled_at, created_at) DESC"))
@@ -17,7 +60,7 @@ module GoodJob
17
60
 
18
61
  def discard
19
62
  @job = ActiveJobJob.find(params[:id])
20
- @job.discard_job("Discarded through dashboard")
63
+ @job.discard_job(DISCARD_MESSAGE)
21
64
  redirect_back(fallback_location: jobs_path, notice: "Job has been discarded")
22
65
  end
23
66
 
@@ -47,6 +47,7 @@ module GoodJob
47
47
  queue_name: params[:queue_name],
48
48
  query: params[:query],
49
49
  state: params[:state],
50
+ cron_key: params[:cron_key],
50
51
  }.merge(override).delete_if { |_, v| v.blank? }
51
52
  end
52
53
 
@@ -54,6 +55,10 @@ module GoodJob
54
55
  raise NotImplementedError
55
56
  end
56
57
 
58
+ def filtered_count
59
+ filtered_query.count
60
+ end
61
+
57
62
  private
58
63
 
59
64
  def default_base_query
@@ -13,12 +13,12 @@ module GoodJob
13
13
  end
14
14
 
15
15
  def filtered_query
16
- query = base_query.includes(:executions)
17
- .joins_advisory_locks.select("#{GoodJob::ActiveJobJob.table_name}.*", 'pg_locks.locktype AS locktype')
16
+ query = base_query.includes(:executions).includes_advisory_locks
18
17
 
19
18
  query = query.job_class(params[:job_class]) if params[:job_class].present?
20
19
  query = query.where(queue_name: params[:queue_name]) if params[:queue_name].present?
21
20
  query = query.search_text(params[:query]) if params[:query].present?
21
+ query = query.where(cron_key: params[:cron_key]) if params[:cron_key].present?
22
22
 
23
23
  if params[:state]
24
24
  case params[:state]
@@ -40,6 +40,10 @@ module GoodJob
40
40
  query
41
41
  end
42
42
 
43
+ def filtered_count
44
+ filtered_query.unscope(:select).count
45
+ end
46
+
43
47
  private
44
48
 
45
49
  def default_base_query
@@ -20,5 +20,11 @@ module GoodJob
20
20
 
21
21
  content_tag :span, status.to_s, class: classes
22
22
  end
23
+
24
+ def render_icon(name)
25
+ # workaround to render svg icons without all of the log messages
26
+ partial = lookup_context.find_template("good_job/shared/icons/#{name}", [], true)
27
+ partial.render(self, {})
28
+ end
23
29
  end
24
30
  end
@@ -3,4 +3,4 @@
3
3
  <% end %>
4
4
 
5
5
  <%= render 'good_job/shared/filter', title: title, filter: @jobs_filter %>
6
- <%= render 'good_job/jobs/table', jobs: @jobs_filter.records %>
6
+ <%= render 'good_job/jobs/table', jobs: @jobs_filter.records, filter: @jobs_filter %>
@@ -1,4 +1,4 @@
1
- <div class="my-3" data-gj-poll-replace id="executions-table">
1
+ <div class="my-3" data-live-poll-region="executions-table">
2
2
  <div class="table-responsive">
3
3
  <table class="table table-hover table-sm mb-0" id="executions_index_table">
4
4
  <thead>
@@ -46,7 +46,7 @@
46
46
  </td>
47
47
  <td>
48
48
  <%= button_to execution_path(execution.id), method: :delete, class: "btn btn-sm btn-outline-danger", title: "Delete execution", data: { confirm: "Confirm delete" } do %>
49
- <%= render "good_job/shared/icons/trash" %>
49
+ <%= render_icon "trash" %>
50
50
  <% end %>
51
51
  </td>
52
52
  </tr>
@@ -1,72 +1,110 @@
1
- <div class="my-3" data-gj-poll-replace id="jobs-table">
1
+ <div class="my-3">
2
2
  <div class="table-responsive">
3
- <table class="table table-hover table-sm mb-0">
4
- <thead>
5
- <tr>
6
- <th>ActiveJob ID</th>
7
- <th>State</th>
8
- <th>Job Class</th>
9
- <th>Queue</th>
10
- <th>Scheduled At</th>
11
- <th>Executions</th>
12
- <th>Error</th>
13
- <th>
14
- ActiveJob Params&nbsp;
15
- <%= tag.button "Toggle", type: "button", class: "btn btn-sm btn-outline-primary", role: "button",
16
- data: { bs_toggle: "collapse", bs_target: ".job-params" },
17
- aria: { expanded: false, controls: jobs.map { |job| "##{dom_id(job, "params")}" }.join(" ") }
18
- %>
19
- </th>
20
- <th>Actions</th>
21
- </tr>
22
- </thead>
23
- <tbody>
24
- <% if jobs.present? %>
25
- <% jobs.each do |job| %>
26
- <tr class="<%= dom_class(job) %>" id="<%= dom_id(job) %>">
27
- <td>
28
- <%= link_to job_path(job.id) do %>
29
- <code><%= job.id %></code>
3
+ <%= form_with(url: mass_update_jobs_path(filter.to_params), method: :put, local: true, data: { "checkbox-toggle": "job_ids" }) do |form| %>
4
+ <table class="table table-hover table-sm mb-0 table-jobs">
5
+ <thead>
6
+ <tr>
7
+ <th><%= check_box_tag('toggle_job_ids', "1", false, data: { "checkbox-toggle-all": "job_ids" }) %></th>
8
+ <th>ActiveJob ID</th>
9
+ <th>State</th>
10
+ <th>Job Class</th>
11
+ <th>Queue</th>
12
+ <th>Scheduled At</th>
13
+ <th>Executions</th>
14
+ <th>Error</th>
15
+ <th>
16
+ ActiveJob Params&nbsp;
17
+ <%= tag.button "Toggle", type: "button", class: "btn btn-sm btn-outline-primary", role: "button",
18
+ data: { bs_toggle: "collapse", bs_target: ".job-params" },
19
+ aria: { expanded: false, controls: jobs.map { |job| "##{dom_id(job, "params")}" }.join(" ") }
20
+ %>
21
+ </th>
22
+ <th>
23
+ Actions<br>
24
+
25
+ <div class="d-inline text-nowrap">
26
+ <%= 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 %>
27
+ <%= render_icon "skip_forward" %> All
30
28
  <% end %>
31
- </td>
32
- <td><%= status_badge(job.status) %></td>
33
- <td><%= job.job_class %></td>
34
- <td><%= job.queue_name %></td>
35
- <td><%= relative_time(job.scheduled_at || job.created_at) %></td>
36
- <td><%= job.executions_count %></td>
37
- <td class="text-break"><%= truncate(job.recent_error, length: 1_000) %></td>
38
- <td>
39
- <%= tag.button "Preview", type: "button", class: "btn btn-sm btn-outline-primary", role: "button",
40
- data: { bs_toggle: "collapse", bs_target: "##{dom_id(job, 'params')}" },
41
- aria: { expanded: false, controls: dom_id(job, "params") }
42
- %>
43
- <%= tag.pre JSON.pretty_generate(job.serialized_params), id: dom_id(job, "params"), class: "collapse job-params" %>
44
- </td>
45
- <td>
46
- <div class="text-nowrap">
47
- <% job_reschedulable = job.status.in? [:scheduled, :retried, :queued] %>
48
- <%= 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 %>
49
- <%= render "good_job/shared/icons/skip_forward" %>
50
- <% end %>
51
29
 
52
- <% job_discardable = job.status.in? [:scheduled, :retried, :queued] %>
53
- <%= 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 %>
54
- <%= render "good_job/shared/icons/stop" %>
55
- <% end %>
30
+ <%= 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 %>
31
+ <%= render_icon "stop" %> All
32
+ <% end %>
56
33
 
57
- <%= 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 %>
58
- <%= render "good_job/shared/icons/arrow_clockwise" %>
34
+ <%= 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 %>
35
+ <%= render_icon "arrow_clockwise" %> All
36
+ <% end %>
37
+ </div>
38
+ </th>
39
+ </tr>
40
+ <tr class="d-none" data-checkbox-toggle-show="job_ids">
41
+ <td class="text-center table-warning" colspan="10">
42
+ <label>
43
+ <%= check_box_tag "all_job_ids", 1, false, disabled: true, data: { "checkbox-toggle-show": "job_ids"} %>
44
+ Apply to all <%= filter.filtered_count %> <%= "job".pluralize(filter.filtered_count) %>.
45
+ </label>
46
+ </td>
47
+ </tr>
48
+ </thead>
49
+ <tbody>
50
+ <% if jobs.present? %>
51
+ <% jobs.each do |job| %>
52
+ <tr class="<%= dom_class(job) %>" id="<%= dom_id(job) %>">
53
+ <td><%= check_box_tag 'job_ids[]', job.id, false, data: { "checkbox-toggle-each": "job_ids" } %></td>
54
+ <td>
55
+ <%= link_to job_path(job.id) do %>
56
+ <code><%= job.id %></code>
59
57
  <% end %>
60
- </div>
61
- </td>
58
+ </td>
59
+ <td><%= status_badge(job.status) %></td>
60
+ <td><%= job.job_class %></td>
61
+ <td><%= job.queue_name %></td>
62
+ <td><%= relative_time(job.scheduled_at || job.created_at) %></td>
63
+ <td><%= job.executions_count %></td>
64
+ <td class="text-break"><%= truncate(job.recent_error, length: 1_000) %></td>
65
+ <td>
66
+ <%= tag.button "Preview", type: "button", class: "btn btn-sm btn-outline-primary", role: "button",
67
+ data: { bs_toggle: "collapse", bs_target: "##{dom_id(job, 'params')}" },
68
+ aria: { expanded: false, controls: dom_id(job, "params") }
69
+ %>
70
+ <%= tag.pre JSON.pretty_generate(job.serialized_params), id: dom_id(job, "params"), class: "collapse job-params" %>
71
+ </td>
72
+ <td>
73
+ <div class="text-nowrap">
74
+ <% if job.status.in? [:scheduled, :retried, :queued] %>
75
+ <%= 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 %>
76
+ <%= render_icon "skip_forward" %>
77
+ <% end %>
78
+ <% else %>
79
+ <button class="btn btn-sm btn-outline-secondary" disabled><%= render_icon "skip_forward" %></button>
80
+ <% end %>
81
+
82
+ <% if job.status.in? [:scheduled, :retried, :queued] %>
83
+ <%= 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 %>
84
+ <%= render_icon "stop" %>
85
+ <% end %>
86
+ <% else %>
87
+ <button class="btn btn-sm btn-outline-secondary" disabled><%= render_icon "stop" %></button>
88
+ <% end %>
89
+
90
+ <% if job.status == :discarded %>
91
+ <%= 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 %>
92
+ <%= render_icon "arrow_clockwise" %>
93
+ <% end %>
94
+ <% else %>
95
+ <button class="btn btn-sm btn-outline-secondary" disabled><%= render_icon "arrow_clockwise" %></button>
96
+ <% end %>
97
+ </div>
98
+ </td>
99
+ </tr>
100
+ <% end %>
101
+ <% else %>
102
+ <tr>
103
+ <td colspan="10" class="py-2 text-center text-muted">No jobs found.</td>
62
104
  </tr>
63
105
  <% end %>
64
- <% else %>
65
- <tr>
66
- <td colspan="8" class="py-2 text-center text-muted">No jobs found.</td>
67
- </tr>
68
- <% end %>
69
- </tbody>
70
- </table>
106
+ </tbody>
107
+ </table>
108
+ <% end %>
71
109
  </div>
72
110
  </div>
@@ -1,15 +1,17 @@
1
1
  <%= render 'good_job/shared/filter', title: "Jobs", filter: @filter %>
2
2
  <%= render 'good_job/shared/chart', chart_data: GoodJob::ScheduledByQueueChart.new(@filter).data %>
3
- <%= render 'good_job/jobs/table', jobs: @filter.records %>
4
3
 
5
- <% if @filter.records.present? %>
6
- <nav aria-label="Job pagination" class="mt-3" data-gj-poll-replace id="jobs-pagination">
7
- <ul class="pagination">
8
- <li class="page-item">
9
- <%= link_to(@filter.to_params(after_scheduled_at: (@filter.last.scheduled_at || @filter.last.created_at), after_id: @filter.last.id), class: "page-link") do %>
10
- Older jobs <span aria-hidden="true">&raquo;</span>
11
- <% end %>
12
- </li>
13
- </ul>
14
- </nav>
15
- <% end %>
4
+ <div data-live-poll-region="jobs-table">
5
+ <%= render 'good_job/jobs/table', jobs: @filter.records, filter: @filter %>
6
+ <% if @filter.records.present? %>
7
+ <nav aria-label="Job pagination" class="mt-3">
8
+ <ul class="pagination">
9
+ <li class="page-item">
10
+ <%= link_to(@filter.to_params(after_scheduled_at: (@filter.last.scheduled_at || @filter.last.created_at), after_id: @filter.last.id), class: "page-link") do %>
11
+ Older jobs <span aria-hidden="true">&raquo;</span>
12
+ <% end %>
13
+ </li>
14
+ </ul>
15
+ </nav>
16
+ <% end %>
17
+ </div>
@@ -2,7 +2,7 @@
2
2
  <h2>Processes</h2>
3
3
  </div>
4
4
 
5
- <div data-gj-poll-replace id="processes">
5
+ <div data-live-poll-region="processes">
6
6
  <% if !GoodJob::Process.migrated? %>
7
7
  <div class="card my-3">
8
8
  <div class="card-body">
@@ -1,4 +1,4 @@
1
- <div class="py-4" data-gj-poll-replace id="chart">
1
+ <div class="py-4" data-live-poll-region="chart">
2
2
  <div class="chart-wrapper container-fluid">
3
3
  <canvas class="chart" data-json="<%= chart_data.to_json %>"></canvas>
4
4
  </div>
@@ -1,4 +1,4 @@
1
- <div data-gj-poll-replace id="filter">
1
+ <div data-live-poll-region id="filter">
2
2
  <div class="bg-light break-out">
3
3
  <h2 class="container-fluid pt-3 pb-2"><%= title %></h2>
4
4
 
@@ -1,4 +1,4 @@
1
- <footer class="footer mt-auto py-3 bg-light border-top text-muted small" id="footer" data-gj-poll-replace>
1
+ <footer class="footer mt-auto py-3 bg-light border-top text-muted small" id="footer" data-live-poll-region="footer">
2
2
  <div class="container-fluid">
3
3
  <div class="row">
4
4
  <div class="col-6">
@@ -14,14 +14,14 @@
14
14
  <%= link_to t(".cron_schedules"), cron_entries_path, class: ["nav-link", ("active" if controller_name == 'cron_entries')] %>
15
15
  </li>
16
16
  <li class="nav-item">
17
- <%= link_to t(".processes"), processes_path, class: ["nav-link", ("active" if controller_name == 'processes')] %>
17
+ <%= link_to t(".processes"), processes_path, class: ["nav-link", ("active" if controller_name == 'processes')] %>
18
18
  </li>
19
19
  </ul>
20
20
  <div class="nav-item pe-2">
21
- <div class="form-check">
22
- <input type="checkbox" id="toggle-poll" name="toggle-poll" <%= 'checked' if params[:poll].present? %>>
23
- <label for="toggle-poll"><%= t(".live_poll") %></label>
24
- </div>
21
+ <label>
22
+ <%= check_box_tag "live_poll", params.fetch("poll", 30), params[:poll].present? %>
23
+ <%= t(".live_poll") %>
24
+ </label>
25
25
  </div>
26
26
  <ul class="navbar-nav">
27
27
  <li class="nav-item dropdown">
@@ -5,6 +5,11 @@ GoodJob::Engine.routes.draw do
5
5
  resources :executions, only: %i[destroy]
6
6
 
7
7
  resources :jobs, only: %i[index show] do
8
+ collection do
9
+ get :mass_update, to: redirect(path: 'jobs')
10
+ put :mass_update
11
+ end
12
+
8
13
  member do
9
14
  put :discard
10
15
  put :reschedule
@@ -12,7 +17,7 @@ GoodJob::Engine.routes.draw do
12
17
  end
13
18
  end
14
19
 
15
- resources :cron_entries, only: %i[index show] do
20
+ resources :cron_entries, only: %i[index show], param: :cron_key do
16
21
  member do
17
22
  post :enqueue
18
23
  end
@@ -148,7 +148,7 @@ module GoodJob
148
148
  # Tests whether the job is being executed right now.
149
149
  # @return [Boolean]
150
150
  def running?
151
- # Avoid N+1 Query: `.joins_advisory_locks.select('good_jobs.*', 'pg_locks.locktype AS locktype')`
151
+ # Avoid N+1 Query: `.includes_advisory_locks`
152
152
  if has_attribute?(:locktype)
153
153
  self['locktype'].present?
154
154
  else
@@ -157,7 +157,7 @@ module GoodJob
157
157
  end
158
158
 
159
159
  # Retry a job that has errored and been discarded.
160
- # This action will create a new job {Execution} record.
160
+ # This action will create a new {Execution} record for the job.
161
161
  # @return [ActiveJob::Base]
162
162
  def retry_job
163
163
  with_advisory_lock do
@@ -165,7 +165,7 @@ module GoodJob
165
165
  active_job = execution.active_job
166
166
 
167
167
  raise AdapterNotGoodJobError unless active_job.class.queue_adapter.is_a? GoodJob::Adapter
168
- raise ActionForStateMismatchError unless status == :discarded
168
+ raise ActionForStateMismatchError if execution.finished_at.blank? || execution.error.blank?
169
169
 
170
170
  # Update the executions count because the previous execution will not have been preserved
171
171
  # Do not update `exception_executions` because that comes from rescue_from's arguments
@@ -176,7 +176,7 @@ module GoodJob
176
176
  current_thread.execution = execution
177
177
 
178
178
  execution.class.transaction(joinable: false, requires_new: true) do
179
- new_active_job = active_job.retry_job(wait: 0, error: error)
179
+ new_active_job = active_job.retry_job(wait: 0, error: execution.error)
180
180
  execution.save
181
181
  end
182
182
  end
@@ -189,11 +189,11 @@ module GoodJob
189
189
  # @return [void]
190
190
  def discard_job(message)
191
191
  with_advisory_lock do
192
- raise ActionForStateMismatchError unless status.in? [:scheduled, :queued, :retried]
193
-
194
192
  execution = head_execution(reload: true)
195
193
  active_job = execution.active_job
196
194
 
195
+ raise ActionForStateMismatchError if execution.finished_at.present?
196
+
197
197
  job_error = GoodJob::ActiveJobJob::DiscardJobError.new(message)
198
198
 
199
199
  update_execution = proc do
@@ -216,7 +216,9 @@ module GoodJob
216
216
  # @return [void]
217
217
  def reschedule_job(scheduled_at = Time.current)
218
218
  with_advisory_lock do
219
- raise ActionForStateMismatchError unless status.in? [:scheduled, :queued, :retried]
219
+ execution = head_execution(reload: true)
220
+
221
+ raise ActionForStateMismatchError if execution.finished_at.present?
220
222
 
221
223
  execution = head_execution(reload: true)
222
224
  execution.update(scheduled_at: scheduled_at)
@@ -159,7 +159,7 @@ module GoodJob
159
159
  alias enable_cron? enable_cron
160
160
 
161
161
  def cron
162
- env_cron = JSON.parse(ENV['GOOD_JOB_CRON'], symbolize_names: true) if ENV['GOOD_JOB_CRON'].present?
162
+ env_cron = JSON.parse(ENV.fetch('GOOD_JOB_CRON'), symbolize_names: true) if ENV['GOOD_JOB_CRON'].present?
163
163
 
164
164
  options[:cron] ||
165
165
  rails_config[:cron] ||
@@ -82,6 +82,16 @@ module GoodJob
82
82
  joins(sanitize_sql_for_conditions([join_sql, { table_name: table_name }]))
83
83
  end)
84
84
 
85
+ # Joins the current query with Postgres's +pg_locks+ table AND SELECTs the resulting columns
86
+ # @!method joins_advisory_locks(column: _advisory_lockable_column)
87
+ # @!scope class
88
+ # @param column [String, Symbol] column values to Advisory Lock against
89
+ # @return [ActiveRecord::Relation]
90
+ scope :includes_advisory_locks, (lambda do |column: _advisory_lockable_column|
91
+ owns_advisory_lock_sql = "#{connection.quote_table_name('pg_locks')}.#{connection.quote_column_name('pid')} = pg_backend_pid() AS owns_advisory_lock"
92
+ joins_advisory_locks(column: column).select("#{quoted_table_name}.*, #{connection.quote_table_name('pg_locks')}.locktype, #{owns_advisory_lock_sql}")
93
+ end)
94
+
85
95
  # Find records that do not have an advisory lock on them.
86
96
  # @!method advisory_unlocked(column: _advisory_lockable_column)
87
97
  # @!scope class
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
  module GoodJob
3
3
  # GoodJob gem version.
4
- VERSION = '2.13.2'
4
+ VERSION = '2.14.2'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: good_job
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.13.2
4
+ version: 2.14.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben Sheldon
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2022-04-25 00:00:00.000000000 Z
11
+ date: 2022-05-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activejob
@@ -362,8 +362,9 @@ files:
362
362
  - README.md
363
363
  - engine/app/assets/good_job/modules/application.js
364
364
  - engine/app/assets/good_job/modules/charts.js
365
+ - engine/app/assets/good_job/modules/checkbox_toggle.js
365
366
  - engine/app/assets/good_job/modules/document_ready.js
366
- - engine/app/assets/good_job/modules/poller.js
367
+ - engine/app/assets/good_job/modules/live_poll.js
367
368
  - engine/app/assets/good_job/modules/toasts.js
368
369
  - engine/app/assets/good_job/scripts.js
369
370
  - engine/app/assets/good_job/style.css
@@ -1,93 +0,0 @@
1
- /*jshint esversion: 6, strict: false */
2
- import renderCharts from "charts";
3
-
4
- // NOTE: this file is a bit disorganized. Please do not use it as a template for how to organize a JS module.
5
-
6
- const DEFAULT_POLL_INTERVAL_SECONDS = 30;
7
- const MINIMUM_POLL_INTERVAL = 1000;
8
-
9
- function getStorage(key) {
10
- const value = localStorage.getItem('good_job-' + key);
11
-
12
- if (value === 'true') {
13
- return true;
14
- } else if (value === 'false') {
15
- return false;
16
- } else {
17
- return value;
18
- }
19
- }
20
-
21
- function setStorage(key, value) {
22
- localStorage.setItem('good_job-' + key, value);
23
- }
24
-
25
- function updatePageContent(newContent) {
26
- const domParser = new DOMParser();
27
- const parsedDOM = domParser.parseFromString(newContent, "text/html");
28
-
29
- const newElements = parsedDOM.querySelectorAll('[data-gj-poll-replace]');
30
-
31
- for (let i = 0; i < newElements.length; i++) {
32
- const newEl = newElements[i];
33
- const oldEl = document.getElementById(newEl.id);
34
-
35
- if (oldEl) {
36
- oldEl.replaceWith(newEl);
37
- }
38
- }
39
-
40
- renderCharts(false);
41
- }
42
-
43
- function refreshPage() {
44
- fetch(window.location.href)
45
- .then(resp => resp.text())
46
- .then(updatePageContent);
47
- }
48
-
49
- const Poller = {
50
- start: () => {
51
- Poller.updateSettings();
52
- Poller.pollUpdates();
53
-
54
- const checkbox = document.querySelector('input[name="toggle-poll"]');
55
- checkbox.addEventListener('change', Poller.togglePoll)
56
- },
57
-
58
- togglePoll: (event) => {
59
- Poller.pollEnabled = event.currentTarget.checked;
60
- setStorage('pollEnabled', Poller.pollEnabled);
61
- },
62
-
63
- updateSettings: () => {
64
- const queryString = window.location.search;
65
- const urlParams = new URLSearchParams(queryString);
66
-
67
- if (urlParams.has('poll')) {
68
- const parsedInterval = (parseInt(urlParams.get('poll')) || DEFAULT_POLL_INTERVAL_SECONDS) * 1000;
69
- Poller.pollInterval = Math.max(parsedInterval, MINIMUM_POLL_INTERVAL);
70
- setStorage('pollInterval', Poller.pollInterval);
71
-
72
- Poller.pollEnabled = true;
73
- } else {
74
- Poller.pollInterval = getStorage('pollInterval') || (DEFAULT_POLL_INTERVAL_SECONDS * 1000);
75
- Poller.pollEnabled = getStorage('pollEnabled') || false;
76
- }
77
-
78
- document.getElementById('toggle-poll').checked = Poller.pollEnabled;
79
- },
80
-
81
- pollUpdates: () => {
82
- setTimeout(() => {
83
- if (Poller.pollEnabled === true) {
84
- refreshPage();
85
- Poller.pollUpdates();
86
- } else {
87
- Poller.pollUpdates();
88
- }
89
- }, Poller.pollInterval);
90
- },
91
- };
92
-
93
- export { Poller as default };