solid_queue_monitor 1.0.0 → 1.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 (29) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -1
  3. data/app/controllers/solid_queue_monitor/base_controller.rb +34 -2
  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 +7 -3
  6. data/app/controllers/solid_queue_monitor/overview_controller.rb +7 -3
  7. data/app/controllers/solid_queue_monitor/queues_controller.rb +21 -8
  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 +4 -3
  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/workers_presenter.rb +4 -3
  24. data/app/services/solid_queue_monitor/html_generator.rb +23 -2
  25. data/app/services/solid_queue_monitor/search_service.rb +126 -0
  26. data/app/services/solid_queue_monitor/stylesheet_generator.rb +614 -0
  27. data/config/routes.rb +1 -0
  28. data/lib/solid_queue_monitor/version.rb +1 -1
  29. metadata +5 -2
@@ -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
@@ -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,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
 
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueMonitor
4
+ class SearchService
5
+ RESULTS_LIMIT = 25
6
+
7
+ def initialize(query)
8
+ @query = query
9
+ end
10
+
11
+ def search
12
+ return empty_results if @query.blank?
13
+
14
+ term = "%#{sanitize_query(@query)}%"
15
+
16
+ {
17
+ ready: search_ready_jobs(term),
18
+ scheduled: search_scheduled_jobs(term),
19
+ failed: search_failed_jobs(term),
20
+ in_progress: search_in_progress_jobs(term),
21
+ completed: search_completed_jobs(term),
22
+ recurring: search_recurring_tasks(term)
23
+ }
24
+ end
25
+
26
+ private
27
+
28
+ def empty_results
29
+ {
30
+ ready: [],
31
+ scheduled: [],
32
+ failed: [],
33
+ in_progress: [],
34
+ completed: [],
35
+ recurring: []
36
+ }
37
+ end
38
+
39
+ def sanitize_query(query)
40
+ # Escape % to prevent LIKE pattern injection
41
+ # We don't escape _ because it requires database-specific ESCAPE clauses
42
+ query.to_s.gsub('%', '\%')
43
+ end
44
+
45
+ def search_ready_jobs(term)
46
+ SolidQueue::ReadyExecution
47
+ .joins(:job)
48
+ .where(job_search_conditions, term: term)
49
+ .includes(:job)
50
+ .limit(RESULTS_LIMIT)
51
+ end
52
+
53
+ def search_scheduled_jobs(term)
54
+ SolidQueue::ScheduledExecution
55
+ .joins(:job)
56
+ .where(job_search_conditions, term: term)
57
+ .includes(:job)
58
+ .limit(RESULTS_LIMIT)
59
+ end
60
+
61
+ def search_failed_jobs(term)
62
+ SolidQueue::FailedExecution
63
+ .joins(:job)
64
+ .where(failed_job_search_conditions, term: term)
65
+ .includes(:job)
66
+ .limit(RESULTS_LIMIT)
67
+ end
68
+
69
+ def search_in_progress_jobs(term)
70
+ SolidQueue::ClaimedExecution
71
+ .joins(:job)
72
+ .where(job_search_conditions, term: term)
73
+ .includes(:job)
74
+ .limit(RESULTS_LIMIT)
75
+ end
76
+
77
+ def search_completed_jobs(term)
78
+ SolidQueue::Job
79
+ .where.not(finished_at: nil)
80
+ .where(completed_job_search_conditions, term: term)
81
+ .order(finished_at: :desc)
82
+ .limit(RESULTS_LIMIT)
83
+ end
84
+
85
+ def search_recurring_tasks(term)
86
+ SolidQueue::RecurringTask
87
+ .where(recurring_task_search_conditions, term: term)
88
+ .limit(RESULTS_LIMIT)
89
+ end
90
+
91
+ def job_search_conditions
92
+ <<~SQL.squish
93
+ solid_queue_jobs.class_name LIKE :term
94
+ OR solid_queue_jobs.queue_name LIKE :term
95
+ OR solid_queue_jobs.arguments LIKE :term
96
+ OR solid_queue_jobs.active_job_id LIKE :term
97
+ SQL
98
+ end
99
+
100
+ def failed_job_search_conditions
101
+ <<~SQL.squish
102
+ solid_queue_jobs.class_name LIKE :term
103
+ OR solid_queue_jobs.queue_name LIKE :term
104
+ OR solid_queue_jobs.arguments LIKE :term
105
+ OR solid_queue_jobs.active_job_id LIKE :term
106
+ OR solid_queue_failed_executions.error LIKE :term
107
+ SQL
108
+ end
109
+
110
+ def completed_job_search_conditions
111
+ <<~SQL.squish
112
+ class_name LIKE :term
113
+ OR queue_name LIKE :term
114
+ OR arguments LIKE :term
115
+ OR active_job_id LIKE :term
116
+ SQL
117
+ end
118
+
119
+ def recurring_task_search_conditions
120
+ <<~SQL.squish
121
+ solid_queue_recurring_tasks.key LIKE :term
122
+ OR solid_queue_recurring_tasks.class_name LIKE :term
123
+ SQL
124
+ end
125
+ end
126
+ end