solid_queue_monitor 1.0.1 → 1.2.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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -1
  3. data/app/controllers/solid_queue_monitor/base_controller.rb +49 -33
  4. data/app/controllers/solid_queue_monitor/failed_jobs_controller.rb +7 -3
  5. data/app/controllers/solid_queue_monitor/in_progress_jobs_controller.rb +9 -7
  6. data/app/controllers/solid_queue_monitor/overview_controller.rb +13 -9
  7. data/app/controllers/solid_queue_monitor/queues_controller.rb +39 -16
  8. data/app/controllers/solid_queue_monitor/ready_jobs_controller.rb +7 -3
  9. data/app/controllers/solid_queue_monitor/recurring_jobs_controller.rb +7 -3
  10. data/app/controllers/solid_queue_monitor/scheduled_jobs_controller.rb +7 -3
  11. data/app/controllers/solid_queue_monitor/search_controller.rb +12 -0
  12. data/app/controllers/solid_queue_monitor/workers_controller.rb +7 -4
  13. data/app/presenters/solid_queue_monitor/base_presenter.rb +47 -5
  14. data/app/presenters/solid_queue_monitor/failed_jobs_presenter.rb +6 -6
  15. data/app/presenters/solid_queue_monitor/in_progress_jobs_presenter.rb +5 -4
  16. data/app/presenters/solid_queue_monitor/jobs_presenter.rb +5 -4
  17. data/app/presenters/solid_queue_monitor/queue_details_presenter.rb +4 -3
  18. data/app/presenters/solid_queue_monitor/queues_presenter.rb +10 -22
  19. data/app/presenters/solid_queue_monitor/ready_jobs_presenter.rb +6 -5
  20. data/app/presenters/solid_queue_monitor/recurring_jobs_presenter.rb +6 -5
  21. data/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb +5 -4
  22. data/app/presenters/solid_queue_monitor/search_results_presenter.rb +190 -0
  23. data/app/presenters/solid_queue_monitor/stats_presenter.rb +1 -2
  24. data/app/presenters/solid_queue_monitor/workers_presenter.rb +4 -3
  25. data/app/services/solid_queue_monitor/chart_data_service.rb +53 -57
  26. data/app/services/solid_queue_monitor/html_generator.rb +23 -2
  27. data/app/services/solid_queue_monitor/search_service.rb +126 -0
  28. data/app/services/solid_queue_monitor/stats_calculator.rb +12 -8
  29. data/app/services/solid_queue_monitor/stylesheet_generator.rb +118 -0
  30. data/config/routes.rb +1 -0
  31. data/lib/generators/solid_queue_monitor/templates/initializer.rb +3 -0
  32. data/lib/solid_queue_monitor/version.rb +1 -1
  33. data/lib/solid_queue_monitor.rb +2 -1
  34. metadata +5 -2
@@ -5,11 +5,12 @@ module SolidQueueMonitor
5
5
  include Rails.application.routes.url_helpers
6
6
  include SolidQueueMonitor::Engine.routes.url_helpers
7
7
 
8
- def initialize(jobs, current_page: 1, total_pages: 1, filters: {})
8
+ def initialize(jobs, current_page: 1, total_pages: 1, filters: {}, sort: {})
9
9
  @jobs = jobs
10
10
  @current_page = current_page
11
11
  @total_pages = total_pages
12
12
  @filters = filters
13
+ @sort = sort
13
14
  end
14
15
 
15
16
  def render
@@ -73,11 +74,11 @@ module SolidQueueMonitor
73
74
  <thead>
74
75
  <tr>
75
76
  <th>ID</th>
76
- <th>Job</th>
77
- <th>Queue</th>
77
+ #{sortable_header('class_name', 'Job')}
78
+ #{sortable_header('queue_name', 'Queue')}
78
79
  <th>Arguments</th>
79
80
  <th>Status</th>
80
- <th>Created At</th>
81
+ #{sortable_header('created_at', 'Created At')}
81
82
  <th>Actions</th>
