solid_queue_monitor 0.6.0 → 1.0.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.
@@ -95,9 +95,9 @@ module SolidQueueMonitor
95
95
  # Build the row HTML
96
96
  row_html = <<-HTML
97
97
  <tr>
98
- <td>#{job.id}</td>
99
- <td>#{job.class_name}</td>
100
- <td>#{job.queue_name}</td>
98
+ <td><a href="#{job_path(job)}" class="job-class-link">#{job.id}</a></td>
99
+ <td><a href="#{job_path(job)}" class="job-class-link">#{job.class_name}</a></td>
100
+ <td>#{queue_link(job.queue_name)}</td>
101
101
  <td>#{format_arguments(job.arguments)}</td>
102
102
  <td><span class='status-badge status-#{status}'>#{status}</span></td>
103
103
  <td>#{format_datetime(job.created_at)}</td>
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueMonitor
4
+ class QueueDetailsPresenter < BasePresenter
5
+ def initialize(queue_name:, paused:, jobs:, counts:, current_page: 1, total_pages: 1, filters: {})
6
+ @queue_name = queue_name
7
+ @paused = paused
8
+ @jobs = jobs
9
+ @counts = counts
10
+ @current_page = current_page
11
+ @total_pages = total_pages
12
+ @filters = filters
13
+ end
14
+
15
+ def render
16
+ section_wrapper("Queue: #{@queue_name}",
17
+ render_header + render_stats_cards + generate_filter_form + generate_table + generate_pagination(@current_page, @total_pages))
18
+ end
19
+
20
+ private
21
+
22
+ def render_header
23
+ <<-HTML
24
+ <div class="section-header-row">
25
+ <div class="section-header-left">
26
+ #{status_badge}
27
+ </div>
28
+ <div class="section-header-right">
29
+ #{action_button}
30
+ </div>
31
+ </div>
32
+ HTML
33
+ end
34
+
35
+ def status_badge
36
+ if @paused
37
+ '<span class="status-badge status-paused">Paused</span>'
38
+ else
39
+ '<span class="status-badge status-active">Active</span>'
40
+ end
41
+ end
42
+
43
+ def action_button
44
+ if @paused
45
+ <<-HTML
46
+ <form action="#{resume_queue_path}" method="post" class="inline-form">
47
+ <input type="hidden" name="queue_name" value="#{@queue_name}">
48
+ <input type="hidden" name="redirect_to" value="#{queue_details_path(queue_name: @queue_name)}">
49
+ <button type="submit" class="action-button resume-button">Resume Queue</button>
50
+ </form>
51
+ HTML
52
+ else
53
+ <<-HTML
54
+ <form action="#{pause_queue_path}" method="post" class="inline-form"
55
+ onsubmit="return confirm('Are you sure you want to pause this queue?');">
56
+ <input type="hidden" name="queue_name" value="#{@queue_name}">
57
+ <input type="hidden" name="redirect_to" value="#{queue_details_path(queue_name: @queue_name)}">
58
+ <button type="submit" class="action-button pause-button">Pause Queue</button>
59
+ </form>
60
+ HTML
61
+ end
62
+ end
63
+
64
+ def render_stats_cards
65
+ <<-HTML
66
+ <div class="stats-container">
67
+ <div class="stats">
68
+ #{generate_stat_card('Total Jobs', @counts[:total])}
69
+ #{generate_stat_card('Ready', @counts[:ready])}
70
+ #{generate_stat_card('Scheduled', @counts[:scheduled])}
71
+ #{generate_stat_card('In Progress', @counts[:in_progress])}
72
+ #{generate_stat_card('Completed', @counts[:completed])}
73
+ #{generate_stat_card('Failed', @counts[:failed])}
74
+ </div>
75
+ </div>
76
+ HTML
77
+ end
78
+
79
+ def generate_stat_card(title, value)
80
+ <<-HTML
81
+ <div class="stat-card">
82
+ <h3>#{title}</h3>
83
+ <p>#{value}</p>
84
+ </div>
85
+ HTML
86
+ end
87
+
88
+ def generate_filter_form
89
+ <<-HTML
90
+ <div class="filter-form-container">
91
+ <form method="get" action="#{queue_details_path(queue_name: @queue_name)}" class="filter-form">
92
+ <div class="filter-group">
93
+ <label for="class_name">Job Class:</label>
94
+ <input type="text" name="class_name" id="class_name" value="#{@filters[:class_name]}" placeholder="Filter by class name">
95
+ </div>
96
+
97
+ <div class="filter-group">
98
+ <label for="arguments">Arguments:</label>
99
+ <input type="text" name="arguments" id="arguments" value="#{@filters[:arguments]}" placeholder="Filter by arguments">
100
+ </div>
101
+
102
+ <div class="filter-group">
103
+ <label for="status">Status:</label>
104
+ <select name="status" id="status">
105
+ <option value="">All Statuses</option>
106
+ <option value="completed" #{@filters[:status] == 'completed' ? 'selected' : ''}>Completed</option>
107
+ <option value="failed" #{@filters[:status] == 'failed' ? 'selected' : ''}>Failed</option>
108
+ <option value="scheduled" #{@filters[:status] == 'scheduled' ? 'selected' : ''}>Scheduled</option>
109
+ <option value="pending" #{@filters[:status] == 'pending' ? 'selected' : ''}>Pending</option>
110
+ <option value="in_progress" #{@filters[:status] == 'in_progress' ? 'selected' : ''}>In Progress</option>
111
+ </select>
112
+ </div>
113
+
114
+ <div class="filter-actions">
115
+ <button type="submit" class="filter-button">Apply Filters</button>
116
+ <a href="#{queue_details_path(queue_name: @queue_name)}" class="reset-button">Reset</a>
117
+ </div>
118
+ </form>
119
+ </div>
120
+ HTML
121
+ end
122
+
123
+ def generate_table
124
+ return '<p class="empty-message">No jobs in this queue</p>' if @jobs.empty?
125
+
126
+ <<-HTML
127
+ <div class="table-container">
128
+ <table>
129
+ <thead>
130
+ <tr>
131
+ <th>ID</th>
132
+ <th>Job</th>
133
+ <th>Arguments</th>
134
+ <th>Status</th>
135
+ <th>Created At</th>
136
+ <th>Actions</th>
137
+ </tr>
138
+ </thead>
139
+ <tbody>
140
+ #{@jobs.map { |job| generate_row(job) }.join}
141
+ </tbody>
142
+ </table>
143
+ </div>
144
+ HTML
145
+ end
146
+
147
+ def generate_row(job)
148
+ status = job_status(job)
149
+
150
+ row_html = <<-HTML
151
+ <tr>
152
+ <td><a href="#{job_path(job)}" class="job-class-link">#{job.id}</a></td>
153
+ <td><a href="#{job_path(job)}" class="job-class-link">#{job.class_name}</a></td>
154
+ <td>#{format_arguments(job.arguments)}</td>
155
+ <td><span class="status-badge status-#{status}">#{status}</span></td>
156
+ <td>#{format_datetime(job.created_at)}</td>
157
+ HTML
158
+
159
+ # Add actions column for failed jobs
160
+ if status == 'failed'
161
+ failed_execution = SolidQueue::FailedExecution.find_by(job_id: job.id)
162
+
163
+ row_html += if failed_execution
164
+ <<-HTML
165
+ <td class="actions-cell">
166
+ <div class="job-actions">
167
+ <form method="post" action="#{retry_failed_job_path(id: failed_execution.id)}" class="inline-form">
168
+ <input type="hidden" name="redirect_to" value="#{queue_details_path(queue_name: @queue_name)}">
169
+ <button type="submit" class="action-button retry-button">Retry</button>
170
+ </form>
171
+ <form method="post" action="#{discard_failed_job_path(id: failed_execution.id)}" class="inline-form"
172
+ onsubmit="return confirm('Are you sure you want to discard this job?');">
173
+ <input type="hidden" name="redirect_to" value="#{queue_details_path(queue_name: @queue_name)}">
174
+ <button type="submit" class="action-button discard-button">Discard</button>
175
+ </form>
176
+ </div>
177
+ </td>
178
+ HTML
179
+ else
180
+ '<td></td>'
181
+ end
182
+ else
183
+ row_html += '<td></td>'
184
+ end
185
+
186
+ row_html += '</tr>'
187
+ row_html
188
+ end
189
+
190
+ def job_status(job)
191
+ SolidQueueMonitor::StatusCalculator.new(job).calculate
192
+ end
193
+ end
194
+ end
@@ -42,7 +42,7 @@ module SolidQueueMonitor
42
42
 
