solid_queue_monitor 1.3.0 → 2.1.0

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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +66 -4
  3. data/app/assets/javascripts/solid_queue_monitor/application.js +393 -0
  4. data/app/{services/solid_queue_monitor/stylesheet_generator.rb → assets/stylesheets/solid_queue_monitor/application.css} +23 -12
  5. data/app/controllers/solid_queue_monitor/application_controller.rb +9 -3
  6. data/app/controllers/solid_queue_monitor/assets_controller.rb +52 -0
  7. data/app/controllers/solid_queue_monitor/base_controller.rb +0 -29
  8. data/app/controllers/solid_queue_monitor/failed_jobs_controller.rb +3 -7
  9. data/app/controllers/solid_queue_monitor/in_progress_jobs_controller.rb +3 -6
  10. data/app/controllers/solid_queue_monitor/jobs_controller.rb +3 -7
  11. data/app/controllers/solid_queue_monitor/overview_controller.rb +3 -12
  12. data/app/controllers/solid_queue_monitor/queues_controller.rb +4 -18
  13. data/app/controllers/solid_queue_monitor/ready_jobs_controller.rb +3 -6
  14. data/app/controllers/solid_queue_monitor/recurring_jobs_controller.rb +3 -6
  15. data/app/controllers/solid_queue_monitor/scheduled_jobs_controller.rb +3 -7
  16. data/app/controllers/solid_queue_monitor/search_controller.rb +3 -4
  17. data/app/controllers/solid_queue_monitor/workers_controller.rb +24 -8
  18. data/app/helpers/solid_queue_monitor/application_helper.rb +46 -0
  19. data/app/helpers/solid_queue_monitor/chart_helper.rb +293 -0
  20. data/app/helpers/solid_queue_monitor/job_details_helper.rb +66 -0
  21. data/app/helpers/solid_queue_monitor/jobs_helper.rb +134 -0
  22. data/app/helpers/solid_queue_monitor/pagination_helper.rb +23 -0
  23. data/app/helpers/solid_queue_monitor/sort_helper.rb +30 -0
  24. data/app/helpers/solid_queue_monitor/workers_helper.rb +88 -0
  25. data/app/services/solid_queue_monitor/asset_cache.rb +56 -0
  26. data/app/views/layouts/solid_queue_monitor/application.html.erb +25 -0
  27. data/app/views/solid_queue_monitor/failed_jobs/_row.html.erb +26 -0
  28. data/app/views/solid_queue_monitor/failed_jobs/index.html.erb +38 -0
  29. data/app/views/solid_queue_monitor/in_progress_jobs/_row.html.erb +13 -0
  30. data/app/views/solid_queue_monitor/in_progress_jobs/index.html.erb +25 -0
  31. data/app/views/solid_queue_monitor/jobs/_arguments.html.erb +9 -0
  32. data/app/views/solid_queue_monitor/jobs/_error.html.erb +26 -0
  33. data/app/views/solid_queue_monitor/jobs/_execution_history.html.erb +25 -0
  34. data/app/views/solid_queue_monitor/jobs/_header.html.erb +37 -0
  35. data/app/views/solid_queue_monitor/jobs/_metadata.html.erb +22 -0
  36. data/app/views/solid_queue_monitor/jobs/_raw_data.html.erb +11 -0
  37. data/app/views/solid_queue_monitor/jobs/_timeline.html.erb +29 -0
  38. data/app/views/solid_queue_monitor/jobs/_timing.html.erb +9 -0
  39. data/app/views/solid_queue_monitor/jobs/_worker.html.erb +12 -0
  40. data/app/views/solid_queue_monitor/jobs/show.html.erb +22 -0
  41. data/app/views/solid_queue_monitor/overview/_chart.html.erb +1 -0
  42. data/app/views/solid_queue_monitor/overview/_recent_job_row.html.erb +26 -0
  43. data/app/views/solid_queue_monitor/overview/_recent_jobs.html.erb +31 -0
  44. data/app/views/solid_queue_monitor/overview/_stat_card.html.erb +4 -0
  45. data/app/views/solid_queue_monitor/overview/_stats.html.erb +11 -0
  46. data/app/views/solid_queue_monitor/overview/index.html.erb +9 -0
  47. data/app/views/solid_queue_monitor/queues/_job_row.html.erb +26 -0
  48. data/app/views/solid_queue_monitor/queues/_row.html.erb +33 -0
  49. data/app/views/solid_queue_monitor/queues/index.html.erb +18 -0
  50. data/app/views/solid_queue_monitor/queues/show.html.erb +63 -0
  51. data/app/views/solid_queue_monitor/ready_jobs/_row.html.erb +7 -0
  52. data/app/views/solid_queue_monitor/ready_jobs/index.html.erb +25 -0
  53. data/app/views/solid_queue_monitor/recurring_jobs/_row.html.erb +8 -0
  54. data/app/views/solid_queue_monitor/recurring_jobs/index.html.erb +26 -0
  55. data/app/views/solid_queue_monitor/scheduled_jobs/_row.html.erb +7 -0
  56. data/app/views/solid_queue_monitor/scheduled_jobs/index.html.erb +36 -0
  57. data/app/views/solid_queue_monitor/search/_completed_row.html.erb +6 -0
  58. data/app/views/solid_queue_monitor/search/_failed_row.html.erb +6 -0
  59. data/app/views/solid_queue_monitor/search/_job_row.html.erb +9 -0
  60. data/app/views/solid_queue_monitor/search/_recurring_row.html.erb +6 -0
  61. data/app/views/solid_queue_monitor/search/_section.html.erb +25 -0
  62. data/app/views/solid_queue_monitor/search/index.html.erb +23 -0
  63. data/app/views/solid_queue_monitor/shared/_filters.html.erb +48 -0
  64. data/app/views/solid_queue_monitor/shared/_flash.html.erb +17 -0
  65. data/app/views/solid_queue_monitor/shared/_footer.html.erb +3 -0
  66. data/app/views/solid_queue_monitor/shared/_header.html.erb +81 -0
  67. data/app/views/solid_queue_monitor/shared/_jobs_table.html.erb +20 -0
  68. data/app/views/solid_queue_monitor/shared/_pagination.html.erb +25 -0
  69. data/app/views/solid_queue_monitor/workers/_row.html.erb +22 -0
  70. data/app/views/solid_queue_monitor/workers/index.html.erb +82 -0
  71. data/config/routes.rb +6 -1
  72. data/lib/solid_queue_monitor/engine.rb +2 -0
  73. data/lib/solid_queue_monitor/version.rb +1 -1
  74. data/lib/solid_queue_monitor.rb +8 -1
  75. metadata +57 -17
  76. data/app/presenters/solid_queue_monitor/base_presenter.rb +0 -211
  77. data/app/presenters/solid_queue_monitor/failed_jobs_presenter.rb +0 -225
  78. data/app/presenters/solid_queue_monitor/in_progress_jobs_presenter.rb +0 -84
  79. data/app/presenters/solid_queue_monitor/job_details_presenter.rb +0 -707
  80. data/app/presenters/solid_queue_monitor/jobs_presenter.rb +0 -144
  81. data/app/presenters/solid_queue_monitor/queue_details_presenter.rb +0 -195
  82. data/app/presenters/solid_queue_monitor/queues_presenter.rb +0 -89
  83. data/app/presenters/solid_queue_monitor/ready_jobs_presenter.rb +0 -81
  84. data/app/presenters/solid_queue_monitor/recurring_jobs_presenter.rb +0 -81
  85. data/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb +0 -178
  86. data/app/presenters/solid_queue_monitor/search_results_presenter.rb +0 -190
  87. data/app/presenters/solid_queue_monitor/stats_presenter.rb +0 -36
  88. data/app/presenters/solid_queue_monitor/workers_presenter.rb +0 -325
  89. data/app/services/solid_queue_monitor/chart_presenter.rb +0 -239
  90. data/app/services/solid_queue_monitor/html_generator.rb +0 -427