82
83
  </tr>
83
84
  </thead>
@@ -2,7 +2,7 @@
2
2
 
3
3
  module SolidQueueMonitor
4
4
  class QueueDetailsPresenter < BasePresenter
5
- def initialize(queue_name:, paused:, jobs:, counts:, current_page: 1, total_pages: 1, filters: {})
5
+ def initialize(queue_name:, paused:, jobs:, counts:, current_page: 1, total_pages: 1, filters: {}, sort: {})
6
6
  @queue_name = queue_name
7
7
  @paused = paused
8
8
  @jobs = jobs
@@ -10,6 +10,7 @@ module SolidQueueMonitor
10
10
  @current_page = current_page
11
11
  @total_pages = total_pages
12
12
  @filters = filters
13
+ @sort = sort
13
14
  end
14
15
 
15
16
  def render
@@ -129,10 +130,10 @@ module SolidQueueMonitor
129
130
  <thead>
130
131
  <tr>
131
132
  <th>ID</th>
132
- <th>Job</th>
133
+ #{sortable_header('class_name', 'Job')}
133
134
  <th>Arguments</th>
134
135
  <th>Status</th>
135
- <th>Created At</th>
136
+ #{sortable_header('created_at', 'Created At')}
136
137
  <th>Actions</th>
137
138
  </tr>
138
139
  </thead>
@@ -2,9 +2,11 @@
2
2
 
3
3
  module SolidQueueMonitor
4
4
  class QueuesPresenter < BasePresenter
5
- def initialize(records, paused_queues = [])
6
- @records = records
5
+ def initialize(records, paused_queues = [], sort: {}, queue_stats: {})
6
+ @records = records
7
7
  @paused_queues = paused_queues
8
+ @sort = sort
9
+ @queue_stats = queue_stats
8
10
  end
9
11
 
10
12
  def render
@@ -19,9 +21,9 @@ module SolidQueueMonitor
19
21
  <table>
20
22
  <thead>
21
23
  <tr>
22
- <th>Queue Name</th>
24
+ #{sortable_header('queue_name', 'Queue Name')}
23
25
  <th>Status</th>
24
- <th>Total Jobs</th>
26
+ #{sortable_header('job_count', 'Total Jobs')}
25
27
  <th>Ready Jobs</th>
26
28
  <th>Scheduled Jobs</th>
27
29
  <th>Failed Jobs</th>
@@ -38,16 +40,16 @@ module SolidQueueMonitor
38
40
 
39
41
  def generate_row(queue)
40
42
  queue_name = queue.queue_name || 'default'
41
- paused = @paused_queues.include?(queue_name)
43
+ paused = @paused_queues.include?(queue_name)
42
44
 
43
45
  <<-HTML
44
46
  <tr class="#{paused ? 'queue-paused' : ''}">
45
47
  <td>#{queue_link(queue_name)}</td>
46
48
  <td>#{status_badge(paused)}</td>
47
49
  <td>#{queue.job_count}</td>
48
- <td>#{ready_jobs_count(queue_name)}</td>
49
- <td>#{scheduled_jobs_count(queue_name)}</td>
50
- <td>#{failed_jobs_count(queue_name)}</td>
50
+ <td>#{@queue_stats.dig(:ready, queue_name) || 0}</td>
51
+ <td>#{@queue_stats.dig(:scheduled, queue_name) || 0}</td>
52
+ <td>#{@queue_stats.dig(:failed, queue_name) || 0}</td>
51
53
  <td class="actions-cell">#{action_button(queue_name, paused)}</td>
52
54
  </tr>
53
55
  HTML
@@ -83,19 +85,5 @@ module SolidQueueMonitor
83
85
  HTML
84
86
  end
85
87
  end
