solid_queue_monitor 0.5.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.
Files changed (25) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +37 -8
  3. data/app/controllers/solid_queue_monitor/jobs_controller.rb +72 -0
  4. data/app/controllers/solid_queue_monitor/overview_controller.rb +11 -0
  5. data/app/controllers/solid_queue_monitor/queues_controller.rb +73 -2
  6. data/app/controllers/solid_queue_monitor/scheduled_jobs_controller.rb +9 -0
  7. data/app/controllers/solid_queue_monitor/workers_controller.rb +74 -0
  8. data/app/presenters/solid_queue_monitor/base_presenter.rb +7 -0
  9. data/app/presenters/solid_queue_monitor/failed_jobs_presenter.rb +3 -7
  10. data/app/presenters/solid_queue_monitor/in_progress_jobs_presenter.rb +2 -1
  11. data/app/presenters/solid_queue_monitor/job_details_presenter.rb +696 -0
  12. data/app/presenters/solid_queue_monitor/jobs_presenter.rb +3 -3
  13. data/app/presenters/solid_queue_monitor/queue_details_presenter.rb +194 -0
  14. data/app/presenters/solid_queue_monitor/queues_presenter.rb +1 -1
  15. data/app/presenters/solid_queue_monitor/ready_jobs_presenter.rb +2 -2
  16. data/app/presenters/solid_queue_monitor/recurring_jobs_presenter.rb +1 -1
  17. data/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb +2 -2
  18. data/app/presenters/solid_queue_monitor/workers_presenter.rb +319 -0
  19. data/app/services/solid_queue_monitor/chart_data_service.rb +100 -0
  20. data/app/services/solid_queue_monitor/chart_presenter.rb +239 -0
  21. data/app/services/solid_queue_monitor/html_generator.rb +169 -8
  22. data/app/services/solid_queue_monitor/stylesheet_generator.rb +650 -33
  23. data/config/routes.rb +9 -0
  24. data/lib/solid_queue_monitor/version.rb +1 -1
  25. metadata +8 -1
@@ -0,0 +1,239 @@
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
@@ -51,6 +51,7 @@ module SolidQueueMonitor
51
51
  #{generate_footer}
52
52
  </div>
53
53
  #{generate_auto_refresh_script}
54
+ #{generate_chart_script}
54
55
  HTML
55
56
  end
56
57
 
@@ -87,20 +88,33 @@ module SolidQueueMonitor
87
88
  end
88
89
 
89
90
  def generate_header
91
+ nav_items = [
92
+ { path: root_path, label: 'Overview', match: 'Overview' },
93
+ { path: ready_jobs_path, label: 'Ready Jobs', match: 'Ready Jobs' },
94
+ { path: in_progress_jobs_path, label: 'In Progress Jobs', match: 'In Progress' },
95
+ { path: scheduled_jobs_path, label: 'Scheduled Jobs', match: 'Scheduled Jobs' },
96
+ { path: recurring_jobs_path, label: 'Recurring Jobs', match: 'Recurring Jobs' },
97
+ { path: failed_jobs_path, label: 'Failed Jobs', match: 'Failed Jobs' },
98
+ { path: queues_path, label: 'Queues', match: 'Queues' },
99
+ { path: workers_path, label: 'Workers', match: 'Workers' }
100
+ ]
101
+
102
+ nav_links = nav_items.map do |item|
103
+ active_class = @title&.include?(item[:match]) ? 'active' : ''
104
+ "<a href=\"#{item[:path]}\" class=\"nav-link #{active_class}\">#{item[:label]}</a>"
105
+ end.join("\n ")
106
+
90
107
  <<-HTML
91
108
  <header>
92
109
  <div class="header-top">
93
110
  <h1>Solid Queue Monitor</h1>
94
- #{generate_auto_refresh_controls}
111
+ <div class="header-controls">
112
+ #{generate_auto_refresh_controls}
113
+ #{generate_theme_toggle}
114
+ </div>
95
115
  </div>
96
116
  <nav class="navigation">
97
- <a href="#{root_path}" class="nav-link">Overview</a>
98
- <a href="#{ready_jobs_path}" class="nav-link">Ready Jobs</a>
99
- <a href="#{in_progress_jobs_path}" class="nav-link">In Progress Jobs</a>
100
- <a href="#{scheduled_jobs_path}" class="nav-link">Scheduled Jobs</a>
101
- <a href="#{recurring_jobs_path}" class="nav-link">Recurring Jobs</a>
102
- <a href="#{failed_jobs_path}" class="nav-link">Failed Jobs</a>
103
- <a href="#{queues_path}" class="nav-link">Queues</a>
117
+ #{nav_links}
104
118
  </nav>
105
119
  </header>
106
120
  HTML
@@ -135,6 +149,27 @@ module SolidQueueMonitor
135
149
  HTML
136
150
  end
137
151
 
