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.
- checksums.yaml +4 -4
- data/README.md +66 -4
- data/app/assets/javascripts/solid_queue_monitor/application.js +393 -0
- data/app/{services/solid_queue_monitor/stylesheet_generator.rb → assets/stylesheets/solid_queue_monitor/application.css} +23 -12
- data/app/controllers/solid_queue_monitor/application_controller.rb +9 -3
- data/app/controllers/solid_queue_monitor/assets_controller.rb +52 -0
- data/app/controllers/solid_queue_monitor/base_controller.rb +0 -29
- data/app/controllers/solid_queue_monitor/failed_jobs_controller.rb +3 -7
- data/app/controllers/solid_queue_monitor/in_progress_jobs_controller.rb +3 -6
- data/app/controllers/solid_queue_monitor/jobs_controller.rb +3 -7
- data/app/controllers/solid_queue_monitor/overview_controller.rb +3 -12
- data/app/controllers/solid_queue_monitor/queues_controller.rb +4 -18
- data/app/controllers/solid_queue_monitor/ready_jobs_controller.rb +3 -6
- data/app/controllers/solid_queue_monitor/recurring_jobs_controller.rb +3 -6
- data/app/controllers/solid_queue_monitor/scheduled_jobs_controller.rb +3 -7
- data/app/controllers/solid_queue_monitor/search_controller.rb +3 -4
- data/app/controllers/solid_queue_monitor/workers_controller.rb +24 -8
- data/app/helpers/solid_queue_monitor/application_helper.rb +46 -0
- data/app/helpers/solid_queue_monitor/chart_helper.rb +293 -0
- data/app/helpers/solid_queue_monitor/job_details_helper.rb +66 -0
- data/app/helpers/solid_queue_monitor/jobs_helper.rb +134 -0
- data/app/helpers/solid_queue_monitor/pagination_helper.rb +23 -0
- data/app/helpers/solid_queue_monitor/sort_helper.rb +30 -0
- data/app/helpers/solid_queue_monitor/workers_helper.rb +88 -0
- data/app/services/solid_queue_monitor/asset_cache.rb +56 -0
- data/app/views/layouts/solid_queue_monitor/application.html.erb +25 -0
- data/app/views/solid_queue_monitor/failed_jobs/_row.html.erb +26 -0
- data/app/views/solid_queue_monitor/failed_jobs/index.html.erb +38 -0
- data/app/views/solid_queue_monitor/in_progress_jobs/_row.html.erb +13 -0
- data/app/views/solid_queue_monitor/in_progress_jobs/index.html.erb +25 -0
- data/app/views/solid_queue_monitor/jobs/_arguments.html.erb +9 -0
- data/app/views/solid_queue_monitor/jobs/_error.html.erb +26 -0
- data/app/views/solid_queue_monitor/jobs/_execution_history.html.erb +25 -0
- data/app/views/solid_queue_monitor/jobs/_header.html.erb +37 -0
- data/app/views/solid_queue_monitor/jobs/_metadata.html.erb +22 -0
- data/app/views/solid_queue_monitor/jobs/_raw_data.html.erb +11 -0
- data/app/views/solid_queue_monitor/jobs/_timeline.html.erb +29 -0
- data/app/views/solid_queue_monitor/jobs/_timing.html.erb +9 -0
- data/app/views/solid_queue_monitor/jobs/_worker.html.erb +12 -0
- data/app/views/solid_queue_monitor/jobs/show.html.erb +22 -0
- data/app/views/solid_queue_monitor/overview/_chart.html.erb +1 -0
- data/app/views/solid_queue_monitor/overview/_recent_job_row.html.erb +26 -0
- data/app/views/solid_queue_monitor/overview/_recent_jobs.html.erb +31 -0
- data/app/views/solid_queue_monitor/overview/_stat_card.html.erb +4 -0
- data/app/views/solid_queue_monitor/overview/_stats.html.erb +11 -0
- data/app/views/solid_queue_monitor/overview/index.html.erb +9 -0
- data/app/views/solid_queue_monitor/queues/_job_row.html.erb +26 -0
- data/app/views/solid_queue_monitor/queues/_row.html.erb +33 -0
- data/app/views/solid_queue_monitor/queues/index.html.erb +18 -0
- data/app/views/solid_queue_monitor/queues/show.html.erb +63 -0
- data/app/views/solid_queue_monitor/ready_jobs/_row.html.erb +7 -0
- data/app/views/solid_queue_monitor/ready_jobs/index.html.erb +25 -0
- data/app/views/solid_queue_monitor/recurring_jobs/_row.html.erb +8 -0
- data/app/views/solid_queue_monitor/recurring_jobs/index.html.erb +26 -0
- data/app/views/solid_queue_monitor/scheduled_jobs/_row.html.erb +7 -0
- data/app/views/solid_queue_monitor/scheduled_jobs/index.html.erb +36 -0
- data/app/views/solid_queue_monitor/search/_completed_row.html.erb +6 -0
- data/app/views/solid_queue_monitor/search/_failed_row.html.erb +6 -0
- data/app/views/solid_queue_monitor/search/_job_row.html.erb +9 -0
- data/app/views/solid_queue_monitor/search/_recurring_row.html.erb +6 -0
- data/app/views/solid_queue_monitor/search/_section.html.erb +25 -0
- data/app/views/solid_queue_monitor/search/index.html.erb +23 -0
- data/app/views/solid_queue_monitor/shared/_filters.html.erb +48 -0
- data/app/views/solid_queue_monitor/shared/_flash.html.erb +17 -0
- data/app/views/solid_queue_monitor/shared/_footer.html.erb +3 -0
- data/app/views/solid_queue_monitor/shared/_header.html.erb +81 -0
- data/app/views/solid_queue_monitor/shared/_jobs_table.html.erb +20 -0
- data/app/views/solid_queue_monitor/shared/_pagination.html.erb +25 -0
- data/app/views/solid_queue_monitor/workers/_row.html.erb +22 -0
- data/app/views/solid_queue_monitor/workers/index.html.erb +82 -0
- data/config/routes.rb +6 -1
- data/lib/solid_queue_monitor/engine.rb +2 -0
- data/lib/solid_queue_monitor/version.rb +1 -1
- data/lib/solid_queue_monitor.rb +8 -1
- metadata +57 -17
- data/app/presenters/solid_queue_monitor/base_presenter.rb +0 -211
- data/app/presenters/solid_queue_monitor/failed_jobs_presenter.rb +0 -225
- data/app/presenters/solid_queue_monitor/in_progress_jobs_presenter.rb +0 -84
- data/app/presenters/solid_queue_monitor/job_details_presenter.rb +0 -707
- data/app/presenters/solid_queue_monitor/jobs_presenter.rb +0 -144
- data/app/presenters/solid_queue_monitor/queue_details_presenter.rb +0 -195
- data/app/presenters/solid_queue_monitor/queues_presenter.rb +0 -89
- data/app/presenters/solid_queue_monitor/ready_jobs_presenter.rb +0 -81
- data/app/presenters/solid_queue_monitor/recurring_jobs_presenter.rb +0 -81
- data/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb +0 -178
- data/app/presenters/solid_queue_monitor/search_results_presenter.rb +0 -190
- data/app/presenters/solid_queue_monitor/stats_presenter.rb +0 -36
- data/app/presenters/solid_queue_monitor/workers_presenter.rb +0 -325
- data/app/services/solid_queue_monitor/chart_presenter.rb +0 -239
- data/app/services/solid_queue_monitor/html_generator.rb +0 -427
|
@@ -1,325 +0,0 @@
|
|
|
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: {}, sort: {})
|
|
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
|
-
@sort = sort
|
|
14
|
-
preload_claimed_data
|
|
15
|
-
calculate_summary_stats
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def render
|
|
19
|
-
section_wrapper('Workers', generate_content)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
private
|
|
23
|
-
|
|
24
|
-
def generate_content
|
|
25
|
-
generate_summary + generate_filter_form + generate_table_or_empty + generate_pagination(@current_page, @total_pages)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def generate_filter_form
|
|
29
|
-
<<-HTML
|
|
30
|
-
<div class="filter-form-container">
|
|
31
|
-
<form method="get" action="#{workers_path}" class="filter-form">
|
|
32
|
-
<div class="filter-group">
|
|
33
|
-
<label for="kind">Kind:</label>
|
|
34
|
-
<select name="kind" id="kind">
|
|
35
|
-
<option value="">All</option>
|
|
36
|
-
#{kind_options}
|
|
37
|
-
</select>
|
|
38
|
-
</div>
|
|
39
|
-
|
|
40
|
-
<div class="filter-group">
|
|
41
|
-
<label for="hostname">Hostname:</label>
|
|
42
|
-
<input type="text" name="hostname" id="hostname" value="#{@filters[:hostname]}" placeholder="Filter by hostname">
|
|
43
|
-
</div>
|
|
44
|
-
|
|
45
|
-
<div class="filter-group">
|
|
46
|
-
<label for="status">Status:</label>
|
|
47
|
-
<select name="status" id="status">
|
|
48
|
-
<option value="">All</option>
|
|
49
|
-
<option value="healthy" #{@filters[:status] == 'healthy' ? 'selected' : ''}>Healthy</option>
|
|
50
|
-
<option value="stale" #{@filters[:status] == 'stale' ? 'selected' : ''}>Stale</option>
|
|
51
|
-
<option value="dead" #{@filters[:status] == 'dead' ? 'selected' : ''}>Dead</option>
|
|
52
|
-
</select>
|
|
53
|
-
</div>
|
|
54
|
-
|
|
55
|
-
<div class="filter-actions">
|
|
56
|
-
<button type="submit" class="filter-button">Apply Filters</button>
|
|
57
|
-
<a href="#{workers_path}" class="reset-button">Reset</a>
|
|
58
|
-
</div>
|
|
59
|
-
</form>
|
|
60
|
-
</div>
|
|
61
|
-
HTML
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def kind_options
|
|
65
|
-
kinds = %w[Worker Dispatcher Scheduler]
|
|
66
|
-
kinds.map do |kind|
|
|
67
|
-
selected = @filters[:kind] == kind ? 'selected' : ''
|
|
68
|
-
"<option value=\"#{kind}\" #{selected}>#{kind}</option>"
|
|
69
|
-
end.join
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def calculate_summary_stats
|
|
73
|
-
all_processes = all_processes_for_summary
|
|
74
|
-
@total_count = all_processes.count
|
|
75
|
-
@healthy_count = all_processes.count { |p| worker_status(p) == :healthy }
|
|
76
|
-
@stale_count = all_processes.count { |p| worker_status(p) == :stale }
|
|
77
|
-
@dead_count = all_processes.count { |p| worker_status(p) == :dead }
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def generate_summary
|
|
81
|
-
<<-HTML
|
|
82
|
-
<div class="workers-summary">
|
|
83
|
-
<div class="summary-card">
|
|
84
|
-
<span class="summary-label">Total Processes</span>
|
|
85
|
-
<span class="summary-value">#{@total_count}</span>
|
|
86
|
-
</div>
|
|
87
|
-
<div class="summary-card summary-healthy">
|
|
88
|
-
<span class="summary-label">Healthy</span>
|
|
89
|
-
<span class="summary-value">#{@healthy_count}</span>
|
|
90
|
-
</div>
|
|
91
|
-
<div class="summary-card summary-stale">
|
|
92
|
-
<span class="summary-label">Stale</span>
|
|
93
|
-
<span class="summary-value">#{@stale_count}</span>
|
|
94
|
-
</div>
|
|
95
|
-
<div class="summary-card summary-dead">
|
|
96
|
-
<span class="summary-label">Dead</span>
|
|
97
|
-
<span class="summary-value">#{@dead_count}</span>
|
|
98
|
-
#{prune_all_link}
|
|
99
|
-
</div>
|
|
100
|
-
</div>
|
|
101
|
-
HTML
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def prune_all_link
|
|
105
|
-
return '' if @dead_count.zero?
|
|
106
|
-
|
|
107
|
-
suffix = @dead_count > 1 ? 'es' : ''
|
|
108
|
-
message = "Remove all #{@dead_count} dead process#{suffix}? " \
|
|
109
|
-
'This will clean up processes that have stopped sending heartbeats.'
|
|
110
|
-
|
|
111
|
-
<<-HTML
|
|
112
|
-
<a href="#" class="summary-action"
|
|
113
|
-
data-confirm-submit="prune-all-form"
|
|
114
|
-
data-confirm="#{CGI.escapeHTML(message)}">
|
|
115
|
-
Prune all
|
|
116
|
-
</a>
|
|
117
|
-
<form id="prune-all-form" action="#{prune_workers_path}" method="post" class="is-hidden"></form>
|
|
118
|
-
HTML
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
def all_processes_for_summary
|
|
122
|
-
@all_processes_for_summary ||= SolidQueue::Process.all.to_a
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
def generate_table_or_empty
|
|
126
|
-
if @processes.empty?
|
|
127
|
-
generate_empty_state
|
|
128
|
-
else
|
|
129
|
-
generate_table
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
def generate_empty_state
|
|
134
|
-
<<-HTML
|
|
135
|
-
<div class="empty-state">
|
|
136
|
-
<p>No worker processes found.</p>
|
|
137
|
-
<p class="empty-state-hint">Workers will appear here when Solid Queue processes are running.</p>
|
|
138
|
-
</div>
|
|
139
|
-
HTML
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def generate_table
|
|
143
|
-
<<-HTML
|
|
144
|
-
<div class="table-container">
|
|
145
|
-
<table>
|
|
146
|
-
<thead>
|
|
147
|
-
<tr>
|
|
148
|
-
<th>Kind</th>
|
|
149
|
-
#{sortable_header('hostname', 'Hostname')}
|
|
150
|
-
<th>PID</th>
|
|
151
|
-
<th>Queues</th>
|
|
152
|
-
#{sortable_header('last_heartbeat_at', 'Last Heartbeat')}
|
|
153
|
-
<th>Status</th>
|
|
154
|
-
<th>Jobs Processing</th>
|
|
155
|
-
<th>Actions</th>
|
|
156
|
-
</tr>
|
|
157
|
-
</thead>
|
|
158
|
-
<tbody>
|
|
159
|
-
#{@processes.map { |process| generate_row(process) }.join}
|
|
160
|
-
</tbody>
|
|
161
|
-
</table>
|
|
162
|
-
</div>
|
|
163
|
-
HTML
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def generate_row(process)
|
|
167
|
-
status = worker_status(process)
|
|
168
|
-
row_class = case status
|
|
169
|
-
when :dead then 'worker-dead'
|
|
170
|
-
when :stale then 'worker-stale'
|
|
171
|
-
else ''
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
<<-HTML
|
|
175
|
-
<tr class="#{row_class}">
|
|
176
|
-
<td>#{kind_badge(process.kind)}</td>
|
|
177
|
-
<td>#{hostname(process)}</td>
|
|
178
|
-
<td><code>#{process.pid}</code></td>
|
|
179
|
-
<td>#{queues_display(process)}</td>
|
|
180
|
-
<td>#{format_heartbeat(process.last_heartbeat_at)}</td>
|
|
181
|
-
<td>#{status_badge(status)}</td>
|
|
182
|
-
<td>#{jobs_processing(process)}</td>
|
|
183
|
-
<td class="actions-cell">#{action_button(process, status)}</td>
|
|
184
|
-
</tr>
|
|
185
|
-
HTML
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
def action_button(process, status)
|
|
189
|
-
return '<span class="action-placeholder">-</span>' unless status == :dead
|
|
190
|
-
|
|
191
|
-
<<-HTML
|
|
192
|
-
<form action="#{remove_worker_path(id: process.id)}" method="post" class="inline-form"
|
|
193
|
-
data-confirm="Remove this dead process from the registry?">
|
|
194
|
-
<button type="submit" class="action-button discard-button" title="Remove dead process">
|
|
195
|
-
Remove
|
|
196
|
-
</button>
|
|
197
|
-
</form>
|
|
198
|
-
HTML
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
def kind_badge(kind)
|
|
202
|
-
badge_class = case kind
|
|
203
|
-
when 'Worker' then 'kind-worker'
|
|
204
|
-
when 'Dispatcher' then 'kind-dispatcher'
|
|
205
|
-
when 'Scheduler' then 'kind-scheduler'
|
|
206
|
-
else 'kind-other'
|
|
207
|
-
end
|
|
208
|
-
"<span class=\"kind-badge #{badge_class}\">#{kind}</span>"
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
def hostname(process)
|
|
212
|
-
process.hostname || parse_metadata(process)['hostname'] || '-'
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
def queues_display(process)
|
|
216
|
-
metadata = parse_metadata(process)
|
|
217
|
-
queues = metadata['queues']
|
|
218
|
-
|
|
219
|
-
return '-' if queues.nil?
|
|
220
|
-
|
|
221
|
-
# Handle string queues (e.g., "*" for all queues)
|
|
222
|
-
if queues.is_a?(String)
|
|
223
|
-
return "<code class=\"queue-tag\">#{queues == '*' ? 'All Queues' : queues}</code>"
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
return '-' if queues.empty?
|
|
227
|
-
|
|
228
|
-
if queues.length <= 3
|
|
229
|
-
queues.map { |q| "<code class=\"queue-tag\">#{q}</code>" }.join(' ')
|
|
230
|
-
else
|
|
231
|
-
visible = queues.first(2).map { |q| "<code class=\"queue-tag\">#{q}</code>" }.join(' ')
|
|
232
|
-
"#{visible} <span class=\"queue-more\">+#{queues.length - 2} more</span>"
|
|
233
|
-
end
|
|
234
|
-
end
|
|
235
|
-
|
|
236
|
-
def format_heartbeat(heartbeat_at)
|
|
237
|
-
return '-' unless heartbeat_at
|
|
238
|
-
|
|
239
|
-
time_ago = time_ago_in_words(heartbeat_at)
|
|
240
|
-
"<span title=\"#{heartbeat_at.strftime('%Y-%m-%d %H:%M:%S')}\">#{time_ago} ago</span>"
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
def worker_status(process)
|
|
244
|
-
return :dead unless process.last_heartbeat_at
|
|
245
|
-
|
|
246
|
-
time_since_heartbeat = Time.current - process.last_heartbeat_at
|
|
247
|
-
|
|
248
|
-
if time_since_heartbeat > HEARTBEAT_DEAD_THRESHOLD
|
|
249
|
-
:dead
|
|
250
|
-
elsif time_since_heartbeat > HEARTBEAT_STALE_THRESHOLD
|
|
251
|
-
:stale
|
|
252
|
-
else
|
|
253
|
-
:healthy
|
|
254
|
-
end
|
|
255
|
-
end
|
|
256
|
-
|
|
257
|
-
def status_badge(status)
|
|
258
|
-
badges = {
|
|
259
|
-
healthy: '<span class="status-badge status-healthy">Healthy</span>',
|
|
260
|
-
stale: '<span class="status-badge status-stale">Stale</span>',
|
|
261
|
-
dead: '<span class="status-badge status-dead">Dead</span>'
|
|
262
|
-
}
|
|
263
|
-
badges[status]
|
|
264
|
-
end
|
|
265
|
-
|
|
266
|
-
def jobs_processing(process)
|
|
267
|
-
count = @claimed_counts[process.id] || 0
|
|
268
|
-
|
|
269
|
-
if count.zero?
|
|
270
|
-
'<span class="jobs-idle">Idle</span>'
|
|
271
|
-
else
|
|
272
|
-
jobs = @claimed_jobs[process.id] || []
|
|
273
|
-
job_names = jobs.map(&:class_name).uniq.first(3)
|
|
274
|
-
|
|
275
|
-
tooltip = jobs.first(10).map { |j| "#{j.class_name} (ID: #{j.id})" }.join(' ')
|
|
276
|
-
|
|
277
|
-
<<-HTML
|
|
278
|
-
<span class="jobs-processing" title="#{tooltip}">
|
|
279
|
-
#{count} job#{count > 1 ? 's' : ''}
|
|
280
|
-
<span class="job-names">(#{job_names.join(', ')}#{jobs.length > 3 ? '...' : ''})</span>
|
|
281
|
-
</span>
|
|
282
|
-
HTML
|
|
283
|
-
end
|
|
284
|
-
end
|
|
285
|
-
|
|
286
|
-
def preload_claimed_data
|
|
287
|
-
return if @processes.empty?
|
|
288
|
-
|
|
289
|
-
process_ids = @processes.map(&:id)
|
|
290
|
-
|
|
291
|
-
# Preload claimed execution counts
|
|
292
|
-
@claimed_counts = SolidQueue::ClaimedExecution
|
|
293
|
-
.where(process_id: process_ids)
|
|
294
|
-
.group(:process_id)
|
|
295
|
-
.count
|
|
296
|
-
|
|
297
|
-
# Preload claimed jobs for processes that have any
|
|
298
|
-
claimed_executions = SolidQueue::ClaimedExecution
|
|
299
|
-
.includes(:job)
|
|
300
|
-
.where(process_id: process_ids)
|
|
301
|
-
|
|
302
|
-
@claimed_jobs = claimed_executions.each_with_object({}) do |execution, hash|
|
|
303
|
-
hash[execution.process_id] ||= []
|
|
304
|
-
hash[execution.process_id] << execution.job
|
|
305
|
-
end
|
|
306
|
-
end
|
|
307
|
-
|
|
308
|
-
def parse_metadata(process)
|
|
309
|
-
@parsed_metadata ||= {}
|
|
310
|
-
@parsed_metadata[process.id] ||= parse_process_metadata(process)
|
|
311
|
-
end
|
|
312
|
-
|
|
313
|
-
def parse_process_metadata(process)
|
|
314
|
-
return {} unless process.metadata
|
|
315
|
-
|
|
316
|
-
if process.metadata.is_a?(String)
|
|
317
|
-
JSON.parse(process.metadata)
|
|
318
|
-
else
|
|
319
|
-
process.metadata
|
|
320
|
-
end
|
|
321
|
-
rescue JSON::ParserError
|
|
322
|
-
{}
|
|
323
|
-
end
|
|
324
|
-
end
|
|
325
|
-
end
|
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module SolidQueueMonitor
|
|
4
|
-
class ChartPresenter
|
|
5
|
-
CHART_WIDTH = 1200
|
|
6
|
-
CHART_HEIGHT = 280
|
|
7
|
-
PADDING = { top: 40, right: 30, bottom: 60, left: 60 }.freeze
|
|
8
|
-
COLORS = {
|
|
9
|
-
created: '#3b82f6', # Blue
|
|
10
|
-
completed: '#10b981', # Green
|
|
11
|
-
failed: '#ef4444' # Red
|
|
12
|
-
}.freeze
|
|
13
|
-
|
|
14
|
-
def initialize(chart_data)
|
|
15
|
-
@data = chart_data
|
|
16
|
-
@plot_width = CHART_WIDTH - PADDING[:left] - PADDING[:right]
|
|
17
|
-
@plot_height = CHART_HEIGHT - PADDING[:top] - PADDING[:bottom]
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def render
|
|
21
|
-
<<-HTML
|
|
22
|
-
<div class="chart-section" id="chart-section">
|
|
23
|
-
<div class="chart-header">
|
|
24
|
-
<div class="chart-header-left">
|
|
25
|
-
<button class="chart-toggle-btn" id="chart-toggle-btn" title="Toggle chart">
|
|
26
|
-
<svg class="chart-toggle-icon" id="chart-toggle-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
27
|
-
<polyline points="6 9 12 15 18 9"></polyline>
|
|
28
|
-
</svg>
|
|
29
|
-
</button>
|
|
30
|
-
<h3>Job Activity</h3>
|
|
31
|
-
#{render_summary}
|
|
32
|
-
</div>
|
|
33
|
-
#{render_time_select}
|
|
34
|
-
</div>
|
|
35
|
-
<div class="chart-collapsible" id="chart-collapsible">
|
|
36
|
-
<div class="chart-container">
|
|
37
|
-
#{render_svg}
|
|
38
|
-
</div>
|
|
39
|
-
#{render_legend}
|
|
40
|
-
</div>
|
|
41
|
-
</div>
|
|
42
|
-
#{render_tooltip}
|
|
43
|
-
HTML
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
private
|
|
47
|
-
|
|
48
|
-
def render_summary
|
|
49
|
-
totals = @data[:totals] || { created: 0, completed: 0, failed: 0 }
|
|
50
|
-
<<-HTML
|
|
51
|
-
<span class="chart-summary">
|
|
52
|
-
<span class="summary-item summary-created">#{totals[:created]} created</span>
|
|
53
|
-
<span class="summary-separator">·</span>
|
|
54
|
-
<span class="summary-item summary-completed">#{totals[:completed]} completed</span>
|
|
55
|
-
<span class="summary-separator">·</span>
|
|
56
|
-
<span class="summary-item summary-failed">#{totals[:failed]} failed</span>
|
|
57
|
-
</span>
|
|
58
|
-
HTML
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def render_time_select
|
|
62
|
-
options = @data[:available_ranges].map do |key, label|
|
|
63
|
-
selected = key == @data[:time_range] ? 'selected' : ''
|
|
64
|
-
"<option value=\"#{key}\" #{selected}>#{label}</option>"
|
|
65
|
-
end.join
|
|
66
|
-
|
|
67
|
-
<<-HTML
|
|
68
|
-
<div class="chart-time-select-wrapper">
|
|
69
|
-
<select class="chart-time-select" id="chart-time-select">
|
|
70
|
-
#{options}
|
|
71
|
-
</select>
|
|
72
|
-
</div>
|
|
73
|
-
HTML
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def render_svg
|
|
77
|
-
return render_empty_state if all_series_empty?
|
|
78
|
-
|
|
79
|
-
max_value = calculate_max_value
|
|
80
|
-
max_value = 10 if max_value.zero?
|
|
81
|
-
|
|
82
|
-
<<-SVG
|
|
83
|
-
<svg viewBox="0 0 #{CHART_WIDTH} #{CHART_HEIGHT}" class="job-activity-chart" preserveAspectRatio="xMidYMid meet">
|
|
84
|
-
#{render_grid_lines(max_value)}
|
|
85
|
-
#{render_axes}
|
|
86
|
-
#{render_x_labels}
|
|
87
|
-
#{render_y_labels(max_value)}
|
|
88
|
-
#{render_series_line(:failed, max_value)}
|
|
89
|
-
#{render_series_line(:completed, max_value)}
|
|
90
|
-
#{render_series_line(:created, max_value)}
|
|
91
|
-
#{render_series_points(:failed, max_value)}
|
|
92
|
-
#{render_series_points(:completed, max_value)}
|
|
93
|
-
#{render_series_points(:created, max_value)}
|
|
94
|
-
</svg>
|
|
95
|
-
SVG
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def all_series_empty?
|
|
99
|
-
%i[created completed failed].all? { |series| series_empty?(series) }
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def series_empty?(series)
|
|
103
|
-
@data[series].nil? || @data[series].all?(&:zero?)
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def render_empty_state
|
|
107
|
-
<<-HTML
|
|
108
|
-
<div class="chart-empty">
|
|
109
|
-
<span>No job activity in this time range</span>
|
|
110
|
-
</div>
|
|
111
|
-
HTML
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def render_series_line(series, max_value)
|
|
115
|
-
return '' if series_empty?(series)
|
|
116
|
-
|
|
117
|
-
render_line(series, max_value)
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def render_series_points(series, max_value)
|
|
121
|
-
return '' if series_empty?(series)
|
|
122
|
-
|
|
123
|
-
render_data_points(series, max_value)
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def calculate_max_value
|
|
127
|
-
all_values = @data[:created] + @data[:completed] + @data[:failed]
|
|
128
|
-
max = all_values.max || 0
|
|
129
|
-
# Round up to nice number
|
|
130
|
-
return 10 if max <= 10
|
|
131
|
-
|
|
132
|
-
magnitude = 10**Math.log10(max).floor
|
|
133
|
-
((max.to_f / magnitude).ceil * magnitude)
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def render_grid_lines(_max_value)
|
|
137
|
-
lines = []
|
|
138
|
-
5.times do |i|
|
|
139
|
-
y = PADDING[:top] + (@plot_height * i / 4.0)
|
|
140
|
-
lines << "<line x1=\"#{PADDING[:left]}\" y1=\"#{y}\" x2=\"#{CHART_WIDTH - PADDING[:right]}\" y2=\"#{y}\" class=\"grid-line\"/>"
|
|
141
|
-
end
|
|
142
|
-
lines.join("\n")
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
def render_axes
|
|
146
|
-
<<-SVG
|
|
147
|
-
<line x1="#{PADDING[:left]}" y1="#{PADDING[:top]}" x2="#{PADDING[:left]}" y2="#{CHART_HEIGHT - PADDING[:bottom]}" class="axis-line"/>
|
|
148
|
-
<line x1="#{PADDING[:left]}" y1="#{CHART_HEIGHT - PADDING[:bottom]}" x2="#{CHART_WIDTH - PADDING[:right]}" y2="#{CHART_HEIGHT - PADDING[:bottom]}" class="axis-line"/>
|
|
149
|
-
SVG
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def render_x_labels
|
|
153
|
-
labels = @data[:labels]
|
|
154
|
-
return '' if labels.empty?
|
|
155
|
-
|
|
156
|
-
# Show fewer labels if too many
|
|
157
|
-
step = labels.size > 12 ? (labels.size / 6.0).ceil : 1
|
|
158
|
-
|
|
159
|
-
label_elements = labels.each_with_index.map do |label, i|
|
|
160
|
-
next unless (i % step).zero? || i == labels.size - 1
|
|
161
|
-
|
|
162
|
-
x = PADDING[:left] + (@plot_width * i / (labels.size - 1).to_f)
|
|
163
|
-
"<text x=\"#{x}\" y=\"#{CHART_HEIGHT - PADDING[:bottom] + 20}\" class=\"axis-label x-label\">#{label}</text>"
|
|
164
|
-
end.compact
|
|
165
|
-
|
|
166
|
-
label_elements.join("\n")
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
def render_y_labels(max_value)
|
|
170
|
-
labels = []
|
|
171
|
-
5.times do |i|
|
|
172
|
-
value = (max_value * (4 - i) / 4.0).round
|
|
173
|
-
y = PADDING[:top] + (@plot_height * i / 4.0)
|
|
174
|
-
labels << "<text x=\"#{PADDING[:left] - 10}\" y=\"#{y + 4}\" class=\"axis-label y-label\">#{value}</text>"
|
|
175
|
-
end
|
|
176
|
-
labels.join("\n")
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
def render_line(series, max_value)
|
|
180
|
-
points = calculate_points(series, max_value)
|
|
181
|
-
return '' if points.empty?
|
|
182
|
-
|
|
183
|
-
points_str = points.map { |p| "#{p[:x]},#{p[:y]}" }.join(' ')
|
|
184
|
-
|
|
185
|
-
"<polyline points=\"#{points_str}\" class=\"chart-line chart-line-#{series}\" fill=\"none\" stroke=\"#{COLORS[series]}\" stroke-width=\"2\"/>"
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
def render_data_points(series, max_value)
|
|
189
|
-
points = calculate_points(series, max_value)
|
|
190
|
-
values = @data[series]
|
|
191
|
-
|
|
192
|
-
points.each_with_index.map do |point, i|
|
|
193
|
-
<<-SVG
|
|
194
|
-
<circle cx="#{point[:x]}" cy="#{point[:y]}" r="4" class="data-point data-point-#{series}" fill="#{COLORS[series]}"
|
|
195
|
-
data-series="#{series}" data-label="#{@data[:labels][i]}" data-value="#{values[i]}"/>
|
|
196
|
-
SVG
|
|
197
|
-
end.join("\n")
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
def calculate_points(series, max_value)
|
|
201
|
-
values = @data[series]
|
|
202
|
-
return [] if values.blank?
|
|
203
|
-
|
|
204
|
-
values.each_with_index.map do |value, i|
|
|
205
|
-
x = PADDING[:left] + (@plot_width * i / (values.size - 1).to_f)
|
|
206
|
-
y = CHART_HEIGHT - PADDING[:bottom] - (@plot_height * value / max_value.to_f)
|
|
207
|
-
{ x: x.round(2), y: y.round(2) }
|
|
208
|
-
end
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
def render_legend
|
|
212
|
-
<<-HTML
|
|
213
|
-
<div class="chart-legend">
|
|
214
|
-
<span class="legend-item">
|
|
215
|
-
<span class="legend-color legend-color-created"></span>
|
|
216
|
-
Created
|
|
217
|
-
</span>
|
|
218
|
-
<span class="legend-item">
|
|
219
|
-
<span class="legend-color legend-color-completed"></span>
|
|
220
|
-
Completed
|
|
221
|
-
</span>
|
|
222
|
-
<span class="legend-item">
|
|
223
|
-
<span class="legend-color legend-color-failed"></span>
|
|
224
|
-
Failed
|
|
225
|
-
</span>
|
|
226
|
-
</div>
|
|
227
|
-
HTML
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
def render_tooltip
|
|
231
|
-
<<-HTML
|
|
232
|
-
<div id="chart-tooltip" class="chart-tooltip">
|
|
233
|
-
<div class="tooltip-label"></div>
|
|
234
|
-
<div class="tooltip-value"></div>
|
|
235
|
-
</div>
|
|
236
|
-
HTML
|
|
237
|
-
end
|
|
238
|
-
end
|
|
239
|
-
end
|