86
-
87
- def ready_jobs_count(queue_name)
88
- SolidQueue::ReadyExecution.where(queue_name: queue_name).count
89
- end
90
-
91
- def scheduled_jobs_count(queue_name)
92
- SolidQueue::ScheduledExecution.where(queue_name: queue_name).count
93
- end
94
-
95
- def failed_jobs_count(queue_name)
96
- SolidQueue::FailedExecution.joins(:job)
97
- .where(solid_queue_jobs: { queue_name: queue_name })
98
- .count
99
- end
100
88
  end
101
89
  end
@@ -2,11 +2,12 @@
2
2
 
3
3
  module SolidQueueMonitor
4
4
  class ReadyJobsPresenter < BasePresenter
5
- def initialize(jobs, current_page: 1, total_pages: 1, filters: {})
5
+ def initialize(jobs, current_page: 1, total_pages: 1, filters: {}, sort: {})
6
6
  @jobs = jobs
7
7
  @current_page = current_page
8
8
  @total_pages = total_pages
9
9
  @filters = filters
10
+ @sort = sort
10
11
  end
11
12
 
12
13
  def render
@@ -50,11 +51,11 @@ module SolidQueueMonitor
50
51
  <table>
51
52
  <thead>
52
53
  <tr>
53
- <th>Job</th>
54
- <th>Queue</th>
55
- <th>Priority</th>
54
+ #{sortable_header('class_name', 'Job')}
55
+ #{sortable_header('queue_name', 'Queue')}
56
+ #{sortable_header('priority', 'Priority')}
56
57
  <th>Arguments</th>
57
- <th>Created At</th>
58
+ #{sortable_header('created_at', 'Created At')}
58
59
  </tr>
59
60
  </thead>
60
61
  <tbody>
@@ -5,11 +5,12 @@ module SolidQueueMonitor
5
5
  include Rails.application.routes.url_helpers
6
6
  include SolidQueueMonitor::Engine.routes.url_helpers
7
7
 
8
- def initialize(jobs, current_page: 1, total_pages: 1, filters: {})
8
+ def initialize(jobs, current_page: 1, total_pages: 1, filters: {}, sort: {})
9
9
  @jobs = jobs
10
10
  @current_page = current_page
11
11
  @total_pages = total_pages
12
12
  @filters = filters
13
+ @sort = sort
13
14
  end
14
15
 
15
16
  def render
@@ -48,11 +49,11 @@ module SolidQueueMonitor
48
49
  <table>
49
50
  <thead>
50
51
  <tr>
51
- <th>Key</th>
52
- <th>Job</th>
52
+ #{sortable_header('key', 'Key')}
53
+ #{sortable_header('class_name', 'Job')}
53
54
  <th>Schedule</th>
54
- <th>Queue</th>
55
- <th>Priority</th>
55
+ #{sortable_header('queue_name', 'Queue')}
56
+ #{sortable_header('priority', 'Priority')}
56
57
  <th>Last Updated</th>
57
58
  </tr>
58
59
  </thead>
@@ -5,11 +5,12 @@ module SolidQueueMonitor
5
5
  include Rails.application.routes.url_helpers
6
6
  include SolidQueueMonitor::Engine.routes.url_helpers
7
7
 
8
- def initialize(jobs, current_page: 1, total_pages: 1, filters: {})
8
+ def initialize(jobs, current_page: 1, total_pages: 1, filters: {}, sort: {})
9
9
  @jobs = jobs
10
10
  @current_page = current_page
11
11
  @total_pages = total_pages
12
12
  @filters = filters
13
+ @sort = sort
13
14
  end
14
15
 
15
16
  def render
@@ -140,9 +141,9 @@ module SolidQueueMonitor
140
141
  <thead>
141
142
  <tr>
142
143
  <th width="50"><input type="checkbox"></th>
143
- <th>Job</th>
144
- <th>Queue</th>
145
- <th>Scheduled At</th>
144
+ #{sortable_header('class_name', 'Job')}
145
+ #{sortable_header('queue_name', 'Queue')}
146
+ #{sortable_header('scheduled_at', 'Scheduled At')}
146
147
  <th>Arguments</th>