43
43
  <<-HTML
44
44
  <tr class="#{paused ? 'queue-paused' : ''}">
45
- <td>#{queue_name}</td>
45
+ <td>#{queue_link(queue_name)}</td>
46
46
  <td>#{status_badge(paused)}</td>
47
47
  <td>#{queue.job_count}</td>
48
48
  <td>#{ready_jobs_count(queue_name)}</td>
@@ -68,8 +68,8 @@ module SolidQueueMonitor
68
68
  def generate_row(execution)
69
69
  <<-HTML
70
70
  <tr>
71
- <td>#{execution.job.class_name}</td>
72
- <td>#{execution.queue_name}</td>
71
+ <td><a href="#{job_path(execution.job)}" class="job-class-link">#{execution.job.class_name}</a></td>
72
+ <td>#{queue_link(execution.queue_name)}</td>
73
73
  <td>#{execution.priority}</td>
74
74
  <td>#{format_arguments(execution.job.arguments)}</td>
75
75
  <td>#{format_datetime(execution.created_at)}</td>
@@ -70,7 +70,7 @@ module SolidQueueMonitor
70
70
  <td>#{task.key}</td>
71
71
  <td>#{task.class_name}</td>
72
72
  <td>#{task.schedule}</td>
73
- <td>#{task.queue_name}</td>
73
+ <td>#{queue_link(task.queue_name)}</td>
74
74
  <td>#{task.priority || 'Default'}</td>
