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.
- checksums.yaml +4 -4
- data/README.md +29 -6
- data/app/controllers/solid_queue_monitor/jobs_controller.rb +72 -0
- data/app/controllers/solid_queue_monitor/queues_controller.rb +73 -2
- data/app/controllers/solid_queue_monitor/scheduled_jobs_controller.rb +9 -0
- data/app/controllers/solid_queue_monitor/workers_controller.rb +74 -0
- data/app/presenters/solid_queue_monitor/base_presenter.rb +7 -0
- data/app/presenters/solid_queue_monitor/failed_jobs_presenter.rb +3 -7
- data/app/presenters/solid_queue_monitor/in_progress_jobs_presenter.rb +2 -1
- data/app/presenters/solid_queue_monitor/job_details_presenter.rb +696 -0
- data/app/presenters/solid_queue_monitor/jobs_presenter.rb +3 -3
- data/app/presenters/solid_queue_monitor/queue_details_presenter.rb +194 -0
- data/app/presenters/solid_queue_monitor/queues_presenter.rb +1 -1
- data/app/presenters/solid_queue_monitor/ready_jobs_presenter.rb +2 -2
- data/app/presenters/solid_queue_monitor/recurring_jobs_presenter.rb +1 -1
- data/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb +2 -2
- data/app/presenters/solid_queue_monitor/workers_presenter.rb +319 -0
- data/app/services/solid_queue_monitor/html_generator.rb +2 -1
- data/app/services/solid_queue_monitor/stylesheet_generator.rb +249 -0
- data/config/routes.rb +7 -0
- data/lib/solid_queue_monitor/version.rb +1 -1
- metadata +6 -1
|
@@ -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(' ')
|
|
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|
|