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.
- checksums.yaml +4 -4
- data/README.md +37 -8
- data/app/controllers/solid_queue_monitor/jobs_controller.rb +72 -0
- data/app/controllers/solid_queue_monitor/overview_controller.rb +11 -0
- data/app/controllers/solid_queue_monitor/queues_controller.rb +73 -2
- data/app/controllers/solid_queue_monitor/scheduled_jobs_controller.rb +9 -0
- data/app/controllers/solid_queue_monitor/workers_controller.rb +74 -0
- data/app/presenters/solid_queue_monitor/base_presenter.rb +7 -0
- data/app/presenters/solid_queue_monitor/failed_jobs_presenter.rb +3 -7
- data/app/presenters/solid_queue_monitor/in_progress_jobs_presenter.rb +2 -1
- data/app/presenters/solid_queue_monitor/job_details_presenter.rb +696 -0
- data/app/presenters/solid_queue_monitor/jobs_presenter.rb +3 -3
- data/app/presenters/solid_queue_monitor/queue_details_presenter.rb +194 -0
- data/app/presenters/solid_queue_monitor/queues_presenter.rb +1 -1
- data/app/presenters/solid_queue_monitor/ready_jobs_presenter.rb +2 -2
- data/app/presenters/solid_queue_monitor/recurring_jobs_presenter.rb +1 -1
- data/app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb +2 -2
- data/app/presenters/solid_queue_monitor/workers_presenter.rb +319 -0
- data/app/services/solid_queue_monitor/chart_data_service.rb +100 -0
- data/app/services/solid_queue_monitor/chart_presenter.rb +239 -0
- data/app/services/solid_queue_monitor/html_generator.rb +169 -8
- data/app/services/solid_queue_monitor/stylesheet_generator.rb +650 -33
- data/config/routes.rb +9 -0
- data/lib/solid_queue_monitor/version.rb +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|