solid_queue_monitor 1.2.2 → 2.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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +36 -1
  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} +80 -12
  5. data/app/controllers/solid_queue_monitor/application_controller.rb +2 -2
  6. data/app/controllers/solid_queue_monitor/assets_controller.rb +52 -0
  7. data/app/controllers/solid_queue_monitor/base_controller.rb +0 -28
  8. data/app/controllers/solid_queue_monitor/failed_jobs_controller.rb +3 -6
  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 -6
  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 -6
  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/services/solid_queue_monitor/chart_data_service.rb +2 -2
  27. data/app/views/layouts/solid_queue_monitor/application.html.erb +25 -0
  28. data/app/views/solid_queue_monitor/failed_jobs/_row.html.erb +26 -0
  29. data/app/views/solid_queue_monitor/failed_jobs/index.html.erb +38 -0
  30. data/app/views/solid_queue_monitor/in_progress_jobs/_row.html.erb +13 -0
  31. data/app/views/solid_queue_monitor/in_progress_jobs/index.html.erb +25 -0
  32. data/app/views/solid_queue_monitor/jobs/_arguments.html.erb +9 -0
  33. data/app/views/solid_queue_monitor/jobs/_error.html.erb +26 -0
  34. data/app/views/solid_queue_monitor/jobs/_execution_history.html.erb +25 -0
  35. data/app/views/solid_queue_monitor/jobs/_header.html.erb +37 -0
  36. data/app/views/solid_queue_monitor/jobs/_metadata.html.erb +22 -0
  37. data/app/views/solid_queue_monitor/jobs/_raw_data.html.erb +11 -0
  38. data/app/views/solid_queue_monitor/jobs/_timeline.html.erb +29 -0
  39. data/app/views/solid_queue_monitor/jobs/_timing.html.erb +9 -0
  40. data/app/views/solid_queue_monitor/jobs/_worker.html.erb +12 -0
  41. data/app/views/solid_queue_monitor/jobs/show.html.erb +22 -0
  42. data/app/views/solid_queue_monitor/overview/_chart.html.erb +1 -0
  43. data/app/views/solid_queue_monitor/overview/_recent_job_row.html.erb +26 -0
  44. data/app/views/solid_queue_monitor/overview/_recent_jobs.html.erb +31 -0
  45. data/app/views/solid_queue_monitor/overview/_stat_card.html.erb +4 -0
  46. data/app/views/solid_queue_monitor/overview/_stats.html.erb +11 -0
  47. data/app/views/solid_queue_monitor/overview/index.html.erb +9 -0
  48. data/app/views/solid_queue_monitor/queues/_job_row.html.erb +26 -0
  49. data/app/views/solid_queue_monitor/queues/_row.html.erb +33 -0
  50. data/app/views/solid_queue_monitor/queues/index.html.erb +18 -0
  51. data/app/views/solid_queue_monitor/queues/show.html.erb +63 -0
  52. data/app/views/solid_queue_monitor/ready_jobs/_row.html.erb +7 -0
  53. data/app/views/solid_queue_monitor/ready_jobs/index.html.erb +25 -0
  54. data/app/views/solid_queue_monitor/recurring_jobs/_row.html.erb +8 -0
  55. data/app/views/solid_queue_monitor/recurring_jobs/index.html.erb +26 -0
  56. data/app/views/solid_queue_monitor/scheduled_jobs/_row.html.erb +7 -0
  57. data/app/views/solid_queue_monitor/scheduled_jobs/index.html.erb +36 -0
  58. data/app/views/solid_queue_monitor/search/_completed_row.html.erb +6 -0
  59. data/app/views/solid_queue_monitor/search/_failed_row.html.erb +6 -0
  60. data/app/views/solid_queue_monitor/search/_job_row.html.erb +9 -0
  61. data/app/views/solid_queue_monitor/search/_recurring_row.html.erb +6 -0
  62. data/app/views/solid_queue_monitor/search/_section.html.erb +25 -0
  63. data/app/views/solid_queue_monitor/search/index.html.erb +23 -0
  64. data/app/views/solid_queue_monitor/shared/_filters.html.erb +48 -0
  65. data/app/views/solid_queue_monitor/shared/_flash.html.erb +17 -0
  66. data/app/views/solid_queue_monitor/shared/_footer.html.erb +3 -0
  67. data/app/views/solid_queue_monitor/shared/_header.html.erb +81 -0
  68. data/app/views/solid_queue_monitor/shared/_jobs_table.html.erb +20 -0
  69. data/app/views/solid_queue_monitor/shared/_pagination.html.erb +25 -0
  70. data/app/views/solid_queue_monitor/workers/_row.html.erb +22 -0
  71. data/app/views/solid_queue_monitor/workers/index.html.erb +82 -0
  72. data/config/routes.rb +6 -1
  73. data/lib/solid_queue_monitor/engine.rb +2 -0
  74. data/lib/solid_queue_monitor/version.rb +1 -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 -312
  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 -696
  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 -173
  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 -320
  89. data/app/services/solid_queue_monitor/chart_presenter.rb +0 -239
  90. data/app/services/solid_queue_monitor/html_generator.rb +0 -401