147
148
  </tr>
148
149
  </thead>
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueMonitor
4
+ class SearchResultsPresenter < BasePresenter
5
+ def initialize(query, results)
6
+ @query = query
7
+ @results = results
8
+ end
9
+
10
+ def render
11
+ section_wrapper('Search Results', generate_content)
12
+ end
13
+
14
+ private
15
+
16
+ def generate_content
17
+ if @query.blank?
18
+ generate_empty_query_message
19
+ elsif total_count.zero?
20
+ generate_no_results_message
21
+ else
22
+ generate_results_summary + generate_all_sections
23
+ end
24
+ end
25
+
26
+ def generate_empty_query_message
27
+ <<-HTML
28
+ <div class="empty-state">
29
+ <p>Enter a search term in the header to find jobs across all categories.</p>
30
+ </div>
31
+ HTML
32
+ end
33
+
34
+ def generate_no_results_message
35
+ <<-HTML
36
+ <div class="empty-state">
37
+ <p>No results found for "#{escape_html(@query)}"</p>
38
+ <p class="results-summary">0 results</p>
39
+ </div>
40
+ HTML
41
+ end
42
+
43
+ def generate_results_summary
44
+ <<-HTML
45
+ <div class="results-summary">
46
+ <p>Found #{total_count} #{total_count == 1 ? 'result' : 'results'} for "#{escape_html(@query)}"</p>
47
+ </div>
48
+ HTML
49
+ end
50
+
51
+ def generate_all_sections
52
+ sections = []
53
+ sections << generate_ready_section if @results[:ready].any?
54
+ sections << generate_scheduled_section if @results[:scheduled].any?
55
+ sections << generate_failed_section if @results[:failed].any?
56
+ sections << generate_in_progress_section if @results[:in_progress].any?
57
+ sections << generate_completed_section if @results[:completed].any?
58
+ sections << generate_recurring_section if @results[:recurring].any?
59
+ sections.join
60
+ end
61
+
62
+ def generate_ready_section
63
+ generate_section('Ready Jobs', @results[:ready]) do |execution|
64
+ generate_job_row(execution.job, execution.queue_name, execution.created_at)
65
+ end
66
+ end
67
+
68
+ def generate_scheduled_section
69
+ generate_section('Scheduled Jobs', @results[:scheduled]) do |execution|
70
+ generate_job_row(execution.job, execution.queue_name, execution.scheduled_at, 'Scheduled for')
71
+ end
72
+ end
73
+
74
+ def generate_failed_section
75
+ generate_section('Failed Jobs', @results[:failed]) do |execution|
76
+ generate_failed_row(execution)
77
+ end
78
+ end
79
+
80
+ def generate_in_progress_section
81
+ generate_section('In Progress Jobs', @results[:in_progress]) do |execution|
82
+ generate_job_row(execution.job, execution.job.queue_name, execution.created_at, 'Started at')
83
+ end
84
+ end
85
+
86
+ def generate_completed_section
87
+ generate_section('Completed Jobs', @results[:completed]) do |job|
88
+ generate_completed_row(job)
89
+ end
90
+ end
91
+
92
+ def generate_recurring_section
93
+ generate_section('Recurring Tasks', @results[:recurring]) do |task|
94
+ generate_recurring_row(task)
95
+ end
96
+ end
97
+
98
+ def generate_section(title, items, &block)
99
+ <<-HTML
100
+ <div class="search-results-section">
101
+ <h3>#{title} (#{items.size})</h3>
102
+ <div class="table-container">
103
+ <table>
104
+ <thead>
105
+ <tr>
106
+ #{section_headers(title)}
107
+ </tr>
108
+ </thead>
109
+ <tbody>
110
+ #{items.map(&block).join}
111
+ </tbody>
112
+ </table>
113
+ </div>
114
+ </div>
115
+ HTML
116
+ end
117
+
118
+ def section_headers(title)
119
+ case title
120
+ when 'Recurring Tasks'
121
+ '<th>Key</th><th>Class</th><th>Schedule</th><th>Queue</th>'
122
+ when 'Failed Jobs'
123
+ '<th>Job</th><th>Queue</th><th>Error</th><th>Failed At</th>'
124
+ when 'Completed Jobs'
125
+ '<th>Job</th><th>Queue</th><th>Arguments</th><th>Completed At</th>'
126
+ else
127
+ '<th>Job</th><th>Queue</th><th>Arguments</th><th>Time</th>'
128
+ end
129
+ end
130
+
131
+ def generate_job_row(job, queue_name, time, time_label = 'Created at')
132
+ <<-HTML
133
+ <tr>
134
+ <td><a href="#{job_path(job)}" class="job-class-link">#{job.class_name}</a></td>
135
+ <td>#{queue_link(queue_name)}</td>
136
+ <td>#{format_arguments(job.arguments)}</td>
137
+ <td>
138
+ <span class="job-timestamp">#{time_label}: #{format_datetime(time)}</span>
139
+ </td>
140
+ </tr>
141
+ HTML
142
+ end
143
+
144
+ def generate_failed_row(execution)
145
+ job = execution.job
146
+ <<-HTML
147
+ <tr>
148
+ <td><a href="#{job_path(job)}" class="job-class-link">#{job.class_name}</a></td>
149
+ <td>#{queue_link(job.queue_name)}</td>
150
+ <td><div class="error-message">#{escape_html(execution.error.to_s.truncate(100))}</div></td>
151
+ <td>
152
+ <span class="job-timestamp">#{format_datetime(execution.created_at)}</span>
153
+ </td>
154
+ </tr>
155
+ HTML
156
+ end
157
+
158
+ def generate_completed_row(job)
159
+ <<-HTML
160
+ <tr>
161
+ <td><a href="#{job_path(job)}" class="job-class-link">#{job.class_name}</a></td>
162
+ <td>#{queue_link(job.queue_name)}</td>
163
+ <td>#{format_arguments(job.arguments)}</td>
164
+ <td>
165
+ <span class="job-timestamp">#{format_datetime(job.finished_at)}</span>
166
+ </td>
167
+ </tr>
168
+ HTML
169
+ end
170
+
171
+ def generate_recurring_row(task)
172
+ <<-HTML
173
+ <tr>
174
+ <td><strong>#{task.key}</strong></td>
175
+ <td>#{task.class_name || '-'}</td>
176
+ <td><code>#{task.schedule}</code></td>
177
+ <td>#{queue_link(task.queue_name)}</td>
178
+ </tr>
179
+ HTML
180
+ end
181
+
182
+ def total_count
183
+ @total_count ||= @results.values.sum(&:size)
184
+ end
185
+
186
+ def escape_html(text)
187
+ text.to_s.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;').gsub('"', '&quot;')
188
+ end
189
+ end
190
+ end
@@ -11,13 +11,12 @@ module SolidQueueMonitor
11
11
  <div class="stats-container">