@@ -6,35 +6,6 @@ module SolidQueueMonitor
6
6
  PaginationService.new(relation, current_page, per_page).paginate
7
7
  end
8
8
 
9
- def render_page(title, content, search_query: nil)
10
- # Get flash message from instance variable (set by set_flash_message) or session
11
- message = @flash_message
12
- message_type = @flash_type
13
-
14
- # Try to get from session as fallback, but don't fail if session unavailable
15
- begin
16
- message ||= session[:flash_message]
17
- message_type ||= session[:flash_type]
18
-
19
- # Clear the flash message from session after using it
20
- session.delete(:flash_message) if message
21
- session.delete(:flash_type) if message_type
22
- rescue StandardError
23
- # Session not available (e.g., no session middleware in tests)
24
- end
25
-
26
- html = SolidQueueMonitor::HtmlGenerator.new(
27
- title: title,
28
- content: content,
29
- message: message,
30
- message_type: message_type,
31
- search_query: search_query,
32
- nonce: content_security_policy_nonce
33
- ).generate
34
-
35
- render html: html.html_safe
36
- end
37
-
38
9
  def current_page
39
10
  (params[:page] || 1).to_i
40
11
  end
@@ -8,13 +8,9 @@ module SolidQueueMonitor
8
8
  base_query = SolidQueue::FailedExecution.includes(:job)
