diff --git a/app/charts/good_job/base_chart.rb b/app/charts/good_job/base_chart.rb new file mode 100644 index 000000000..06cd012b6 --- /dev/null +++ b/app/charts/good_job/base_chart.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module GoodJob + class BaseChart + def start_end_binds + end_time = Time.current + start_time = end_time - 1.day + + [ + ActiveRecord::Relation::QueryAttribute.new('start_time', start_time, ActiveRecord::Type::DateTime.new), + ActiveRecord::Relation::QueryAttribute.new('end_time', end_time, ActiveRecord::Type::DateTime.new), + ] + end + + def string_to_hsl(string) + hash_value = string.sum + + hue = hash_value % 360 + saturation = (hash_value % 50) + 50 + lightness = '50' + + "hsl(#{hue}, #{saturation}%, #{lightness}%)" + end + end +end diff --git a/app/charts/good_job/performance_index_chart.rb b/app/charts/good_job/performance_index_chart.rb new file mode 100644 index 000000000..a35a2ba47 --- /dev/null +++ b/app/charts/good_job/performance_index_chart.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module GoodJob + class PerformanceIndexChart < BaseChart + def data + table_name = GoodJob::DiscreteExecution.table_name + + sum_query = Arel.sql(GoodJob::Job.pg_or_jdbc_query(<<~SQL.squish)) + SELECT * + FROM generate_series( + date_trunc('hour', $1::timestamp), + date_trunc('hour', $2::timestamp), + '1 hour' + ) timestamp + LEFT JOIN ( + SELECT + date_trunc('hour', scheduled_at) AS scheduled_at, + job_class, + SUM(duration) AS sum + FROM #{table_name} sources + GROUP BY date_trunc('hour', scheduled_at), job_class + ) sources ON sources.scheduled_at = timestamp + ORDER BY timestamp ASC + SQL + + executions_data = GoodJob::Job.connection.exec_query(GoodJob::Job.pg_or_jdbc_query(sum_query), "GoodJob Performance Chart", start_end_binds) + + job_names = executions_data.reject { |d| d['sum'].nil? }.map { |d| d['job_class'] || BaseFilter::EMPTY }.uniq + labels = [] + jobs_data = executions_data.to_a.group_by { |d| d['timestamp'] }.each_with_object({}) do |(timestamp, values), hash| + labels << timestamp.in_time_zone.strftime('%H:%M') + job_names.each do |job_class| + sum = values.find { |d| d['job_class'] == job_class }&.[]('sum') + duration = sum ? ActiveSupport::Duration.parse(sum).to_f : 0 + (hash[job_class] ||= []) << duration + end + end + + { + type: "line", + data: { + labels: labels, + datasets: jobs_data.map do |job_class, data| + label = job_class || '(none)' + { + label: label, + data: data, + backgroundColor: string_to_hsl(label), + borderColor: string_to_hsl(label), + } + end, + }, + options: { + plugins: { + title: { + display: true, + text: I18n.t("good_job.performance.index.chart_title"), + }, + }, + scales: { + y: { + beginAtZero: true, + }, + }, + }, + } + end + end +end diff --git a/app/charts/good_job/performance_show_chart.rb b/app/charts/good_job/performance_show_chart.rb new file mode 100644 index 000000000..703270376 --- /dev/null +++ b/app/charts/good_job/performance_show_chart.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module GoodJob + class PerformanceShowChart < BaseChart + # These numbers are lifted from Sidekiq + BUCKET_INTERVALS = [ + 0.02, 0.03, 0.045, 0.065, 0.1, + 0.15, 0.225, 0.335, 0.5, 0.75, + 1.1, 1.7, 2.5, 3.8, 5.75, + 8.5, 13, 20, 30, 45, + 65, 100, 150, 225, 335, + 10**8 # About 3 years + ].freeze + + def initialize(job_class) + super() + @job_class = job_class + end + + def data + table_name = GoodJob::DiscreteExecution.table_name + + interval_entries = BUCKET_INTERVALS.map { "interval '#{_1}'" }.join(",") + sum_query = Arel.sql(GoodJob::Job.pg_or_jdbc_query(<<~SQL.squish)) + SELECT + WIDTH_BUCKET(duration, ARRAY[#{interval_entries}]) as bucket_index, + COUNT(WIDTH_BUCKET(duration, ARRAY[#{interval_entries}])) AS count + FROM #{table_name} sources + WHERE + scheduled_at > $1::timestamp + AND scheduled_at < $2::timestamp + AND job_class = $3 + AND duration IS NOT NULL + GROUP BY bucket_index + SQL + + binds = [ + *start_end_binds, + @job_class, + ] + labels = BUCKET_INTERVALS.map { |interval| GoodJob::ApplicationController.helpers.format_duration(interval) } + labels[-1] = I18n.t("good_job.performance.show.slow") + executions_data = GoodJob::Job.connection.exec_query(GoodJob::Job.pg_or_jdbc_query(sum_query), "GoodJob Performance Job Chart", binds) + executions_data = executions_data.to_a.index_by { |data| data["bucket_index"] } + + bucket_data = 0.upto(BUCKET_INTERVALS.count).map do |bucket_index| + executions_data.dig(bucket_index, "count") || 0 + end + + { + type: "bar", + data: { + labels: labels, + datasets: [{ + label: @job_class, + data: bucket_data, + backgroundColor: string_to_hsl(@job_class), + borderColor: string_to_hsl(@job_class), + }], + }, + options: { + scales: { + y: { + beginAtZero: true, + }, + }, + }, + } + end + end +end diff --git a/app/charts/good_job/scheduled_by_queue_chart.rb b/app/charts/good_job/scheduled_by_queue_chart.rb index d6df51273..7f5c21b21 100644 --- a/app/charts/good_job/scheduled_by_queue_chart.rb +++ b/app/charts/good_job/scheduled_by_queue_chart.rb @@ -1,14 +1,13 @@ # frozen_string_literal: true module GoodJob - class ScheduledByQueueChart + class ScheduledByQueueChart < BaseChart def initialize(filter) + super() @filter = filter end def data - end_time = Time.current - start_time = end_time - 1.day table_name = GoodJob::Job.table_name count_query = Arel.sql(GoodJob::Job.pg_or_jdbc_query(<<~SQL.squish)) @@ -31,11 +30,7 @@ def data ORDER BY timestamp ASC SQL - binds = [ - ActiveRecord::Relation::QueryAttribute.new('start_time', start_time, ActiveRecord::Type::DateTime.new), - ActiveRecord::Relation::QueryAttribute.new('end_time', end_time, ActiveRecord::Type::DateTime.new), - ] - executions_data = GoodJob::Job.connection.exec_query(GoodJob::Job.pg_or_jdbc_query(count_query), "GoodJob Dashboard Chart", binds) + executions_data = GoodJob::Job.connection.exec_query(GoodJob::Job.pg_or_jdbc_query(count_query), "GoodJob Dashboard Chart", start_end_binds) queue_names = executions_data.reject { |d| d['count'].nil? }.map { |d| d['queue_name'] || BaseFilter::EMPTY }.uniq labels = [] @@ -47,27 +42,27 @@ def data end { - labels: labels, - datasets: queues_data.map do |queue, data| - label = queue || '(none)' - { - label: label, - data: data, - backgroundColor: string_to_hsl(label), - borderColor: string_to_hsl(label), - } - end, + type: "line", + data: { + labels: labels, + datasets: queues_data.map do |queue, data| + label = queue || '(none)' + { + label: label, + data: data, + backgroundColor: string_to_hsl(label), + borderColor: string_to_hsl(label), + } + end, + }, + options: { + scales: { + y: { + beginAtZero: true, + }, + }, + }, } end - - def string_to_hsl(string) - hash_value = string.sum - - hue = hash_value % 360 - saturation = (hash_value % 50) + 50 - lightness = '50' - - "hsl(#{hue}, #{saturation}%, #{lightness}%)" - end end end diff --git a/app/controllers/good_job/metrics_controller.rb b/app/controllers/good_job/metrics_controller.rb index 80e626e14..8096aa859 100644 --- a/app/controllers/good_job/metrics_controller.rb +++ b/app/controllers/good_job/metrics_controller.rb @@ -9,27 +9,17 @@ def primary_nav processes_count = GoodJob::Process.active.count render json: { - jobs_count: number_to_human(jobs_count), - batches_count: number_to_human(batches_count), - cron_entries_count: number_to_human(cron_entries_count), - processes_count: number_to_human(processes_count), + jobs_count: helpers.number_to_human(jobs_count), + batches_count: helpers.number_to_human(batches_count), + cron_entries_count: helpers.number_to_human(cron_entries_count), + processes_count: helpers.number_to_human(processes_count), } end def job_status @filter = JobsFilter.new(params) - render json: @filter.states.transform_values { |count| number_with_delimiter(count) } - end - - private - - def number_to_human(count) - helpers.number_to_human(count, **helpers.translate_hash("good_job.number.human.decimal_units")) - end - - def number_with_delimiter(count) - helpers.number_with_delimiter(count, **helpers.translate_hash('good_job.number.format')) + render json: @filter.states.transform_values { |count| helpers.number_with_delimiter(count) } end end end diff --git a/app/controllers/good_job/performance_controller.rb b/app/controllers/good_job/performance_controller.rb index d6d3675d6..2151e21a1 100644 --- a/app/controllers/good_job/performance_controller.rb +++ b/app/controllers/good_job/performance_controller.rb @@ -15,5 +15,10 @@ def index ") .order("job_class") end + + def show + representative_job = GoodJob::Job.find_by!(job_class: params[:id]) + @job_class = representative_job.job_class + end end end diff --git a/app/frontend/good_job/modules/charts.js b/app/frontend/good_job/modules/charts.js index 11dd05466..2804fdb5a 100644 --- a/app/frontend/good_job/modules/charts.js +++ b/app/frontend/good_job/modules/charts.js @@ -4,25 +4,13 @@ function renderCharts(animate) { for (let i = 0; i < charts.length; i++) { const chartEl = charts[i]; const chartData = JSON.parse(chartEl.dataset.json); + chartData.options ||= {}; + chartData.options.animation = animate; + chartData.options.responsive = true; + chartData.options.maintainAspectRatio = false; const ctx = chartEl.getContext('2d'); - const chart = new Chart(ctx, { - type: 'line', - data: { - labels: chartData.labels, - datasets: chartData.datasets - }, - options: { - animation: animate, - responsive: true, - maintainAspectRatio: false, - scales: { - y: { - beginAtZero: true - } - } - } - }); + const chart = new Chart(ctx, chartData); } } diff --git a/app/helpers/good_job/application_helper.rb b/app/helpers/good_job/application_helper.rb index 4fa12f4b0..1849dfa50 100644 --- a/app/helpers/good_job/application_helper.rb +++ b/app/helpers/good_job/application_helper.rb @@ -14,7 +14,7 @@ def format_duration(sec) if sec < 1 t 'good_job.duration.milliseconds', ms: (sec * 1000).floor elsif sec < 10 - t 'good_job.duration.less_than_10_seconds', sec: sec.floor + t 'good_job.duration.less_than_10_seconds', sec: number_with_delimiter(sec.floor(1)) elsif sec < 60 t 'good_job.duration.seconds', sec: sec.floor elsif sec < 3600 @@ -30,6 +30,14 @@ def relative_time(timestamp, **options) tag.time(text, datetime: timestamp, title: timestamp) end + def number_to_human(count) + super(count, **translate_hash("good_job.number.human.decimal_units")) + end + + def number_with_delimiter(count) + super(count, **translate_hash('good_job.number.format')) + end + def translate_hash(key, **options) translation_exists?(key, **options) ? translate(key, **options) : {} end diff --git a/app/views/good_job/performance/index.html.erb b/app/views/good_job/performance/index.html.erb index 3b8ee3424..c96e0f611 100644 --- a/app/views/good_job/performance/index.html.erb +++ b/app/views/good_job/performance/index.html.erb @@ -2,6 +2,8 @@

<%= t ".title" %>

+<%= render 'good_job/shared/chart', chart_data: GoodJob::PerformanceIndexChart.new.data %> +
@@ -18,7 +20,7 @@ <% @performances.each do |performance| %>
-
<%= performance.job_class %>
+
<%= link_to performance.job_class, performance_path(performance.job_class) %>
<%= t ".executions" %>
<%= performance.executions_count %> diff --git a/app/views/good_job/performance/show.html.erb b/app/views/good_job/performance/show.html.erb new file mode 100644 index 000000000..3d850c8b5 --- /dev/null +++ b/app/views/good_job/performance/show.html.erb @@ -0,0 +1,5 @@ +
+

<%= t ".title" %> - <%= @job_class %>

+
+ +<%= render 'good_job/shared/chart', chart_data: GoodJob::PerformanceShowChart.new(@job_class).data %> diff --git a/app/views/good_job/shared/_filter.erb b/app/views/good_job/shared/_filter.erb index 504de6b7b..cd59e0564 100644 --- a/app/views/good_job/shared/_filter.erb +++ b/app/views/good_job/shared/_filter.erb @@ -15,7 +15,7 @@ <% filter.queues.each do |name, count| %> - + <% end %>
@@ -26,7 +26,7 @@ <% filter.job_classes.each do |name, count| %> - + <% end %>
diff --git a/config/locales/de.yml b/config/locales/de.yml index eb0f973c0..78e52de7d 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -197,11 +197,15 @@ de: performance: index: average_duration: Durchschnittliche Dauer + chart_title: Gesamtdauer der Auftragsausführung in Sekunden executions: Hinrichtungen job_class: Berufsklasse maximum_duration: Maximale Dauer minimum_duration: Mindestdauer title: Leistung + show: + slow: Langsam + title: Leistung processes: index: cron_enabled: Cron aktiviert diff --git a/config/locales/en.yml b/config/locales/en.yml index 4963482ab..9eef04e03 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -197,11 +197,15 @@ en: performance: index: average_duration: Average duration + chart_title: Total job execution time in seconds executions: Executions job_class: Job class maximum_duration: Maximum duration minimum_duration: Minimum duration title: Performance + show: + slow: Slow + title: Performance processes: index: cron_enabled: Cron enabled diff --git a/config/locales/es.yml b/config/locales/es.yml index 8c612cd7e..b44669e36 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -197,11 +197,15 @@ es: performance: index: average_duration: Duración promedio + chart_title: Tiempo total de ejecución del trabajo en segundos executions: Ejecuciones job_class: clase de trabajo maximum_duration: Duración máxima minimum_duration: Duración mínima title: Actuación + show: + slow: Lento + title: Actuación processes: index: cron_enabled: Cron habilitado diff --git a/config/locales/fr.yml b/config/locales/fr.yml index d90bcbf36..4013bd250 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -197,11 +197,15 @@ fr: performance: index: average_duration: Durée moyenne + chart_title: Durée totale d'exécution du travail en secondes executions: Exécutions job_class: Catégorie d'emplois maximum_duration: Durée maximale minimum_duration: Durée minimale title: Performance + show: + slow: Lenteur + title: Performance processes: index: cron_enabled: Cron activé diff --git a/config/locales/it.yml b/config/locales/it.yml index 560fdc365..f7eda4959 100644 --- a/config/locales/it.yml +++ b/config/locales/it.yml @@ -197,11 +197,15 @@ it: performance: index: average_duration: Durata media + chart_title: Tempo totale di esecuzione del lavoro in secondi executions: Esecuzioni job_class: Classe di lavoro maximum_duration: Durata massima minimum_duration: Durata minima title: Prestazione + show: + slow: Lento + title: Prestazione processes: index: cron_enabled: Cron abilitato diff --git a/config/locales/ja.yml b/config/locales/ja.yml index dd5061bc2..4b6959861 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -197,11 +197,15 @@ ja: performance: index: average_duration: 平均所要時間 + chart_title: ジョブの総実行時間(秒 executions: 処刑 job_class: 職種 maximum_duration: 最大持続時間 minimum_duration: 最小期間 title: パフォーマンス + show: + slow: 遅い + title: パフォーマンス processes: index: cron_enabled: Cron が有効になっている diff --git a/config/locales/ko.yml b/config/locales/ko.yml index 69e6b5cf1..8c165a9df 100644 --- a/config/locales/ko.yml +++ b/config/locales/ko.yml @@ -197,11 +197,15 @@ ko: performance: index: average_duration: 평균 지속 시간 + chart_title: 총 작업 실행 시간(초) executions: 처형 job_class: 직업군 maximum_duration: 최대 기간 minimum_duration: 최소 기간 title: 성능 + show: + slow: 느림 + title: 성능 processes: index: cron_enabled: Cron이 활성화되어 있음 diff --git a/config/locales/nl.yml b/config/locales/nl.yml index e65011b16..26691f648 100644 --- a/config/locales/nl.yml +++ b/config/locales/nl.yml @@ -197,11 +197,15 @@ nl: performance: index: average_duration: Gemiddelde duur + chart_title: Totale uitvoeringstijd van de taak in seconden executions: Executies job_class: Functie klasse maximum_duration: Maximale duur minimum_duration: Minimale duur title: Prestatie + show: + slow: Langzaam + title: Prestatie processes: index: cron_enabled: Cron ingeschakeld diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml index 0f1452bb0..668b62466 100644 --- a/config/locales/pt-BR.yml +++ b/config/locales/pt-BR.yml @@ -197,11 +197,15 @@ pt-BR: performance: index: average_duration: Duração média + chart_title: Tempo total de execução do trabalho em segundos executions: Execuções job_class: Classe de trabalho maximum_duration: Duração máxima minimum_duration: Duração mínima title: Desempenho + show: + slow: Lento + title: Desempenho processes: index: cron_enabled: Agendamento ativado diff --git a/config/locales/ru.yml b/config/locales/ru.yml index f9bbb2a08..df703401c 100644 --- a/config/locales/ru.yml +++ b/config/locales/ru.yml @@ -223,11 +223,15 @@ ru: performance: index: average_duration: Средняя продолжительность + chart_title: Общее время выполнения задания в секундах executions: Казни job_class: Класс работы maximum_duration: Максимальная продолжительность minimum_duration: Минимальная продолжительность title: Производительность + show: + slow: Медленный + title: Производительность processes: index: cron_enabled: Cron включен diff --git a/config/locales/tr.yml b/config/locales/tr.yml index 7048a9c28..57a2393f6 100644 --- a/config/locales/tr.yml +++ b/config/locales/tr.yml @@ -197,11 +197,15 @@ tr: performance: index: average_duration: Ortalama süre + chart_title: Saniye cinsinden toplam iş yürütme süresi executions: İnfazlar job_class: İş sınıfı maximum_duration: Maksimum süre minimum_duration: Minimum süre title: Verim + show: + slow: Yavaş + title: Verim processes: index: cron_enabled: Cron etkin diff --git a/config/locales/uk.yml b/config/locales/uk.yml index 034205b0a..cdd987d7c 100644 --- a/config/locales/uk.yml +++ b/config/locales/uk.yml @@ -223,11 +223,15 @@ uk: performance: index: average_duration: Середня тривалість + chart_title: Загальний час виконання завдання в секундах executions: Страти job_class: Клас роботи maximum_duration: Максимальна тривалість minimum_duration: Мінімальна тривалість title: Продуктивність + show: + slow: Повільно + title: Продуктивність processes: index: cron_enabled: Cron увімкнено diff --git a/config/routes.rb b/config/routes.rb index 75e46221e..2d6c95be0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -31,7 +31,7 @@ resources :processes, only: %i[index] - resources :performance, only: %i[index] + resources :performance, only: %i[index show] scope :frontend, controller: :frontends do get "modules/:name", action: :module, as: :frontend_module, constraints: { format: 'js' } diff --git a/spec/app/controllers/good_job/performance_controller_spec.rb b/spec/app/controllers/good_job/performance_controller_spec.rb index 2f6956b67..cc469923b 100644 --- a/spec/app/controllers/good_job/performance_controller_spec.rb +++ b/spec/app/controllers/good_job/performance_controller_spec.rb @@ -19,4 +19,18 @@ expect(response.body).to include('Performance') end end + + describe '#show' do + it 'renders the show page' do + get :show, params: { id: "ExampleJob" } + expect(response).to have_http_status(:ok) + expect(response.body).to include('Performance - ExampleJob') + end + + it "raises a 404 when the job doesn't exist" do + expect do + get :show, params: { id: "Missing" } + end.to raise_error(ActiveRecord::RecordNotFound) + end + end end diff --git a/spec/system/performance_spec.rb b/spec/system/performance_spec.rb index 7ce4cafaf..a37ce4a52 100644 --- a/spec/system/performance_spec.rb +++ b/spec/system/performance_spec.rb @@ -9,11 +9,19 @@ GoodJob.perform_inline end - it 'renders properly' do + it 'renders index properly' do visit good_job.root_path click_link 'Performance' expect(page).to have_css 'h2', text: 'Performance' expect(page).to have_content 'ExampleJob' end + + it 'renders show properly' do + visit good_job.root_path + click_link 'Performance' + click_link 'ExampleJob' + + expect(page).to have_css 'h2', text: 'Performance - ExampleJob' + end end