12
12
  <h3>Queue Statistics</h3>
13
13
  <div class="stats">
14
- #{generate_stat_card('Total Jobs', @stats[:total_jobs])}
14
+ #{generate_stat_card('Active Jobs', @stats[:active_jobs])}
15
15
  #{generate_stat_card('Ready', @stats[:ready])}
16
16
  #{generate_stat_card('In Progress', @stats[:in_progress])}
17
17
  #{generate_stat_card('Scheduled', @stats[:scheduled])}
18
18
  #{generate_stat_card('Recurring', @stats[:recurring])}
19
19
  #{generate_stat_card('Failed', @stats[:failed])}
20
- #{generate_stat_card('Completed', @stats[:completed])}
21
20
  </div>
22
21
  </div>
23
22
  HTML
@@ -5,11 +5,12 @@ module SolidQueueMonitor
5
5
  HEARTBEAT_STALE_THRESHOLD = 5.minutes
6
6
  HEARTBEAT_DEAD_THRESHOLD = 10.minutes
7
7
 
8
- def initialize(processes, current_page: 1, total_pages: 1, filters: {})
8
+ def initialize(processes, current_page: 1, total_pages: 1, filters: {}, sort: {})
9
9
  @processes = processes.to_a # Load records once to avoid multiple queries
10
10
  @current_page = current_page
