good_job 4.19.2 → 4.19.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +1 -1
  4. data/app/charts/good_job/performance_index_chart.rb +50 -17
  5. data/app/charts/good_job/performance_range.rb +380 -0
  6. data/app/charts/good_job/performance_show_chart.rb +4 -3
  7. data/app/charts/good_job/scheduled_by_queue_chart.rb +2 -2
  8. data/app/controllers/good_job/cleaner_controller.rb +1 -1
  9. data/app/controllers/good_job/performance_controller.rb +28 -3
  10. data/app/frontend/good_job/application.js +2 -0
  11. data/app/frontend/good_job/modules/chart_controller.js +252 -2
  12. data/app/frontend/good_job/modules/performance_range_controller.js +321 -0
  13. data/app/frontend/good_job/modules/timezone_name_formatter.js +13 -0
  14. data/app/frontend/good_job/style.css +97 -0
  15. data/app/helpers/good_job/application_helper.rb +16 -0
  16. data/app/models/good_job/cron_entry.rb +34 -4
  17. data/app/models/good_job/setting.rb +16 -4
  18. data/app/views/good_job/batches/show.html.erb +1 -1
  19. data/app/views/good_job/performance/_range.erb +91 -0
  20. data/app/views/good_job/performance/index.html.erb +17 -5
  21. data/app/views/good_job/performance/show.html.erb +4 -2
  22. data/config/locales/az.yml +321 -0
  23. data/config/locales/de.yml +24 -0
  24. data/config/locales/en.yml +24 -0
  25. data/config/locales/es.yml +24 -0
  26. data/config/locales/fr.yml +24 -0
  27. data/config/locales/it.yml +24 -0
  28. data/config/locales/ja.yml +24 -0
  29. data/config/locales/ko.yml +24 -0
  30. data/config/locales/nl.yml +24 -0
  31. data/config/locales/pt-BR.yml +24 -0
  32. data/config/locales/ru.yml +24 -0
  33. data/config/locales/tr.yml +24 -0
  34. data/config/locales/uk.yml +24 -0
  35. data/config/locales/zh-CN.yml +24 -0
  36. data/lib/generators/good_job/templates/install/migrations/create_good_jobs.rb.erb +3 -0
  37. data/lib/generators/good_job/templates/update/migrations/15_add_index_good_jobs_discarded_job_class.rb.erb +13 -0
  38. data/lib/good_job/adapter.rb +12 -7
  39. data/lib/good_job/configuration/validator.rb +42 -0
  40. data/lib/good_job/configuration.rb +12 -0
  41. data/lib/good_job/probe_server.rb +12 -2
  42. data/lib/good_job/version.rb +1 -1
  43. data/lib/good_job.rb +3 -3
  44. metadata +9 -2
@@ -3,6 +3,103 @@
3
3
  height: 200px;
4
4
  }
5
5
 