152
+ def generate_theme_toggle
153
+ <<-HTML
154
+ <button class="theme-toggle-btn" id="theme-toggle-btn" title="Toggle dark mode">
155
+ <svg class="theme-icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
156
+ <circle cx="12" cy="12" r="5"></circle>
157
+ <line x1="12" y1="1" x2="12" y2="3"></line>
158
+ <line x1="12" y1="21" x2="12" y2="23"></line>
159
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
160
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
161
+ <line x1="1" y1="12" x2="3" y2="12"></line>
162
+ <line x1="21" y1="12" x2="23" y2="12"></line>
163
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
164
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
165
+ </svg>
166
+ <svg class="theme-icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
167
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
168
+ </svg>
169
+ </button>
170
+ HTML
171
+ end
172
+
138
173
  def generate_auto_refresh_script
139
174
  return '' unless SolidQueueMonitor.auto_refresh_enabled
140
175
 
@@ -212,6 +247,132 @@ module SolidQueueMonitor
212
247
  JS
213
248
  end
214
249
 
250
+ def generate_chart_script
251
+ <<-HTML
252
+ <script>
253
+ #{theme_toggle_javascript}
254
+ #{chart_tooltip_javascript}
255
+ </script>
256
+ HTML
257
+ end
258
+
259
+ def theme_toggle_javascript
260
+ <<-JS
261
+ (function() {
262
+ var body = document.body;
263
+ var themeBtn = document.getElementById('theme-toggle-btn');
264
+ var storageKey = 'sqm_dark_theme';
265
+
266
+ // Check for saved preference or system preference
267
+ function getPreferredTheme() {
268
+ var saved = localStorage.getItem(storageKey);
269
+ if (saved !== null) {
270
+ return saved === 'true';
271
+ }
272
+ // Check system preference
273
+ return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
274
+ }
275
+
276
+ function setTheme(isDark) {
277
+ if (isDark) {
278
+ body.classList.add('dark-theme');
279
+ } else {
280
+ body.classList.remove('dark-theme');
281
+ }
282
+ localStorage.setItem(storageKey, isDark ? 'true' : 'false');
283
+ }
284
+
285
+ // Initialize theme
286
+ setTheme(getPreferredTheme());
287
+
288
+ // Toggle on button click
289
+ if (themeBtn) {
290
+ themeBtn.addEventListener('click', function() {
291
+ var isDark = body.classList.contains('dark-theme');
292
+ setTheme(!isDark);
293
+ });
294
+ }
295
+
296
+ // Listen for system preference changes
297
+ if (window.matchMedia) {
298
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
299
+ // Only auto-switch if user hasn't manually set a preference
300
+ if (localStorage.getItem(storageKey) === null) {
301
+ setTheme(e.matches);
302
+ }
303
+ });
304
+ }
305
+ })();
306
+ JS
307
+ end
308
+
309
+ def chart_tooltip_javascript
310
+ <<-JS
311
+ (function() {
312
+ // Chart collapse/expand functionality
313
+ var chartSection = document.getElementById('chart-section');
314
+ var toggleBtn = document.getElementById('chart-toggle-btn');
315
+
316
+ if (chartSection && toggleBtn) {
317
+ var isCollapsed = localStorage.getItem('sqm_chart_collapsed') === 'true';
318
+
319
+ if (isCollapsed) {
320
+ chartSection.classList.add('collapsed');
321
+ }
322
+
323
+ toggleBtn.addEventListener('click', function() {
324
+ chartSection.classList.toggle('collapsed');
325
+ var collapsed = chartSection.classList.contains('collapsed');
326
+ localStorage.setItem('sqm_chart_collapsed', collapsed ? 'true' : 'false');
327
+ });
328
+ }
329
+
330
+ // Chart tooltip functionality
331
+ var tooltip = document.getElementById('chart-tooltip');
332
+ if (!tooltip) return;
333
+
334
+ var dataPoints = document.querySelectorAll('.data-point');
335
+ var seriesNames = { created: 'Created', completed: 'Completed', failed: 'Failed' };
336
+
337
+ dataPoints.forEach(function(point) {
338
+ point.addEventListener('mouseenter', function(e) {
339
+ var series = this.getAttribute('data-series');
340
+ var label = this.getAttribute('data-label');
341
+ var value = this.getAttribute('data-value');
342
+
343
+ tooltip.querySelector('.tooltip-label').textContent = label;
344
+ tooltip.querySelector('.tooltip-value').textContent = seriesNames[series] + ': ' + value;
345
+ tooltip.style.display = 'block';
346
+ positionTooltip(e);
347
+ });
348
+
349
+ point.addEventListener('mousemove', function(e) {
350
+ positionTooltip(e);
351
+ });
352
+
353
+ point.addEventListener('mouseleave', function() {
354
+ tooltip.style.display = 'none';
355
+ });
356
+ });
357
+
358
+ function positionTooltip(e) {
359
+ var x = e.clientX + 10;
360
+ var y = e.clientY - 30;
361
+
362
+ if (x + tooltip.offsetWidth > window.innerWidth) {
363
+ x = e.clientX - tooltip.offsetWidth - 10;
364
+ }
365
+ if (y < 0) {
366
+ y = e.clientY + 10;
367
+ }
368
+
369
+ tooltip.style.left = x + 'px';
370
+ tooltip.style.top = y + 'px';
371
+ }
372
+ })();
373
+ JS
374
+ end
375
+
215
376
  def default_url_options
216
377
  { only_path: true }
217
378
  end