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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +66 -4
  3. data/app/assets/javascripts/solid_queue_monitor/application.js +393 -0
  4. data/app/{services/solid_queue_monitor/stylesheet_generator.rb → assets/stylesheets/solid_queue_monitor/application.css} +23 -12
  5. data/app/controllers/solid_queue_monitor/application_controller.rb +9 -3
  6. data/app/controllers/solid_queue_monitor/assets_controller.rb +52 -0
  7. data/app/controllers/solid_queue_monitor/base_controller.rb +0 -29
  8. data/app/controllers/solid_queue_monitor/failed_jobs_controller.rb +3 -7
  9. data/app/controllers/solid_queue_monitor/in_progress_jobs_controller.rb +3 -6
  10. data/app/controllers/solid_queue_monitor/jobs_controller.rb +3 -7
  11. data/app/controllers/solid_queue_monitor/overview_controller.rb +3 -12
  12. data/app/controllers/solid_queue_monitor/queues_controller.rb +4 -18
  13. data/app/controllers/solid_queue_monitor/ready_jobs_controller.rb +3 -6
  14. data/app/controllers/solid_queue_monitor/recurring_jobs_controller.rb +3 -6
  15. data/app/controllers/solid_queue_monitor/scheduled_jobs_controller.rb +3 -7
  16. data/app/controllers/solid_queue_monitor/search_controller.rb +3 -4
  17. data/app/controllers/solid_queue_monitor/workers_controller.rb +24 -8
  18. data/app/helpers/solid_queue_monitor/application_helper.rb +46 -0
  19. data/app/helpers/solid_queue_monitor/chart_helper.rb +293 -0
  20. data/app/helpers/solid_queue_monitor/job_details_helper.rb +66 -0
  21. data/app/helpers/solid_queue_monitor/jobs_helper.rb +134 -0
  22. data/app/helpers/solid_queue_monitor/pagination_helper.rb +23 -0
  23. data/app/helpers/solid_queue_monitor/sort_helper.rb +30 -0
  24. data/app/helpers/solid_queue_monitor/workers_helper.rb +88 -0
  25. data/app/services/solid_queue_monitor/asset_cache.rb +56 -0
  26. data/app/views/layouts/solid_queue_monitor/application.html.erb +25 -0
  27. data/app/views/solid_queue_monitor/failed_jobs/_row.html.erb +26 -0
  28. data/app/views/solid_queue_monitor/failed_jobs/index.html.erb +38 -0
  29. data/app/views/solid_queue_monitor/in_progress_jobs/_row.html.erb +13 -0
  30. data/app/views/solid_queue_monitor/in_progress_jobs/index.html.erb +25 -0
  31. data/app/views/solid_queue_monitor/jobs/_arguments.html.erb +9 -0
  32. data/app/views/solid_queue_monitor/jobs/_error.html.erb +26 -0
  33. data/app/views/solid_queue_monitor/jobs/_execution_history.html.erb +25 -0
  34. data/app/views/solid_queue_monitor/jobs/_header.html.erb +37 -0
  35. data/app/views/solid_queue_monitor/jobs/_metadata.html.erb +22 -0
  36. data/app/views/solid_queue_monitor/jobs/_raw_data.html.erb +11 -0
  37. data/app/views/solid_queue_monitor/jobs/_timeline.html.erb +29 -0
  38. data/app/views/solid_queue_monitor/jobs/_timing.html.erb +9 -0
  39. data/app/views/solid_queue_monitor/jobs/_worker.html.erb +12 -0
  40. data/app/views/solid_queue_monitor/jobs/show.html.erb +22 -0
  41. data/app/views/solid_queue_monitor/overview/_chart.html.erb +1 -0
  42. data/app/views/solid_queue_monitor/overview/_recent_job_row.html.erb +26 -0
  43. data/app/views/solid_queue_monitor/overview/_recent_jobs.html.erb +31 -0
  44. data/app/views/solid_queue_monitor/overview/_stat_card.html.erb +4 -0
  45. data/app/views/solid_queue_monitor/overview/_stats.html.erb +11 -0
  46. data/app/views/solid_queue_monitor/overview/index.html.erb +9 -0
  47. data/app/views/solid_queue_monitor/queues/_job_row.html.erb +26 -0
  48. data/app/views/solid_queue_monitor/queues/_row.html.erb +33 -0
  49. data/app/views/solid_queue_monitor/queues/index.html.erb +18 -0
  50. data/app/views/solid_queue_monitor/queues/show.html.erb +63 -0
  51. data/app/views/solid_queue_monitor/ready_jobs/_row.html.erb +7 -0
  52. data/app/views/solid_queue_monitor/ready_jobs/index.html.erb +25 -0
  53. data/app/views/solid_queue_monitor/recurring_jobs/_row.html.erb +8 -0
  54. data/app/views/solid_queue_monitor/recurring_jobs/index.html.erb +26 -0
  55. data/app/views/solid_queue_monitor/scheduled_jobs/_row.html.erb +7 -0
  56. data/app/views/solid_queue_monitor/scheduled_jobs/index.html.erb +36 -0
  57. data/app/views/solid_queue_monitor/search/_completed_row.html.erb +6 -0
  58. data/app/views/solid_queue_monitor/search/_failed_row.html.erb +6 -0
  59. data/app/views/solid_queue_monitor/search/_job_row.html.erb +9 -0
  60. data/app/views/solid_queue_monitor/search/_recurring_row.html.erb +6 -0
  61. data/app/views/solid_queue_monitor/search/_section.html.erb +25 -0
  62. data/app/views/solid_queue_monitor/search/index.html.erb +23 -0
  63. data/app/views/solid_queue_monitor/shared/_filters.html.erb +48 -0
  64. data/app/views/solid_queue_monitor/shared/_flash.html.erb +17 -0
  65. data/app/views/solid_queue_monitor/shared/_footer.html.erb +3 -0
  66. data/app/views/solid_queue_monitor/shared/_header.html.erb +81 -0
  67. data/app/views/solid_queue_monitor/shared/_jobs_table.html.erb +20 -0
  68. data/app/views/solid_queue_monitor/shared/_pagination.html.erb +25 -0
  69. data/app/views/solid_queue_monitor/workers/_row.html.erb +22 -0
  70. data/app/views/solid_queue_monitor/workers/index.html.erb +82 -0
  71. data/config/routes.rb +6 -1
  72. data/lib/solid_queue_monitor/engine.rb +2 -0
  73. data/lib/solid_queue_monitor/version.rb +1 -1
  74. data/lib/solid_queue_monitor.rb +8 -1
  75. metadata +57 -17
  76. data/app/presenters/solid_queue_monitor/base_presenter.rb +0 -211
  77. data/app/presenters/solid_queue_monitor/failed_jobs_presenter.rb +0 -225
  78. data/app/presenters/solid_queue_monitor/in_progress_jobs_presenter.rb +0 -84
  79. data/app/presenters/solid_queue_monitor/job_details_presenter.rb +0 -707
  80. data/app/presenters/solid_queue_monitor/jobs_presenter.rb +0 -144
  81. data/app/presenters/solid_queue_monitor/queue_details_presenter.rb +0 -195
  82. data/app/presenters/solid_queue_monitor/queues_presenter.rb +0 -89
  83. data/app/presenters/solid_queue_monitor/ready_jobs_presenter.rb +0 -81
  84. data/app/presenters/solid_queue_monitor/recurring_jobs_presenter.rb +0 -81
  85. data/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb +0 -178
  86. data/app/presenters/solid_queue_monitor/search_results_presenter.rb +0 -190
  87. data/app/presenters/solid_queue_monitor/stats_presenter.rb +0 -36
  88. data/app/presenters/solid_queue_monitor/workers_presenter.rb +0 -325
  89. data/app/services/solid_queue_monitor/chart_presenter.rb +0 -239
  90. 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('&#10;')
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