9
9
  sorted_query = apply_execution_sorting(filter_failed_jobs(base_query), SORTABLE_COLUMNS, 'created_at', :desc)
10
10
  @failed_jobs = paginate(sorted_query)
11
-
12
- render_page('Failed Jobs', SolidQueueMonitor::FailedJobsPresenter.new(@failed_jobs[:records],
13
- current_page: @failed_jobs[:current_page],
14
- total_pages: @failed_jobs[:total_pages],
15
- filters: filter_params,
16
- sort: sort_params,
17
- nonce: content_security_policy_nonce).render)
11
+ @filters = filter_params
12
+ @sort = sort_params
13
+ @action_path = failed_jobs_path
18
14
  end
19
15
 
20
16
  def retry
@@ -8,12 +8,9 @@ module SolidQueueMonitor
8
8
  base_query = SolidQueue::ClaimedExecution.includes(:job)
9
9
  sorted_query = apply_execution_sorting(filter_in_progress_jobs(base_query), SORTABLE_COLUMNS, 'created_at', :desc)
10
10
  @in_progress_jobs = paginate(sorted_query)
11
-
12
- render_page('In Progress Jobs', SolidQueueMonitor::InProgressJobsPresenter.new(@in_progress_jobs[:records],
13
- current_page: @in_progress_jobs[:current_page],
14
- total_pages: @in_progress_jobs[:total_pages],
15
- filters: filter_params,
16
- sort: sort_params).render)
11
+ @filters = filter_params
12
+ @sort = sort_params
13
+ @action_path = in_progress_jobs_path
17
14
  end
18
15
 
19
16
  private
@@ -11,13 +11,9 @@ module SolidQueueMonitor
11
11
  return
12
12
  end
13
13
 
14
- job_data = load_job_data(@job)
15
-
16
- render_page("Job ##{@job.id}", SolidQueueMonitor::JobDetailsPresenter.new(
17
- @job,
18
- **job_data,
19
- nonce: content_security_policy_nonce
20
- ).render)
14
+ load_job_data(@job).each do |name, value|
15
+ instance_variable_set("@#{name}", value)
16
+ end
21
17
  end
22
18
 
23
19
  private
@@ -7,14 +7,15 @@ module SolidQueueMonitor
7
7
  def index
8
8
  @stats = SolidQueueMonitor::StatsCalculator.calculate
9
9
  @chart_data = SolidQueueMonitor.show_chart ? SolidQueueMonitor::ChartDataService.new(time_range: time_range_param).calculate : nil
10
+ @time_range = time_range_param
10
11
 
11
12
  recent_jobs_query = SolidQueue::Job.limit(100)
12
13
  sorted_query = apply_sorting(filter_jobs(recent_jobs_query), SORTABLE_COLUMNS, 'created_at', :desc)
13
14
  @recent_jobs = paginate(sorted_query)
15
+ @filters = filter_params
16
+ @sort = sort_params
14
17
 
15
18
  preload_job_statuses(@recent_jobs[:records])
16
-
17
- render_page('Overview', generate_overview_content)
18
19
  end
19
20
 
20
21
  def chart_data
@@ -27,15 +28,5 @@ module SolidQueueMonitor
27
28
  def time_range_param
28
29
  params[:time_range] || ChartDataService::DEFAULT_TIME_RANGE
29
30
  end