75
75
  <td>#{format_datetime(task.updated_at)}</td>
76
76
  </tr>
@@ -161,8 +161,8 @@ module SolidQueueMonitor
161
161
  <td>
162
162
  <input type="checkbox" name="job_ids[]" value="#{execution.id}">
163
163
  </td>
164
- <td>#{execution.job.class_name}</td>
165
- <td>#{execution.queue_name}</td>
164
+ <td><a href="#{job_path(execution.job)}" class="job-class-link">#{execution.job.class_name}</a></td>
165
+ <td>#{queue_link(execution.queue_name)}</td>
166
166
  <td>#{format_datetime(execution.scheduled_at)}</td>
167
167
  <td>#{format_arguments(execution.job.arguments)}</td>
168
168
  </tr>
@@ -0,0 +1,319 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueMonitor
4
+ class WorkersPresenter < BasePresenter
5
+ HEARTBEAT_STALE_THRESHOLD = 5.minutes
6
+ HEARTBEAT_DEAD_THRESHOLD = 10.minutes
7
+
8
+ def initialize(processes, current_page: 1, total_pages: 1, filters: {})
9
+ @processes = processes.to_a # Load records once to avoid multiple queries
10
+ @current_page = current_page
11
+ @total_pages = total_pages
12
+ @filters = filters
13
+ preload_claimed_data
14
+ calculate_summary_stats
15
+ end
16
+
17
+ def render
18
+ section_wrapper('Workers', generate_content)
19
+ end
20
+
21
+ private
22
+
23
+ def generate_content
24
+ generate_summary + generate_filter_form + generate_table_or_empty + generate_pagination(@current_page, @total_pages)
25
+ end
26
+
27
+ def generate_filter_form
28
+ <<-HTML
29
+ <div class="filter-form-container">
30
+ <form method="get" action="#{workers_path}" class="filter-form">
31
+ <div class="filter-group">
32
+ <label for="kind">Kind:</label>
33
+ <select name="kind" id="kind">
34
+ <option value="">All</option>
35
+ #{kind_options}
36
+ </select>
37
+ </div>
38
+
39
+ <div class="filter-group">
40
+ <label for="hostname">Hostname:</label>
41
+ <input type="text" name="hostname" id="hostname" value="#{@filters[:hostname]}" placeholder="Filter by hostname">
42
+ </div>
43
+
44
+ <div class="filter-group">
45
+ <label for="status">Status:</label>
46
+ <select name="status" id="status">
47
+ <option value="">All</option>
48
+ <option value="healthy" #{@filters[:status] == 'healthy' ? 'selected' : ''}>Healthy</option>
49
+ <option value="stale" #{@filters[:status] == 'stale' ? 'selected' : ''}>Stale</option>
50
+ <option value="dead" #{@filters[:status] == 'dead' ? 'selected' : ''}>Dead</option>
51
+ </select>
52
+ </div>
53
+
54
+ <div class="filter-actions">
55
+ <button type="submit" class="filter-button">Apply Filters</button>
56
+ <a href="#{workers_path}" class="reset-button">Reset</a>
57
+ </div>
58
+ </form>
59
+ </div>
60
+ HTML
61
+ end
62
+
63
+ def kind_options
64
+ kinds = %w[Worker Dispatcher Scheduler]
65
+ kinds.map do |kind|
66
+ selected = @filters[:kind] == kind ? 'selected' : ''
67
+ "<option value=\"#{kind}\" #{selected}>#{kind}</option>"
68
+ end.join
69
+ end
70
+
71
+ def calculate_summary_stats
72
+ all_processes = all_processes_for_summary
73
+ @total_count = all_processes.count
74
+ @healthy_count = all_processes.count { |p| worker_status(p) == :healthy }
75
+ @stale_count = all_processes.count { |p| worker_status(p) == :stale }
76
+ @dead_count = all_processes.count { |p| worker_status(p) == :dead }
77
+ end
78
+
79
+ def generate_summary
80
+ <<-HTML
81
+ <div class="workers-summary">
82
+ <div class="summary-card">
83
+ <span class="summary-label">Total Processes</span>
84
+ <span class="summary-value">#{@total_count}</span>
85
+ </div>
86
+ <div class="summary-card summary-healthy">
87
+ <span class="summary-label">Healthy</span>
88
+ <span class="summary-value">#{@healthy_count}</span>
89
+ </div>
90
+ <div class="summary-card summary-stale">
91
+ <span class="summary-label">Stale</span>
92
+ <span class="summary-value">#{@stale_count}</span>
93
+ </div>
94
+ <div class="summary-card summary-dead">
95
+ <span class="summary-label">Dead</span>
96
+ <span class="summary-value">#{@dead_count}</span>
97
+ #{prune_all_link}
98
+ </div>
99
+ </div>
100
+ HTML
101
+ end
102
+
103
+ def prune_all_link
104
+ return '' if @dead_count.zero?
105
+
106
+ <<-HTML
107
+ <a href="#" class="summary-action"
108
+ onclick="if(confirm('Remove all #{@dead_count} dead process#{@dead_count > 1 ? 'es' : ''}? This will clean up processes that have stopped sending heartbeats.')) { document.getElementById('prune-all-form').submit(); } return false;">
109
+ Prune all
110
+ </a>
111
+ <form id="prune-all-form" action="#{prune_workers_path}" method="post" style="display: none;"></form>
112
+ HTML
113
+ end
114
+
115
+ def all_processes_for_summary
116
+ @all_processes_for_summary ||= SolidQueue::Process.all.to_a
117
+ end
118
+
119
+ def generate_table_or_empty
120
+ if @processes.empty?
121
+ generate_empty_state
122
+ else
123
+ generate_table
124
+ end
125
+ end
126
+
127
+ def generate_empty_state
128
+ <<-HTML
129
+ <div class="empty-state">
130
+ <p>No worker processes found.</p>
131
+ <p class="empty-state-hint">Workers will appear here when Solid Queue processes are running.</p>
132
+ </div>
133
+ HTML
134
+ end
135
+
136
+ def generate_table
137
+ <<-HTML
138
+ <div class="table-container">
139
+ <table>
140
+ <thead>
141
+ <tr>
142
+ <th>Kind</th>
143
+ <th>Hostname</th>
144
+ <th>PID</th>
145
+ <th>Queues</th>
146
+ <th>Last Heartbeat</th>
147
+ <th>Status</th>
148
+ <th>Jobs Processing</th>
149
+ <th>Actions</th>
150
+ </tr>
151
+ </thead>
152
+ <tbody>
153
+ #{@processes.map { |process| generate_row(process) }.join}
154
+ </tbody>
155
+ </table>
156
+ </div>
157
+ HTML
158
+ end
159
+
160
+ def generate_row(process)
161
+ status = worker_status(process)
162
+ row_class = case status
163
+ when :dead then 'worker-dead'
164
+ when :stale then 'worker-stale'
165
+ else ''
166
+ end
167
+
168
+ <<-HTML
169
+ <tr class="#{row_class}">
170
+ <td>#{kind_badge(process.kind)}</td>
171
+ <td>#{hostname(process)}</td>
172
+ <td><code>#{process.pid}</code></td>
173
+ <td>#{queues_display(process)}</td>
174
+ <td>#{format_heartbeat(process.last_heartbeat_at)}</td>
175
+ <td>#{status_badge(status)}</td>
176
+ <td>#{jobs_processing(process)}</td>
177
+ <td class="actions-cell">#{action_button(process, status)}</td>
178
+ </tr>
179
+ HTML
180
+ end
181
+
182
+ def action_button(process, status)
183
+ return '<span class="action-placeholder">-</span>' unless status == :dead
184
+
185
+ <<-HTML
186
+ <form action="#{remove_worker_path(id: process.id)}" method="post" class="inline-form"
187
+ onsubmit="return confirm('Remove this dead process from the registry?');">
188
+ <button type="submit" class="action-button discard-button" title="Remove dead process">
189
+ Remove
190
+ </button>
191
+ </form>
192
+ HTML
193
+ end
194
+
195
+ def kind_badge(kind)
196
+ badge_class = case kind
197
+ when 'Worker' then 'kind-worker'
198
+ when 'Dispatcher' then 'kind-dispatcher'
199
+ when 'Scheduler' then 'kind-scheduler'
200
+ else 'kind-other'
201
+ end
202
+ "<span class=\"kind-badge #{badge_class}\">#{kind}</span>"
203
+ end
204
+
205
+ def hostname(process)
206
+ process.hostname || parse_metadata(process)['hostname'] || '-'
207
+ end
208
+
209
+ def queues_display(process)
210
+ metadata = parse_metadata(process)
211
+ queues = metadata['queues']
212
+
213
+ return '-' if queues.nil?
214
+
215
+ # Handle string queues (e.g., "*" for all queues)
216
+ if queues.is_a?(String)
217
+ return "<code class=\"queue-tag\">#{queues == '*' ? 'All Queues' : queues}</code>"
218
+ end
219
+
220
+ return '-' if queues.empty?
221
+
222
+ if queues.length <= 3
223
+ queues.map { |q| "<code class=\"queue-tag\">#{q}</code>" }.join(' ')
224
+ else
225
+ visible = queues.first(2).map { |q| "<code class=\"queue-tag\">#{q}</code>" }.join(' ')
226
+ "#{visible} <span class=\"queue-more\">+#{queues.length - 2} more</span>"
227
+ end
228
+ end
229
+
230
+ def format_heartbeat(heartbeat_at)
231
+ return '-' unless heartbeat_at
232
+
233
+ time_ago = time_ago_in_words(heartbeat_at)
234
+ "<span title=\"#{heartbeat_at.strftime('%Y-%m-%d %H:%M:%S')}\">#{time_ago} ago</span>"
235
+ end
236
+
237
+ def worker_status(process)
238
+ return :dead unless process.last_heartbeat_at
239
+
240
+ time_since_heartbeat = Time.current - process.last_heartbeat_at
241
+
242
+ if time_since_heartbeat > HEARTBEAT_DEAD_THRESHOLD
243
+ :dead
244
+ elsif time_since_heartbeat > HEARTBEAT_STALE_THRESHOLD
245
+ :stale
246
+ else
247
+ :healthy
248
+ end
249
+ end
250
+
251
+ def status_badge(status)
252
+ badges = {
253
+ healthy: '<span class="status-badge status-healthy">Healthy</span>',
254
+ stale: '<span class="status-badge status-stale">Stale</span>',
255
+ dead: '<span class="status-badge status-dead">Dead</span>'
256
+ }
257
+ badges[status]
258
+ end
259
+
260
+ def jobs_processing(process)
261
+ count = @claimed_counts[process.id] || 0
262
+
263
+ if count.zero?
264
+ '<span class="jobs-idle">Idle</span>'
265
+ else
266
+ jobs = @claimed_jobs[process.id] || []
267
+ job_names = jobs.map(&:class_name).uniq.first(3)
268
+
269
+ tooltip = jobs.first(10).map { |j| "#{j.class_name} (ID: #{j.id})" }.join('&#10;')
270
+
271
+ <<-HTML
272
+ <span class="jobs-processing" title="#{tooltip}">
273
+ #{count} job#{count > 1 ? 's' : ''}
274
+ <span class="job-names">(#{job_names.join(', ')}#{jobs.length > 3 ? '...' : ''})</span>
275
+ </span>
276
+ HTML
277
+ end
278
+ end
279
+
280
+ def preload_claimed_data
281
+ return if @processes.empty?
282
+
283
+ process_ids = @processes.map(&:id)
284
+
285
+ # Preload claimed execution counts
286
+ @claimed_counts = SolidQueue::ClaimedExecution
287
+ .where(process_id: process_ids)
288
+ .group(:process_id)
289
+ .count
290
+
291
+ # Preload claimed jobs for processes that have any
292
+ claimed_executions = SolidQueue::ClaimedExecution
293
+ .includes(:job)
294
+ .where(process_id: process_ids)
295
+
296
+ @claimed_jobs = claimed_executions.each_with_object({}) do |execution, hash|
297
+ hash[execution.process_id] ||= []
298
+ hash[execution.process_id] << execution.job
299
+ end
300
+ end
301
+
302
+ def parse_metadata(process)
303
+ @parsed_metadata ||= {}
304
+ @parsed_metadata[process.id] ||= parse_process_metadata(process)
305
+ end
306
+
307
+ def parse_process_metadata(process)
308
+ return {} unless process.metadata
309
+
310
+ if process.metadata.is_a?(String)
311
+ JSON.parse(process.metadata)
312
+ else
313
+ process.metadata
314
+ end
315
+ rescue JSON::ParserError
316
+ {}
317
+ end
318
+ end
319
+ end
@@ -95,7 +95,8 @@ module SolidQueueMonitor
95
95
  { path: scheduled_jobs_path, label: 'Scheduled Jobs', match: 'Scheduled Jobs' },
96
96
  { path: recurring_jobs_path, label: 'Recurring Jobs', match: 'Recurring Jobs' },
97
97
  { path: failed_jobs_path, label: 'Failed Jobs', match: 'Failed Jobs' },
98
- { path: queues_path, label: 'Queues', match: 'Queues' }
98
+ { path: queues_path, label: 'Queues', match: 'Queues' },
99
+ { path: workers_path, label: 'Workers', match: 'Workers' }
99
100
  ]
100
101
 
101
102
  nav_links = nav_items.map do |item|