11
11
  @total_pages = total_pages
12
12
  @filters = filters
13
+ @sort = sort
13
14
  preload_claimed_data
14
15
  calculate_summary_stats
15
16
  end
@@ -140,10 +141,10 @@ module SolidQueueMonitor
140
141
  <thead>
141
142
  <tr>
142
143
  <th>Kind</th>
143
- <th>Hostname</th>
144
+ #{sortable_header('hostname', 'Hostname')}
144
145
  <th>PID</th>
145
146
  <th>Queues</th>
146
- <th>Last Heartbeat</th>
147
+ #{sortable_header('last_heartbeat_at', 'Last Heartbeat')}
147
148
  <th>Status</th>
148
149
  <th>Jobs Processing</th>
149
150
  <th>Actions</th>
@@ -5,47 +5,42 @@ module SolidQueueMonitor
5
5
  TIME_RANGES = {
6
6
  '15m' => { duration: 15.minutes, buckets: 15, label_format: '%H:%M', label: 'Last 15 minutes' },
7
7
  '30m' => { duration: 30.minutes, buckets: 15, label_format: '%H:%M', label: 'Last 30 minutes' },
8
- '1h' => { duration: 1.hour, buckets: 12, label_format: '%H:%M', label: 'Last 1 hour' },
9
- '3h' => { duration: 3.hours, buckets: 18, label_format: '%H:%M', label: 'Last 3 hours' },
10
- '6h' => { duration: 6.hours, buckets: 24, label_format: '%H:%M', label: 'Last 6 hours' },
8
+ '1h' => { duration: 1.hour, buckets: 12, label_format: '%H:%M', label: 'Last 1 hour' },
9
+ '3h' => { duration: 3.hours, buckets: 18, label_format: '%H:%M', label: 'Last 3 hours' },
10
+ '6h' => { duration: 6.hours, buckets: 24, label_format: '%H:%M', label: 'Last 6 hours' },
11
11
  '12h' => { duration: 12.hours, buckets: 24, label_format: '%H:%M', label: 'Last 12 hours' },
12
- '1d' => { duration: 1.day, buckets: 24, label_format: '%H:%M', label: 'Last 24 hours' },
13
- '3d' => { duration: 3.days, buckets: 36, label_format: '%m/%d %H:%M', label: 'Last 3 days' },
14
- '1w' => { duration: 7.days, buckets: 28, label_format: '%m/%d', label: 'Last 7 days' }
12
+ '1d' => { duration: 1.day, buckets: 24, label_format: '%H:%M', label: 'Last 24 hours' },
13
+ '3d' => { duration: 3.days, buckets: 36, label_format: '%m/%d %H:%M', label: 'Last 3 days' },
14
+ '1w' => { duration: 7.days, buckets: 28, label_format: '%m/%d', label: 'Last 7 days' }
15
15
  }.freeze
16
16
 
17
17
  DEFAULT_TIME_RANGE = '1d'
18
18
 
19
19
  def initialize(time_range: DEFAULT_TIME_RANGE)
20
20
  @time_range = TIME_RANGES.key?(time_range) ? time_range : DEFAULT_TIME_RANGE
21
- @config = TIME_RANGES[@time_range]
21
+ @config = TIME_RANGES[@time_range]
22
22
  end
23
23
 
24
24
  def calculate
25
- end_time = Time.current
26
- start_time = end_time - @config[:duration]
27
- bucket_duration = @config[:duration] / @config[:buckets]
25
+ end_time = Time.current
26
+ start_time = end_time - @config[:duration]
27
+ bucket_seconds = (@config[:duration] / @config[:buckets]).to_i
28
+ buckets = build_buckets(start_time, bucket_seconds)
28
29
 