@@ -1,320 +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
- <<-HTML
108
- <a href="#" class="summary-action"
109
- 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;">
110
- Prune all
111
- </a>
112
- <form id="prune-all-form" action="#{prune_workers_path}" method="post" style="display: none;"></form>
113
- HTML
114
- end
115
-
116
- def all_processes_for_summary
117
- @all_processes_for_summary ||= SolidQueue::Process.all.to_a
118
- end
119
-
120
- def generate_table_or_empty
121
- if @processes.empty?
122
- generate_empty_state
123
- else
124
- generate_table
125
- end
126
- end
127
-
128
- def generate_empty_state
129
- <<-HTML
130
- <div class="empty-state">
131
- <p>No worker processes found.</p>
132
- <p class="empty-state-hint">Workers will appear here when Solid Queue processes are running.</p>
133
- </div>
134
- HTML
135
- end
136
-
137
- def generate_table
138
- <<-HTML
139
- <div class="table-container">
140
- <table>
141
- <thead>
142
- <tr>
143
- <th>Kind</th>
144
- #{sortable_header('hostname', 'Hostname')}
145
- <th>PID</th>
146
- <th>Queues</th>
147
- #{sortable_header('last_heartbeat_at', 'Last Heartbeat')}
148
- <th>Status</th>
149
- <th>Jobs Processing</th>
150
- <th>Actions</th>
151
- </tr>
152
- </thead>
153
- <tbody>
154
- #{@processes.map { |process| generate_row(process) }.join}
155
- </tbody>
156
- </table>
157
- </div>
158
- HTML
159
- end
160
-
161
- def generate_row(process)
162
- status = worker_status(process)
163
- row_class = case status
164
- when :dead then 'worker-dead'
165
- when :stale then 'worker-stale'
166
- else ''
167
- end
168
-
169
- <<-HTML
170
- <tr class="#{row_class}">
171
- <td>#{kind_badge(process.kind)}</td>
172
- <td>#{hostname(process)}</td>
173
- <td><code>#{process.pid}</code></td>
174
- <td>#{queues_display(process)}</td>
175
- <td>#{format_heartbeat(process.last_heartbeat_at)}</td>
176
- <td>#{status_badge(status)}</td>
177
- <td>#{jobs_processing(process)}</td>
178
- <td class="actions-cell">#{action_button(process, status)}</td>
179
- </tr>
180
- HTML
181
- end
182
-
183
- def action_button(process, status)
184
- return '<span class="action-placeholder">-</span>' unless status == :dead
185
-
186
- <<-HTML
187
- <form action="#{remove_worker_path(id: process.id)}" method="post" class="inline-form"
188
- onsubmit="return confirm('Remove this dead process from the registry?');">
189
- <button type="submit" class="action-button discard-button" title="Remove dead process">
190
- Remove
191
- </button>
192
- </form>
193
- HTML
194
- end
195
-
196
- def kind_badge(kind)
197
- badge_class = case kind
198
- when 'Worker' then 'kind-worker'
199
- when 'Dispatcher' then 'kind-dispatcher'
200
- when 'Scheduler' then 'kind-scheduler'
201
- else 'kind-other'
202
- end
203
- "<span class=\"kind-badge #{badge_class}\">#{kind}</span>"
204
- end
205
-
206
- def hostname(process)
207
- process.hostname || parse_metadata(process)['hostname'] || '-'
208
- end
209
-
210
- def queues_display(process)
211
- metadata = parse_metadata(process)
212
- queues = metadata['queues']
213
-
214
- return '-' if queues.nil?
215
-
216
- # Handle string queues (e.g., "*" for all queues)
217
- if queues.is_a?(String)
218
- return "<code class=\"queue-tag\">#{queues == '*' ? 'All Queues' : queues}</code>"
219
- end
220
-
221
- return '-' if queues.empty?
222
-
223
- if queues.length <= 3
224
- queues.map { |q| "<code class=\"queue-tag\">#{q}</code>" }.join(' ')
225
- else
226
- visible = queues.first(2).map { |q| "<code class=\"queue-tag\">#{q}</code>" }.join(' ')
227
- "#{visible} <span class=\"queue-more\">+#{queues.length - 2} more</span>"
228
- end
229
- end
230
-
231
- def format_heartbeat(heartbeat_at)
232
- return '-' unless heartbeat_at
233
-
234
- time_ago = time_ago_in_words(heartbeat_at)
235
- "<span title=\"#{heartbeat_at.strftime('%Y-%m-%d %H:%M:%S')}\">#{time_ago} ago</span>"
236
- end
237
-
238
- def worker_status(process)
239
- return :dead unless process.last_heartbeat_at
240
-
241
- time_since_heartbeat = Time.current - process.last_heartbeat_at
242
-
243
- if time_since_heartbeat > HEARTBEAT_DEAD_THRESHOLD
244
- :dead
245
- elsif time_since_heartbeat > HEARTBEAT_STALE_THRESHOLD
246
- :stale
247
- else
248
- :healthy
249
- end
250
- end
251
-
252
- def status_badge(status)
253
- badges = {
254
- healthy: '<span class="status-badge status-healthy">Healthy</span>',
255
- stale: '<span class="status-badge status-stale">Stale</span>',
256
- dead: '<span class="status-badge status-dead">Dead</span>'
257
- }
258
- badges[status]
259
- end
260
-
261
- def jobs_processing(process)
262
- count = @claimed_counts[process.id] || 0
263
-
264
- if count.zero?
265
- '<span class="jobs-idle">Idle</span>'
266
- else
267
- jobs = @claimed_jobs[process.id] || []
268
- job_names = jobs.map(&:class_name).uniq.first(3)
269
-
270
- tooltip = jobs.first(10).map { |j| "#{j.class_name} (ID: #{j.id})" }.join('&#10;')
271
-
272
- <<-HTML
273
- <span class="jobs-processing" title="#{tooltip}">
274
- #{count} job#{count > 1 ? 's' : ''}
275
- <span class="job-names">(#{job_names.join(', ')}#{jobs.length > 3 ? '...' : ''})</span>
276
- </span>
277
- HTML
278
- end
279
- end
280
-
281
- def preload_claimed_data
282
- return if @processes.empty?
283
-
284
- process_ids = @processes.map(&:id)
285
-
286
- # Preload claimed execution counts
287
- @claimed_counts = SolidQueue::ClaimedExecution
288
- .where(process_id: process_ids)
289
- .group(:process_id)
290
- .count
291
-
292
- # Preload claimed jobs for processes that have any
293
- claimed_executions = SolidQueue::ClaimedExecution
294
- .includes(:job)
295
- .where(process_id: process_ids)
296
-
297
- @claimed_jobs = claimed_executions.each_with_object({}) do |execution, hash|
298
- hash[execution.process_id] ||= []
299
- hash[execution.process_id] << execution.job
300
- end
301
- end
302
-
303
- def parse_metadata(process)
304
- @parsed_metadata ||= {}
305
- @parsed_metadata[process.id] ||= parse_process_metadata(process)
306
- end
307
-
308
- def parse_process_metadata(process)
309
- return {} unless process.metadata
310
-
311
- if process.metadata.is_a?(String)
312
- JSON.parse(process.metadata)
313
- else
314
- process.metadata
315
- end
316
- rescue JSON::ParserError
317
- {}
318
- end
319
- end
320
- 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" onchange="window.location.href='?time_range=' + this.value">
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" style="background-color: #{COLORS[:created]}"></span>
216
- Created
217
- </span>
218
- <span class="legend-item">
219
- <span class="legend-color" style="background-color: #{COLORS[:completed]}"></span>
220
- Completed
221
- </span>
222
- <span class="legend-item">
223
- <span class="legend-color" style="background-color: #{COLORS[: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" style="display: none;">
233
- <div class="tooltip-label"></div>
234
- <div class="tooltip-value"></div>
235
- </div>
236
- HTML
237
- end
238
- end
239
- end