30
-
31
- def generate_overview_content
32
- html = SolidQueueMonitor::StatsPresenter.new(@stats).render
33
- html += SolidQueueMonitor::ChartPresenter.new(@chart_data).render if @chart_data
34
- html + SolidQueueMonitor::JobsPresenter.new(@recent_jobs[:records],
35
- current_page: @recent_jobs[:current_page],
36
- total_pages: @recent_jobs[:total_pages],
37
- filters: filter_params,
38
- sort: sort_params).render
39
- end
40
31
  end
41
32
  end
@@ -11,12 +11,7 @@ module SolidQueueMonitor
11
11
  @queues = apply_queue_sorting(base_query)
12
12
  @paused_queues = QueuePauseService.paused_queues
13
13
  @queue_stats = aggregate_queue_stats
14
-
15
- render_page('Queues', SolidQueueMonitor::QueuesPresenter.new(
16
- @queues, @paused_queues,
17
- queue_stats: @queue_stats,
18
- sort: sort_params
19
- ).render)
14
+ @sort = sort_params
20
15
  end
21
16
 
22
17
  def show
@@ -30,18 +25,9 @@ module SolidQueueMonitor
30
25
  preload_job_statuses(@jobs[:records])
31
26
 
32
27
  @counts = calculate_queue_counts(@queue_name)
33
-
34
- render_page("Queue: #{@queue_name}",
35
- SolidQueueMonitor::QueueDetailsPresenter.new(
36
- queue_name: @queue_name,
37
- paused: @paused,
38
- jobs: @jobs[:records],
39
- counts: @counts,
40
- current_page: @jobs[:current_page],
41
- total_pages: @jobs[:total_pages],
42
- filters: queue_filter_params,
43
- sort: sort_params
44
- ).render)
28
+ @filters = queue_filter_params
29
+ @sort = sort_params
30
+ @action_path = queue_details_path(queue_name: @queue_name)
45
31
  end
46
32
 
47
33
  def pause
@@ -8,12 +8,9 @@ module SolidQueueMonitor
8
8
  base_query = SolidQueue::ReadyExecution.includes(:job)
9
9
  sorted_query = apply_execution_sorting(filter_ready_jobs(base_query), SORTABLE_COLUMNS, 'created_at', :desc)
10
10
  @ready_jobs = paginate(sorted_query)
11
-
12
- render_page('Ready Jobs', SolidQueueMonitor::ReadyJobsPresenter.new(@ready_jobs[:records],
13
- current_page: @ready_jobs[:current_page],
14
- total_pages: @ready_jobs[:total_pages],
15
- filters: filter_params,
16
- sort: sort_params).render)
11
+ @filters = filter_params
12
+ @sort = sort_params
13
+ @action_path = ready_jobs_path
17
14
  end
18
15
  end
19
16
  end
@@ -8,12 +8,9 @@ module SolidQueueMonitor
8
8
  base_query = filter_recurring_jobs(SolidQueue::RecurringTask.all)
9
9
  sorted_query = apply_sorting(base_query, SORTABLE_COLUMNS, 'key', :asc)
10
10
  @recurring_jobs = paginate(sorted_query)
11
-
12
- render_page('Recurring Jobs', SolidQueueMonitor::RecurringJobsPresenter.new(@recurring_jobs[:records],
13
- current_page: @recurring_jobs[:current_page],
14
- total_pages: @recurring_jobs[:total_pages],
15
- filters: filter_params,
16
- sort: sort_params).render)
11
+ @filters = filter_params
12
+ @sort = sort_params
13
+ @action_path = recurring_jobs_path
17
14
  end
18
15
  end
19
16
  end
@@ -8,13 +8,9 @@ module SolidQueueMonitor
8
8
  base_query = SolidQueue::ScheduledExecution.includes(:job)
9
9
  sorted_query = apply_execution_sorting(filter_scheduled_jobs(base_query), SORTABLE_COLUMNS, 'scheduled_at', :asc)
10
10
  @scheduled_jobs = paginate(sorted_query)
11
-
12
- render_page('Scheduled Jobs', SolidQueueMonitor::ScheduledJobsPresenter.new(@scheduled_jobs[:records],
13
- current_page: @scheduled_jobs[:current_page],
14
- total_pages: @scheduled_jobs[:total_pages],
15
- filters: filter_params,
16
- sort: sort_params,
17
- nonce: content_security_policy_nonce).render)
11
+ @filters = filter_params
12
+ @sort = sort_params
13
+ @action_path = scheduled_jobs_path
18
14
  end