6
+ .chart-range-selection {
7
+ position: absolute;
8
+ pointer-events: none;
9
+ background-color: rgba(var(--bs-primary-rgb), 0.2);
10
+ border: 1px solid rgba(var(--bs-primary-rgb), 0.75);
11
+ }
12
+
13
+ .performance-range-control {
14
+ align-items: stretch;
15
+ display: flex;
16
+ flex-wrap: wrap;
17
+ gap: 0.25rem;
18
+ justify-content: flex-start;
19
+ max-width: 100%;
20
+ width: 100%;
21
+ }
22
+
23
+ .performance-range-state,
24
+ .performance-range-control .btn {
25
+ align-items: center;
26
+ display: inline-flex;
27
+ }
28
+
29
+ .performance-range-state {
30
+ background-color: var(--bs-body-bg);
31
+ border: var(--bs-border-width) solid var(--bs-border-color);
32
+ border-radius: var(--bs-border-radius);
33
+ color: var(--bs-body-color);
34
+ font-size: 1rem;
35
+ line-height: 1.5;
36
+ padding: 0.375rem 0.75rem;
37
+ white-space: nowrap;
38
+ }
39
+
40
+ .performance-range-date {
41
+ cursor: pointer;
42
+ gap: 0.25rem;
43
+ position: relative;
44
+ }
45
+
46
+ .performance-range-native-input {
47
+ cursor: pointer;
48
+ height: 100%;
49
+ inset: 0;
50
+ opacity: 0;
51
+ position: absolute;
52
+ width: 100%;
53
+ z-index: 1;
54
+ }
55
+
56
+ .performance-range-date:focus-within {
57
+ border-color: var(--bs-primary);
58
+ box-shadow: 0 0 0 0.125rem var(--bs-body-bg);
59
+ outline: 0.1875rem solid var(--bs-primary);
60
+ outline-offset: 0.125rem;
61
+ z-index: 2;
62
+ }
63
+
64
+ .performance-range-date:has(.performance-range-native-input:invalid) {
65
+ border-color: var(--bs-danger);
66
+ }
67
+
68
+ .performance-range-toggle {
69
+ margin-left: calc(-1 * var(--bs-border-width));
70
+ }
71
+
72
+ .performance-range-toggle:focus-visible {
73
+ border-color: var(--bs-primary);
74
+ box-shadow: 0 0 0 0.125rem var(--bs-body-bg);
75
+ outline: 0.1875rem solid var(--bs-primary);
76
+ outline-offset: 0.125rem;
77
+ position: relative;
78
+ z-index: 2;
79
+ }
80
+
81
+ .performance-range-key,
82
+ .performance-range-option-key {
83
+ justify-content: center;
84
+ min-width: 2.5rem;
85
+ }
86
+
87
+ .performance-range-menu {
88
+ min-width: 14rem;
89
+ }
90
+
91
+ .performance-name {
92
+ overflow-wrap: anywhere;
93
+ white-space: normal;
94
+ }
95
+
96
+ @media (max-width: 575.98px) {
97
+ .performance-range-control {
98
+ gap: 0.125rem;
99
+ width: 100%;
100
+ }
101
+ }
102
+
6
103
  .legend-item-color-box {
7
104
  display: inline-block;
8
105
  flex-shrink: 0;
@@ -34,6 +34,22 @@ module GoodJob
34
34
  end
35
35
  end
36
36
 
37
+ def format_performance_bucket_size(seconds)
38
+ if seconds >= 365.days && (seconds % 365.days.to_i).zero?
39
+ t "good_job.performance.range.bucket_size.years", count: seconds / 365.days.to_i
40
+ elsif seconds >= 30.days && (seconds % 30.days.to_i).zero?
41
+ t "good_job.performance.range.bucket_size.months", count: seconds / 30.days.to_i
42
+ elsif seconds >= 1.day && (seconds % 1.day.to_i).zero?
43
+ t "good_job.performance.range.bucket_size.days", count: seconds / 1.day.to_i
44
+ elsif (seconds % 1.hour.to_i).zero?
45
+ t "good_job.performance.range.bucket_size.hours", count: seconds / 1.hour.to_i
46
+ elsif (seconds % 1.minute.to_i).zero?
47
+ t "good_job.performance.range.bucket_size.minutes", count: seconds / 1.minute.to_i
48
+ else
49
+ t "good_job.performance.range.bucket_size.seconds", count: seconds
50
+ end
51
+ end
52
+
37
53
  def relative_time(timestamp, **options)
38
54
  options = options.reverse_merge({ scope: "good_job.datetime.distance_in_words" })
39
55
  text = t("good_job.helpers.relative_time.#{timestamp.future? ? 'future' : 'past'}", time: time_ago_in_words(timestamp, **options))
@@ -13,6 +13,15 @@ module GoodJob # :nodoc:
13
13
 
14
14
  attr_reader :params
15
15
 
16
+ VALUE_TYPES = {
17
+ args: Array,
18
+ kwargs: Hash,
19
+ set: Hash,
20
+ }.freeze
21
+
22
+ validate :validate_job_class
23
+ validate :validate_value_types
24
+
16
25
  def self.all(configuration: nil)
17
26
  configuration ||= GoodJob.configuration
18
27
  configuration.cron_entries
@@ -28,8 +37,8 @@ module GoodJob # :nodoc:
28
37
 
29
38
  join_clause = <<~SQL.squish
30
39
  CROSS JOIN LATERAL (
31
- SELECT * FROM good_jobs
32
- WHERE good_jobs.cron_key = cron_keys.cron_key
40
+ SELECT * FROM #{GoodJob::Job.quoted_table_name}
41
+ WHERE #{GoodJob::Job.quoted_table_name}.cron_key = cron_keys.cron_key
33
42
  ORDER BY cron_at DESC NULLS LAST
34
43
  LIMIT 1
35
44
  ) AS lateral_jobs
@@ -126,8 +135,7 @@ module GoodJob # :nodoc:
126
135
  current_thread.cron_at = cron_at
127
136
 
128
137
  I18n.with_locale(I18n.default_locale) do
129
- job_klass = job_class_value
130
- job_klass = job_klass.constantize if job_klass.is_a?(String)
138
+ job_klass = resolve_job_class
131
139
  next unless job_klass.is_a?(Class)
132
140
 
133
141
  configured_job = job_klass.set(set_value)
@@ -175,6 +183,28 @@ module GoodJob # :nodoc:
175
183
 
176
184
  private
177
185
 
186
+ def validate_job_class
187
+ return if job_class.blank? || job_class.is_a?(Class) || job_class.respond_to?(:call)
188
+
189
+ resolve_job_class
190
+ rescue NameError
191
+ errors.add(:class, "'#{job_class}' does not exist")
192
+ end
193
+
194
+ def validate_value_types
195
+ VALUE_TYPES.each do |attribute, type|
196
+ value = public_send(attribute)
197
+ next if value.blank? || value.respond_to?(:call) || value.is_a?(type)
198
+
199
+ errors.add(attribute, "must be a #{type}")
200
+ end
201
+ end
202
+
203
+ def resolve_job_class
204
+ value = job_class_value
205
+ value.is_a?(String) ? value.constantize : value
206
+ end
207
+
178
208
  def cron
179
209
  params.fetch(:cron)
180
210
  end
@@ -101,10 +101,22 @@ module GoodJob
101
101
  setting.save!
102
102
  end
103
103
 
104
- def self.paused?(queue: nil, job_class: nil, label: nil)
105
- raise ArgumentError, "Must provide at most one of queue, job_class, or label" if [queue, job_class, label].many?(&:present?)
104
+ def self.paused?(active_job: nil, queue: nil, job_class: nil, label: nil)
105
+ raise ArgumentError, "Must provide at most one of active_job, queue, job_class, or label" if [active_job, queue, job_class, label].many?(&:present?)
106
106
 
107
- if queue
107
+ if active_job
108
+ pauses = paused
109
+
110
+ return :queue_name_paused if pauses[:queues].include?(active_job.queue_name.to_s)
111
+ return :job_class_paused if pauses[:job_classes].include?(active_job.class.to_s)
112
+
113
+ labels = active_job.respond_to?(:good_job_labels) ? Array(active_job.good_job_labels) : []
114
+ labels = labels.filter_map { |label| label.to_s.presence }.uniq
115
+
116
+ return :label_paused if (labels & pauses[:labels]).any?
117
+
118
+ false
119
+ elsif queue
108
120
  queue.in? paused(:queues)
109
121
  elsif job_class
110
122
  job_class.in? paused(:job_classes)
@@ -116,7 +128,7 @@ module GoodJob
116
128
  end
117
129
 
118
130
  def self.paused(type = nil)
119
- setting = find_by(key: PAUSES)
131
+ setting = uncached { find_by(key: PAUSES) }
120
132
  pauses = setting&.value&.deep_dup || { "queues" => [], "job_classes" => [], "labels" => [] }
121
133
  pauses = pauses.with_indifferent_access
122
134
 
@@ -25,7 +25,7 @@
25
25
  <div class="my-4">
26
26
  <h5><%= t ".attributes" %></h5>
27
27
  <div class="bg-dark text-secondary p-3 rounded">
28
- <%= tag.pre JSON.pretty_generate @batch.display_attributes, class: 'text-wrap text-break' %>
28
+ <%= tag.pre JSON.pretty_generate(@batch.display_attributes), class: 'text-wrap text-break' %>
29
29
  </div>
30
30
  </div>
31
31
 
@@ -0,0 +1,91 @@
1
+ <% range_badge_label = performance_range.key || t(".custom_range") %>
2
+ <% reload_range_label = t(".reload_range") %>
3
+ <% reload_range_query = range_context.merge(performance_range.reload_params).to_query %>
4
+ <% reload_range_path = reload_range_query.present? ? "#{range_path}?#{reload_range_query}" : range_path %>
5
+ <% custom_range_path = "#{range_path}?#{range_context.merge(performance_range.custom_params).to_query}" %>
6
+ <% time_zone_label = Time.zone.name %>
7
+ <% range_form_data = {
8
+ action: "submit->performance-range#prepareSubmission",
9
+ controller: "performance-range",
10
+ performance_range_application_time_zone_value: time_zone_label,
11
+ performance_range_application_time_zone_identifier_value: Time.zone.tzinfo.name,
12
+ performance_range_end_fallback_value: performance_range.end_local_value,
13
+ performance_range_end_label_value: performance_range.end_label,
14
+ performance_range_end_timestamp_value: performance_range.canonical_timestamp(performance_range.end_time),
15
+ performance_range_label_style_value: performance_range.label_style,
16
+ performance_range_maximum_value: GoodJob::PerformanceRange::MAXIMUM_LOCAL_VALUE,
17
+ performance_range_minimum_value: GoodJob::PerformanceRange::MINIMUM_LOCAL_VALUE,
18
+ performance_range_start_fallback_value: performance_range.start_local_value,
19
+ performance_range_start_label_value: performance_range.start_label,
20
+ performance_range_start_timestamp_value: performance_range.canonical_timestamp(performance_range.start_time),
21
+ } %>
22
+
23
+ <%= tag.form(
24
+ action: range_path,
25
+ method: "get",
26
+ class: "performance-range-toolbar mb-3",
27
+ aria: { describedby: "performance-range-time-zone", labelledby: "performance-range-name" },
28
+ data: range_form_data
29
+ ) do %>
30
+ <span class="visually-hidden" id="performance-range-name"><%= t ".time_range" %></span>
31
+ <% if range_context["locale"] %>
32
+ <%= hidden_field_tag "locale", range_context["locale"] %>
33
+ <% end %>
34
+ <%= hidden_field_tag "chart_start", performance_range.canonical_timestamp(performance_range.start_time), disabled: true, id: nil, data: { performance_range_target: "startCanonicalInput" } %>
35
+ <%= hidden_field_tag "chart_end", performance_range.canonical_timestamp(performance_range.end_time), disabled: true, id: nil, data: { performance_range_target: "endCanonicalInput" } %>
36
+ <%= hidden_field_tag GoodJob::PerformanceRange::TIME_ZONE_PARAMETER_KEY, nil, disabled: true, id: nil, data: { performance_range_target: "timeZoneInput" } %>
37
+ <div class="performance-range-control">
38
+ <label class="performance-range-state performance-range-date" title="<%= t ".start_time" %>">
39
+ <%= render_icon "clock", class: "me-1" %>
40
+ <span data-performance-range-target="startLabel"><%= performance_range.start_label %></span>
41
+ <%= datetime_local_field_tag "chart_start", performance_range.start_local_value, min: GoodJob::PerformanceRange::MINIMUM_LOCAL_VALUE, max: GoodJob::PerformanceRange::MAXIMUM_LOCAL_VALUE, step: 1, required: true, class: "performance-range-native-input", aria: { describedby: "performance-range-time-zone", label: t(".start_time") }, data: { performance_range_target: "startInput", action: "input->performance-range#edit input->performance-range#constrain change->performance-range#constrain change->performance-range#submitRange click->performance-range#openPicker" } %>
42
+ </label>
43
+ <label class="performance-range-state performance-range-date" title="<%= t ".end_time" %>">
44
+ <span data-performance-range-target="endLabel"><%= performance_range.end_label %></span>
45
+ <%= datetime_local_field_tag "chart_end", performance_range.end_local_value, min: GoodJob::PerformanceRange::MINIMUM_LOCAL_VALUE, max: GoodJob::PerformanceRange::MAXIMUM_LOCAL_VALUE, step: 1, required: true, class: "performance-range-native-input", aria: { describedby: "performance-range-time-zone", label: t(".end_time") }, data: { performance_range_target: "endInput", action: "input->performance-range#edit input->performance-range#constrain change->performance-range#constrain change->performance-range#submitRange click->performance-range#openPicker" } %>
46
+ </label>
47
+ <div class="performance-range-selector d-inline-flex position-relative">
48
+ <span class="performance-range-state performance-range-key rounded-end-0">
49
+ <%= range_badge_label %>
50
+ </span>
51
+ <button class="btn btn-outline-secondary dropdown-toggle dropdown-toggle-split performance-range-toggle rounded-start-0" type="button" data-bs-toggle="dropdown" aria-expanded="false" aria-label="<%= t ".open_time_ranges" %>">
52
+ <span class="visually-hidden"><%= t ".open_time_ranges" %></span>
53
+ </button>
54
+ <ul class="dropdown-menu dropdown-menu-end performance-range-menu">
55
+ <li><h6 class="dropdown-header"><%= t ".quick_ranges" %></h6></li>
56
+ <% performance_range.options.each do |range| %>
57
+ <% active = performance_range.key == range[:key] %>
58
+ <% preset_range_path = "#{range_path}?#{range_context.merge('chart_range' => range[:key]).to_query}" %>
59
+ <li>
60
+ <%= link_to preset_range_path, class: ["dropdown-item", "performance-range-menu-item", "d-flex", "align-items-center", "gap-2", ("active" if active)], aria: { current: ("page" if active) } do %>
61
+ <span class="performance-range-option-key"><%= range[:label] %></span>
62
+ <span class="flex-grow-1"><%= t ".range_options.#{range[:key]}" %></span>
63
+ <% end %>
64
+ </li>
65
+ <% end %>
66
+ <li><hr class="dropdown-divider"></li>
67
+ <li>
68
+ <%= link_to custom_range_path, class: ["dropdown-item", "performance-range-menu-item", "d-flex", "align-items-center", "gap-2", "performance-range-custom", ("active" if performance_range.custom?)], aria: { current: ("page" if performance_range.custom?) } do %>
69
+ <span class="performance-range-option-key">#</span>
70
+ <span class="flex-grow-1"><%= t ".custom_range" %></span>
71
+ <% if local_assigns[:drag_enabled] %>
72
+ <small class="text-body-secondary text-nowrap"><%= t ".custom_range_hint" %></small>
73
+ <% end %>
74
+ <% end %>
75
+ </li>
76
+ </ul>
77
+ </div>
78
+ <%= link_to reload_range_path, class: %w[btn btn-outline-secondary performance-range-reload flex-shrink-0], aria: { label: reload_range_label }, data: { turbo: false }, title: reload_range_label do %>
79
+ <%= render_icon "arrow_clockwise" %>
80
+ <span class="visually-hidden"><%= reload_range_label %></span>
81
+ <% end %>
82
+ </div>
83
+ <div class="performance-range-meta small text-body-secondary mt-2" id="performance-range-time-zone">
84
+ <span><%= t ".time_zone" %></span>
85
+ <span data-performance-range-target="timeZoneLabel"><%= time_zone_label %></span>
86
+ <% if local_assigns[:bucket_size] %>
87
+ <span class="mx-1" aria-hidden="true">·</span>
88
+ <span class="performance-chart-bucket-size"><%= t ".chart_bucket_size", size: bucket_size %></span>
89
+ <% end %>
90
+ </div>
91
+ <% end %>
@@ -1,8 +1,12 @@
1
- <div class="border-bottom">
1
+ <div class="border-bottom mb-3">
2
2
  <h2 class="pt-3 pb-2"><%= t ".title" %></h2>
3
3
  </div>
4
4
 
5
- <%= render 'good_job/shared/chart_container', chart_data: GoodJob::PerformanceIndexChart.new.data %>
5
+ <%= render "range", performance_range: @performance_range, drag_enabled: true, range_context: @performance_range_context, range_path: performance_index_path(locale: nil), bucket_size: format_performance_bucket_size(@performance_range.interval_seconds) %>
6
+
7
+ <%= render 'good_job/shared/chart_container', chart_data: @chart_data %>
8
+
9
+ <% performance_range_query = @performance_range_context.merge(@performance_range.navigation_params).to_query %>
6
10
 
7
11
  <div class="my-3 card">
8
12
  <div class="list-group list-group-flush text-nowrap" role="table">
@@ -16,12 +20,16 @@
16
20
  </div>
17
21
  </header>
18
22
 
23
+ <% if @performances.empty? %>
24
+ <div role="row" class="list-group-item py-3 text-body-secondary"><%= t ".no_executions" %></div>
25
+ <% end %>
26
+
19
27
  <% @performances.each do |performance| %>
20
28
  <div role="row" class="list-group-item py-3">
21
29
  <div class="row align-items-center">
22
- <div class="col-12 col-lg-4">
30
+ <div class="col-12 col-lg-4 performance-name">
23
31
  <div class="d-flex gap-3 justify-content-between">
24
- <%= link_to performance.job_class, performance_path(performance.job_class) %>
32
+ <%= link_to performance.job_class, "#{performance_path(performance.job_class, locale: nil)}?#{performance_range_query}" %>
25
33
  </div>
26
34
  </div>
27
35
  <div class="col-6 col-lg-2 text-wrap">
@@ -58,10 +66,14 @@
58
66
  </div>
59
67
  </header>
60
68
 
69
+ <% if @queue_performances.empty? %>
70
+ <div role="row" class="list-group-item py-3 text-body-secondary"><%= t ".no_executions" %></div>
71
+ <% end %>
72
+
61
73
  <% @queue_performances.each do |performance| %>
62
74
  <div role="row" class="list-group-item py-3">
63
75
  <div class="row align-items-center">
64
- <div class="col-12 col-lg-4">
76
+ <div class="col-12 col-lg-4 performance-name">
65
77
  <div class="d-flex gap-3 justify-content-between">
66
78
  <%= performance.queue_name %>
67
79
  </div>
@@ -1,5 +1,7 @@
1
- <div class="border-bottom">
1
+ <div class="border-bottom mb-3">
2
2
  <h2 class="pt-3 pb-2"><%= t ".title" %> - <%= @job_class %></h2>
3
3
  </div>
4
4
 
5
- <%= render 'good_job/shared/chart_container', chart_data: GoodJob::PerformanceShowChart.new(@job_class).data %>
5
+ <%= render "range", performance_range: @performance_range, range_context: @performance_range_context, range_path: performance_path(@job_class, locale: nil) %>
6
+
7
+ <%= render 'good_job/shared/chart_container', chart_data: @chart_data %>