29
- buckets = build_buckets(start_time, bucket_duration)
30
+ created_data = bucket_counts(SolidQueue::Job, :created_at, start_time, end_time, bucket_seconds)
31
+ completed_data = bucket_counts(SolidQueue::Job, :finished_at, start_time, end_time, bucket_seconds, exclude_nil: true)
32
+ failed_data = bucket_counts(SolidQueue::FailedExecution, :created_at, start_time, end_time, bucket_seconds)
30
33
 
31
- created_counts = fetch_created_counts(start_time, end_time)
32
- completed_counts = fetch_completed_counts(start_time, end_time)
33
- failed_counts = fetch_failed_counts(start_time, end_time)
34
-
35
- created_data = assign_to_buckets(created_counts, buckets, bucket_duration)
36
- completed_data = assign_to_buckets(completed_counts, buckets, bucket_duration)
37
- failed_data = assign_to_buckets(failed_counts, buckets, bucket_duration)
34
+ created_arr = fill_buckets(buckets, created_data)
35
+ completed_arr = fill_buckets(buckets, completed_data)
36
+ failed_arr = fill_buckets(buckets, failed_data)
38
37
 
39
38
  {
40
39
  labels: buckets.map { |b| b[:label] }, # rubocop:disable Rails/Pluck
41
- created: created_data,
42
- completed: completed_data,
43
- failed: failed_data,
44
- totals: {
45
- created: created_data.sum,
46
- completed: completed_data.sum,
47
- failed: failed_data.sum
48
- },
40
+ created: created_arr,
41
+ completed: completed_arr,
42
+ failed: failed_arr,
43
+ totals: { created: created_arr.sum, completed: completed_arr.sum, failed: failed_arr.sum },
49
44
  time_range: @time_range,
50
45
  time_range_label: @config[:label],
51
46
  available_ranges: TIME_RANGES.transform_values { |v| v[:label] }
@@ -54,47 +49,48 @@ module SolidQueueMonitor
54
49
 
55
50
  private
56
51
 
57
- def build_buckets(start_time, bucket_duration)
52
+ def build_buckets(start_time, bucket_seconds)
58
53
  @config[:buckets].times.map do |i|
59
- bucket_start = start_time + (i * bucket_duration)
60
- {
61
- start: bucket_start,
62
- end: bucket_start + bucket_duration,
63
- label: bucket_start.strftime(@config[:label_format])
64
- }
54
+ bucket_start = start_time + (i * bucket_seconds)
55
+ { index: i, start: bucket_start, label: bucket_start.strftime(@config[:label_format]) }
65
56
  end
66
57
  end
67
58
 
68
- def fetch_created_counts(start_time, end_time)
69
- SolidQueue::Job
70
- .where(created_at: start_time..end_time)
71
- .pluck(:created_at)
72
- end
73
-
74
- def fetch_completed_counts(start_time, end_time)
75
- SolidQueue::Job
76
- .where(finished_at: start_time..end_time)
77
- .where.not(finished_at: nil)
78
- .pluck(:finished_at)
59
+ # Returns a Hash of { bucket_index => count } using SQL GROUP BY.
60
+ # The bucket index is computed as: (epoch(column) - epoch(start_time)) / interval
61
+ # This works identically on PostgreSQL and SQLite.
62
+ def bucket_counts(model, column, start_time, end_time, interval, exclude_nil: false)
63
+ start_epoch = start_time.to_i
64
+ expr = bucket_index_expr(column, start_epoch, interval)
65
+
66
+ scope = model.where(column => start_time..end_time)
67
+ scope = scope.where.not(column => nil) if exclude_nil
68
+
69
+ # rubocop:disable Style/HashTransformKeys -- pluck returns Array<Array>, not Hash
70
+ scope
71
+ .group(Arel.sql(expr))
72
+ .pluck(Arel.sql("#{expr} AS bucket_idx, COUNT(*) AS cnt"))
73
+ .to_h { |idx, cnt| [idx.to_i, cnt] }
74
+ # rubocop:enable Style/HashTransformKeys
79
75
  end
80
76
 
81
- def fetch_failed_counts(start_time, end_time)
82
- SolidQueue::FailedExecution
83
- .where(created_at: start_time..end_time)
84
- .pluck(:created_at)
77
+ def fill_buckets(buckets, index_counts)
78
+ buckets.map { |b| index_counts.fetch(b[:index], 0) }
85
79
  end
86
80
 
87
- def assign_to_buckets(timestamps, buckets, _bucket_duration)
88
- counts = Array.new(buckets.size, 0)
89
-
90
- timestamps.each do |timestamp|
91
- bucket_index = buckets.find_index do |bucket|
92
- timestamp >= bucket[:start] && timestamp < bucket[:end]
93
- end
94
- counts[bucket_index] += 1 if bucket_index
81
+ # Cross-DB bucket index expression.
82
+ # PostgreSQL: CAST((EXTRACT(EPOCH FROM col) - start) / interval AS INTEGER)
83
+ # SQLite: CAST((CAST(strftime('%s', col) AS INTEGER) - start) / interval AS INTEGER)
84
+ def bucket_index_expr(column, start_epoch, interval_seconds)
85
+ if sqlite?
86
+ "CAST((CAST(strftime('%s', #{column}) AS INTEGER) - #{start_epoch}) / #{interval_seconds} AS INTEGER)"
87
+ else
88
+ "CAST((EXTRACT(EPOCH FROM #{column}) - #{start_epoch}) / #{interval_seconds} AS INTEGER)"
95
89
  end
90
+ end
96
91
 
97
- counts
92
+ def sqlite?
93
+ ActiveRecord::Base.connection.adapter_name.downcase.include?('sqlite')
98
94
  end
99
95
  end
100
96
  end
@@ -5,11 +5,12 @@ module SolidQueueMonitor
5
5
  include Rails.application.routes.url_helpers
6
6
  include SolidQueueMonitor::Engine.routes.url_helpers
7
7
 
8
- def initialize(title:, content:, message: nil, message_type: nil)
8
+ def initialize(title:, content:, message: nil, message_type: nil, search_query: nil)
9
9
  @title = title
10
10
  @content = content
11
11
  @message = message
12
12
  @message_type = message_type
13
+ @search_query = search_query
13
14
  end
14
15
 
15
16
  def generate
@@ -107,7 +108,8 @@ module SolidQueueMonitor
107
108
  <<-HTML
108
109
  <header>
109
110
  <div class="header-top">
110
- <h1>Solid Queue Monitor</h1>
111
+ <h1><a href="#{root_path}" class="header-title-link">Solid Queue Monitor</a></h1>
112
+ #{generate_search_box}
111
113
  <div class="header-controls">
112
114
  #{generate_auto_refresh_controls}
113
115
  #{generate_theme_toggle}
@@ -128,6 +130,25 @@ module SolidQueueMonitor
128
130
  HTML
129
131
  end
130
132
 
133
+ def generate_search_box
134
+ search_value = @search_query ? escape_html(@search_query) : ''
135
+ <<-HTML
136
+ <form method="get" action="#{search_path}" class="header-search-form">
137
+ <input type="text" name="q" value="#{search_value}" placeholder="Search by class, queue, job ID, or error..." class="header-search-input">
138
+ <button type="submit" class="header-search-button" title="Search">
139
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
140
+ <circle cx="11" cy="11" r="8"></circle>
141
+ <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
142
+ </svg>
143
+ </button>
144
+ </form>
145
+ HTML
146
+ end
147
+
148
+ def escape_html(text)
149
+ text.to_s.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;').gsub('"', '&quot;')
150
+ end
151
+
131
152
  def generate_auto_refresh_controls
132
153
  return '' unless SolidQueueMonitor.auto_refresh_enabled
133
154