19
15
 
20
16
  def create
@@ -3,10 +3,9 @@
3
3
  module SolidQueueMonitor
4
4
  class SearchController < BaseController
5
5
  def index
6
- query = params[:q]
7
- results = SearchService.new(query).search
8
-
9
- render_page('Search', SearchResultsPresenter.new(query, results).render, search_query: query)
6
+ @query = params[:q]
7
+ @results = SearchService.new(@query).search
8
+ @total_count = @results.values.sum(&:size)
10
9
  end
11
10
  end
12
11
  end
@@ -8,14 +8,11 @@ module SolidQueueMonitor
8
8
  base_query = SolidQueue::Process.all
9
9
  sorted_query = apply_sorting(filter_workers(base_query), SORTABLE_COLUMNS, 'last_heartbeat_at', :desc)
10
10
  @processes = paginate(sorted_query)
11
-
12
- render_page('Workers', SolidQueueMonitor::WorkersPresenter.new(
13
- @processes[:records],
14
- current_page: @processes[:current_page],
15
- total_pages: @processes[:total_pages],
16
- filters: worker_filter_params,
17
- sort: sort_params
18
- ).render)
11
+ @process_records = @processes[:records].to_a
12
+ @filters = worker_filter_params
13
+ @sort = sort_params
14
+ @summary = worker_summary
15
+ preload_claimed_data
19
16
  end
20
17
 
21
18
  def remove
@@ -73,5 +70,24 @@ module SolidQueueMonitor
73
70
  status: params[:status]
74
71
  }
75
72
  end
73
+
74
+ def worker_summary
75
+ all_processes = SolidQueue::Process.all.to_a
76
+ {
77
+ total: all_processes.count,
78
+ healthy: all_processes.count { |process| view_context.worker_status(process) == :healthy },
79
+ stale: all_processes.count { |process| view_context.worker_status(process) == :stale },
80
+ dead: all_processes.count { |process| view_context.worker_status(process) == :dead }
81
+ }
82
+ end
83
+
84
+ def preload_claimed_data
85
+ process_ids = @process_records.map(&:id)
86
+ @claimed_counts = SolidQueue::ClaimedExecution.where(process_id: process_ids).group(:process_id).count
87
+ @claimed_jobs = SolidQueue::ClaimedExecution.includes(:job).where(process_id: process_ids).each_with_object({}) do |execution, hash|
88
+ hash[execution.process_id] ||= []
89
+ hash[execution.process_id] << execution.job
90
+ end
91
+ end
76
92
  end
77
93
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueMonitor
4
+ module ApplicationHelper
5
+ def asset_url_for(file_name)
6
+ base = File.basename(file_name, '.*')
7
+ ext = File.extname(file_name)
8
+ hash = SolidQueueMonitor::AssetCache.fingerprint_for(file_name)
9
+ fingerprinted_file = "#{base}-#{hash}#{ext}"
10
+
11
+ if respond_to?(:solid_queue_monitor)
12
+ solid_queue_monitor.asset_path(file: fingerprinted_file)
13
+ else
14
+ SolidQueueMonitor::Engine.routes.url_helpers.asset_path(file: fingerprinted_file)
15
+ end
16
+ end
17
+
18
+ def format_datetime(datetime)
19
+ return '-' unless datetime
20
+
21
+ datetime.strftime('%Y-%m-%d %H:%M:%S')
22
+ end
23
+
24
+ def message_class(type)
25
+ type.to_s == 'success' ? 'message-success' : 'message-error'
26
+ end
27
+
28
+ def queue_link(queue_name, css_class: nil)
29
+ return '-' if queue_name.blank?
30
+
31
+ link_to(queue_name,
32
+ queue_details_url_for(queue_name),
33
+ class: class_names('queue-link', css_class))
34
+ end
35
+
36
+ private
37
+
38
+ def queue_details_url_for(queue_name)
39
+ if respond_to?(:queue_details_path)
40
+ queue_details_path(queue_name: queue_name)
41
+ else
42
+ SolidQueueMonitor::Engine.routes.url_helpers.queue_details_path(queue_name: queue_name)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueMonitor
4
+ # rubocop:disable Metrics/ModuleLength
5
+ module ChartHelper
6
+ CHART_WIDTH = 1200
7
+ CHART_HEIGHT = 280
8
+ PADDING = { top: 40, right: 30, bottom: 60, left: 60 }.freeze
9
+ COLORS = {
10
+ created: '#3b82f6',
11
+ completed: '#10b981',
12
+ failed: '#ef4444'
13
+ }.freeze
14
+ SERIES = %i[failed completed created].freeze
15
+
16
+ def render_chart(data:, time_range: nil)
17
+ context = chart_context(data, time_range)
18
+
19
+ safe_join(
20
+ [
21
+ chart_section(context),
22
+ chart_tooltip
23
+ ]
24
+ )
25
+ end
26
+
27
+ def chart_time_range_options
28
+ SolidQueueMonitor::ChartDataService::TIME_RANGES.map do |key, config|
29
+ [config[:label], key]
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def chart_context(data, time_range)
36
+ {
37
+ data: data.merge(time_range: time_range || data[:time_range]),
38
+ plot_width: CHART_WIDTH - PADDING[:left] - PADDING[:right],
39
+ plot_height: CHART_HEIGHT - PADDING[:top] - PADDING[:bottom]
40
+ }
41
+ end
42
+
43
+ def chart_section(context)
44
+ tag.div(id: 'chart-section', class: 'chart-section') do
45
+ safe_join(
46
+ [
47
+ chart_header(context),
48
+ tag.div(id: 'chart-collapsible', class: 'chart-collapsible') do
49
+ safe_join(
50
+ [
51
+ tag.div(chart_body(context), class: 'chart-container'),
52
+ chart_legend
53
+ ]
54
+ )
55
+ end
56
+ ]
57
+ )
58
+ end
59
+ end
60
+
61
+ def chart_header(context)
62
+ tag.div(class: 'chart-header') do
63
+ safe_join(
64
+ [
65
+ tag.div(class: 'chart-header-left') do
66
+ safe_join(
67
+ [
68
+ chart_toggle_button,
69
+ tag.h3('Job Activity'),
70
+ chart_summary(context)
71
+ ]
72
+ )
73
+ end,
74
+ chart_time_select(context)
75
+ ]
76
+ )
77
+ end
78
+ end
79
+
80
+ def chart_toggle_button
81
+ tag.button(class: 'chart-toggle-btn', id: 'chart-toggle-btn', title: 'Toggle chart') do
82
+ tag.svg(class: 'chart-toggle-icon',
83
+ id: 'chart-toggle-icon',
84
+ width: 16,
85
+ height: 16,
86
+ viewBox: '0 0 24 24',
87
+ fill: 'none',
88
+ stroke: 'currentColor',
89
+ stroke_width: 2) do
90
+ tag.polyline(points: '6 9 12 15 18 9')
91
+ end
92
+ end
93
+ end
94
+
95
+ def chart_summary(context)
96
+ totals = context[:data][:totals] || { created: 0, completed: 0, failed: 0 }
97
+
98
+ tag.span(class: 'chart-summary') do
99
+ safe_join(
100
+ [
101
+ tag.span("#{totals[:created]} created", class: 'summary-item summary-created'),
102
+ tag.span('.', class: 'summary-separator'),
103
+ tag.span("#{totals[:completed]} completed", class: 'summary-item summary-completed'),
104
+ tag.span('.', class: 'summary-separator'),
105
+ tag.span("#{totals[:failed]} failed", class: 'summary-item summary-failed')
106
+ ],
107
+ ' '
108
+ )
109
+ end
110
+ end
111
+
112
+ def chart_time_select(context)
113
+ options = context[:data][:available_ranges].map do |key, label|
114
+ tag.option(label, value: key, selected: key == context[:data][:time_range])
115
+ end
116
+
117
+ tag.div(class: 'chart-time-select-wrapper') do
118
+ tag.select(safe_join(options), class: 'chart-time-select', id: 'chart-time-select')
119
+ end
120
+ end
121
+
122
+ def chart_body(context)
123
+ return chart_empty_state if all_series_empty?(context)
124
+
125
+ max_value = [calculate_max_value(context), 10].max
126
+
127
+ tag.svg(viewBox: "0 0 #{CHART_WIDTH} #{CHART_HEIGHT}",
128
+ class: 'job-activity-chart',
129
+ preserveAspectRatio: 'xMidYMid meet') do
130
+ safe_join(
131
+ [
132
+ chart_grid_lines(context),
133
+ chart_axes,
134
+ chart_x_labels(context),
135
+ chart_y_labels(context, max_value),
136
+ *SERIES.map { |series| series_line(context, series, max_value) },
137
+ *SERIES.map { |series| series_points(context, series, max_value) }
138
+ ].compact
139
+ )
140
+ end
141
+ end
142
+
143
+ def chart_empty_state
144
+ tag.div(class: 'chart-empty') do
145
+ tag.span('No job activity in this time range')
146
+ end
147
+ end
148
+
149
+ def chart_grid_lines(context)
150
+ safe_join(
151
+ 5.times.map do |index|
152
+ y = PADDING[:top] + (context[:plot_height] * index / 4.0)
153
+ tag.line(x1: PADDING[:left],
154
+ y1: y,
155
+ x2: CHART_WIDTH - PADDING[:right],
156
+ y2: y,
157
+ class: 'grid-line')
158
+ end
159
+ )
160
+ end
161
+
162
+ def chart_axes
163
+ safe_join(
164
+ [
165
+ tag.line(x1: PADDING[:left],
166
+ y1: PADDING[:top],
167
+ x2: PADDING[:left],
168
+ y2: CHART_HEIGHT - PADDING[:bottom],
169
+ class: 'axis-line'),
170
+ tag.line(x1: PADDING[:left],
171
+ y1: CHART_HEIGHT - PADDING[:bottom],
172
+ x2: CHART_WIDTH - PADDING[:right],
173
+ y2: CHART_HEIGHT - PADDING[:bottom],
174
+ class: 'axis-line')
175
+ ]
176
+ )
177
+ end
178
+
179
+ def chart_x_labels(context)
180
+ labels = context[:data][:labels]
181
+ return ''.html_safe if labels.blank?
182
+
183
+ step = labels.size > 12 ? (labels.size / 6.0).ceil : 1
184
+ safe_join(labels.each_with_index.filter_map do |label, index|
185
+ next unless (index % step).zero? || index == labels.size - 1
186
+
187
+ tag.text(label,
188
+ x: x_for_index(context, index, labels.size),
189
+ y: CHART_HEIGHT - PADDING[:bottom] + 20,
190
+ class: 'axis-label x-label')
191
+ end)
192
+ end
193
+
194
+ def chart_y_labels(context, max_value)
195
+ safe_join(
196
+ 5.times.map do |index|
197
+ value = (max_value * (4 - index) / 4.0).round
198
+ y = PADDING[:top] + (context[:plot_height] * index / 4.0)
199
+ tag.text(value, x: PADDING[:left] - 10, y: y + 4, class: 'axis-label y-label')
200
+ end
201
+ )
202
+ end
203
+
204
+ def series_line(context, series, max_value)
205
+ return if series_empty?(context, series)
206
+
207
+ points = calculate_points(context, series, max_value)
208
+ return if points.empty?
209
+
210
+ tag.polyline(points: points.map { |point| "#{point[:x]},#{point[:y]}" }.join(' '),
211
+ class: "chart-line chart-line-#{series}",
212
+ fill: 'none',
213
+ stroke: COLORS[series],
214
+ stroke_width: 2)
215
+ end
216
+
217
+ def series_points(context, series, max_value)
218
+ return if series_empty?(context, series)
219
+
220
+ values = context[:data][series]
221
+ safe_join(calculate_points(context, series, max_value).each_with_index.map do |point, index|
222
+ tag.circle(cx: point[:x],
223
+ cy: point[:y],
224
+ r: 4,
225
+ class: "data-point data-point-#{series}",
226
+ fill: COLORS[series],
227
+ data: { series: series, label: context[:data][:labels][index], value: values[index] })
228
+ end)
229
+ end
230
+
231
+ def chart_legend
232
+ tag.div(class: 'chart-legend') do
233
+ safe_join(%i[created completed failed].map do |series|
234
+ tag.span(class: 'legend-item') do
235
+ safe_join(
236
+ [
237
+ tag.span('', class: "legend-color legend-color-#{series}"),
238
+ series.to_s.capitalize
239
+ ],
240
+ "\n"
241
+ )
242
+ end
243
+ end)
244
+ end
245
+ end
246
+
247
+ def chart_tooltip
248
+ tag.div(id: 'chart-tooltip', class: 'chart-tooltip') do
249
+ safe_join(
250
+ [
251
+ tag.div('', class: 'tooltip-label'),
252
+ tag.div('', class: 'tooltip-value')
253
+ ]
254
+ )
255
+ end
256
+ end
257
+
258
+ def all_series_empty?(context)
259
+ %i[created completed failed].all? { |series| series_empty?(context, series) }
260
+ end
261
+
262
+ def series_empty?(context, series)
263
+ context[:data][series].nil? || context[:data][series].all?(&:zero?)
264
+ end
265
+
266
+ def calculate_max_value(context)
267
+ max = (context[:data][:created] + context[:data][:completed] + context[:data][:failed]).max || 0
268
+ return 10 if max <= 10
269
+
270
+ magnitude = 10**Math.log10(max).floor
271
+ ((max.to_f / magnitude).ceil * magnitude)
272
+ end
273
+
274
+ def calculate_points(context, series, max_value)
275
+ values = context[:data][series]
276
+ return [] if values.blank?
277
+
278
+ values.each_with_index.map do |value, index|
279
+ {
280
+ x: x_for_index(context, index, values.size).round(2),
281
+ y: (CHART_HEIGHT - PADDING[:bottom] - (context[:plot_height] * value / max_value.to_f)).round(2)
282
+ }
283
+ end
284
+ end
285
+
286
+ def x_for_index(context, index, count)
287
+ return PADDING[:left] if count <= 1
288
+
289
+ PADDING[:left] + (context[:plot_width] * index / (count - 1).to_f)
290
+ end
291
+ end
292
+ # rubocop:enable Metrics/ModuleLength
293
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueMonitor
4
+ module JobDetailsHelper
5
+ def detail_job_status(job:, failed_execution:, claimed_execution:, scheduled_execution:)
6
+ return :failed if failed_execution
7
+ return :in_progress if claimed_execution
8
+ return :scheduled if scheduled_execution || job.scheduled_at&.future?
9
+ return :completed if job.finished_at
10
+
11
+ :pending
12
+ end
13
+
14
+ def detail_status_label(status = detail_job_status)
15
+ {
16
+ failed: 'Failed',
17
+ in_progress: 'In Progress',
18
+ scheduled: 'Scheduled',
19
+ completed: 'Completed',
20
+ pending: 'Pending'
21
+ }[status]
22
+ end
23
+
24
+ def detail_status_class(status = detail_job_status)
25
+ "status-#{status.to_s.tr('_', '-')}"
26
+ end
27
+
28
+ def detail_duration(seconds)
29
+ return '-' unless seconds
30
+ return "#{(seconds * 1000).round}ms" if seconds < 1
31
+ return "#{seconds.round(1)}s" if seconds < 60
32
+ return "#{(seconds / 60).floor}m #{(seconds % 60).round}s" if seconds < 3600
33
+
34
+ "#{(seconds / 3600).floor}h #{((seconds % 3600) / 60).floor}m"
35
+ end
36
+
37
+ def detail_timing(job:, claimed_execution:, failed_execution:)
38
+ created_at = job.created_at
39
+ started_at = claimed_execution&.created_at
40
+ finished_at = job.finished_at
41
+ failed_at = failed_execution&.created_at
42
+ end_time = finished_at || failed_at
43
+
44
+ {
45
+ queue_wait: started_at && created_at ? started_at - created_at : nil,
46
+ execution: started_at && end_time ? end_time - started_at : nil,
47
+ total: created_at && end_time ? end_time - created_at : nil
48
+ }
49
+ end
50
+
51
+ def pretty_arguments(args)
52
+ return '-' if args.blank?
53
+
54
+ JSON.pretty_generate(args)
55
+ rescue JSON::GeneratorError
56
+ args.inspect
57
+ end
58
+
59
+ def recent_job_duration(job)
60
+ end_time = job.finished_at || job.failed_execution&.created_at
61
+ return '-' unless end_time
62
+
63
+ detail_duration(end_time - job.created_at)
64
+ end
65
+ end
66
+ end