solid_queue_monitor 0.4.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3bed1180e7bb59bb8741c6fd8607cfcec9e9b604143f4f96c3d4e6f91ae045c6
4
- data.tar.gz: 5fe88544f47d2146ec5e8034aa476032bba8fbfae2fbb7bf5aa283b66650dfb8
3
+ metadata.gz: 385e4d7ff7dc636b9448cefef256495f8c7aefd2e3318c57a6637c69d4cb5c1e
4
+ data.tar.gz: c4f7d1ee427a0cd5255b345bcff159fc88f3120b893fe103fb94701c8a33f446
5
5
  SHA512:
6
- metadata.gz: c59ad8d48e9414e12e86d848f029b9ad31437b6f41c842b787876ce1ae4a348d35ea6808904305c3212ec29124783b02cc7896a039a0ba7e43625842b127c552
7
- data.tar.gz: 55554c085582dd57bd884b50bfebaa59e2dffa5758646e49e2744e83ea28b4c7d23d347335c5cd08df0909e312d926a6070371e5474911d52d93256585d7e96f
6
+ metadata.gz: 29410fb9b7d8dfba02eb65f673462c822fdef643701c637fa7531aa11cac3e5ce9530146127b32a68f2128666e71f23fd9ecd2df8de924752193274393b03b18
7
+ data.tar.gz: c01b1718f58067a0488d75f359d33c6ef1802105f528410f03ac52b17f1944adcc767fc7fb46500a5e88f33004497ab980328f5c948eb25d2b477c44e6c19cdf
data/README.md CHANGED
@@ -16,12 +16,15 @@ A lightweight, zero-dependency web interface for monitoring Solid Queue backgrou
16
16
  ## Features
17
17
 
18
18
  - **Dashboard Overview**: Get a quick snapshot of your queue's health with statistics on all job types
19
+ - **Job Activity Chart**: Visual line chart showing jobs created, completed, and failed over time with 9 time range options (15m to 1 week)
20
+ - **Dark Theme**: Toggle between light and dark themes with system preference detection and localStorage persistence
19
21
  - **Ready Jobs**: View jobs that are ready to be executed
20
22
  - **In Progress Jobs**: Monitor jobs currently being processed by workers
21
23
  - **Scheduled Jobs**: See upcoming jobs scheduled for future execution with ability to execute immediately or reject permanently
22
24
  - **Recurring Jobs**: Manage periodic jobs that run on a schedule
23
25
  - **Failed Jobs**: Track and debug failed jobs, with the ability to retry or discard them
24
- - **Queue Management**: View and filter jobs by queue
26
+ - **Queue Management**: View and filter jobs by queue with pause/resume controls
27
+ - **Pause/Resume Queues**: Temporarily stop processing jobs on specific queues for incident response
25
28
  - **Advanced Job Filtering**: Filter jobs by class name, queue, status, and job arguments
26
29
  - **Quick Actions**: Retry or discard failed jobs, execute or reject scheduled jobs directly from any view
27
30
  - **Performance Optimized**: Designed for high-volume applications with smart pagination
@@ -32,9 +35,13 @@ A lightweight, zero-dependency web interface for monitoring Solid Queue backgrou
32
35
 
33
36
  ## Screenshots
34
37
 
35
- ### Dashboard Overview
38
+ ### Dashboard Overview (Light Theme)
36
39
 
37
- ![Dashboard Overview](screenshots/dashboard-3.png)
40
+ ![Dashboard Overview - Light Theme](screenshots/dashboard-light.png)
41
+
42
+ ### Dashboard Overview (Dark Theme)
43
+
44
+ ![Dashboard Overview - Dark Theme](screenshots/dashboard-dark.png)
38
45
 
39
46
  ### Failed Jobs
40
47
 
@@ -45,7 +52,7 @@ A lightweight, zero-dependency web interface for monitoring Solid Queue backgrou
45
52
  Add this line to your application's Gemfile:
46
53
 
47
54
  ```ruby
48
- gem 'solid_queue_monitor', '~> 0.4.0'
55
+ gem 'solid_queue_monitor', '~> 0.6.0'
49
56
  ```
50
57
 
51
58
  Then execute:
@@ -10,8 +10,17 @@ module SolidQueueMonitor
10
10
  skip_before_action :verify_authenticity_token
11
11
 
12
12
  def set_flash_message(message, type)
13
- session[:flash_message] = message
14
- session[:flash_type] = type
13
+ # Store in instance variable for access in views
14
+ @flash_message = message
15
+ @flash_type = type
16
+
17
+ # Try to use Rails flash if available
18
+ begin
19
+ flash[:notice] = message if type == :success
20
+ flash[:alert] = message if type == :error
21
+ rescue StandardError
22
+ # Flash not available (e.g., no session middleware)
23
+ end
15
24
  end
16
25
 
17
26
  private
@@ -7,13 +7,21 @@ module SolidQueueMonitor
7
7
  end
8
8
 
9
9
  def render_page(title, content)
10
- # Get flash message from session
11
- message = session[:flash_message]
12
- message_type = session[:flash_type]
13
-
14
- # Clear the flash message from session after using it
15
- session.delete(:flash_message)
16
- session.delete(:flash_type)
10
+ # Get flash message from instance variable (set by set_flash_message) or session
11
+ message = @flash_message
12
+ message_type = @flash_type
13
+
14
+ # Try to get from session as fallback, but don't fail if session unavailable
15
+ begin
16
+ message ||= session[:flash_message]
17
+ message_type ||= session[:flash_type]
18
+
19
+ # Clear the flash message from session after using it
20
+ session.delete(:flash_message) if message
21
+ session.delete(:flash_type) if message_type
22
+ rescue StandardError
23
+ # Session not available (e.g., no session middleware in tests)
24
+ end
17
25
 
18
26
  html = SolidQueueMonitor::HtmlGenerator.new(
19
27
  title: title,
@@ -4,6 +4,7 @@ module SolidQueueMonitor
4
4
  class OverviewController < BaseController
5
5
  def index
6
6
  @stats = SolidQueueMonitor::StatsCalculator.calculate
7
+ @chart_data = SolidQueueMonitor::ChartDataService.new(time_range: time_range_param).calculate
7
8
 
8
9
  recent_jobs_query = SolidQueue::Job.order(created_at: :desc).limit(100)
9
10
  @recent_jobs = paginate(filter_jobs(recent_jobs_query))
@@ -13,10 +14,20 @@ module SolidQueueMonitor
13
14
  render_page('Overview', generate_overview_content)
14
15
  end
15
16
 
17
+ def chart_data
18
+ chart_data = SolidQueueMonitor::ChartDataService.new(time_range: time_range_param).calculate
19
+ render json: chart_data
20
+ end
21
+
16
22
  private
17
23
 
24
+ def time_range_param
25
+ params[:time_range] || ChartDataService::DEFAULT_TIME_RANGE
26
+ end
27
+
18
28
  def generate_overview_content
19
29
  SolidQueueMonitor::StatsPresenter.new(@stats).render +
30
+ SolidQueueMonitor::ChartPresenter.new(@chart_data).render +
20
31
  SolidQueueMonitor::JobsPresenter.new(@recent_jobs[:records],
21
32
  current_page: @recent_jobs[:current_page],
22
33
  total_pages: @recent_jobs[:total_pages],
@@ -6,8 +6,25 @@ module SolidQueueMonitor
6
6
  @queues = SolidQueue::Job.group(:queue_name)
7
7
  .select('queue_name, COUNT(*) as job_count')
8
8
  .order('job_count DESC')
9
+ @paused_queues = QueuePauseService.paused_queues
9
10
 
10
- render_page('Queues', SolidQueueMonitor::QueuesPresenter.new(@queues).render)
11
+ render_page('Queues', SolidQueueMonitor::QueuesPresenter.new(@queues, @paused_queues).render)
12
+ end
13
+
14
+ def pause
15
+ queue_name = params[:queue_name]
16
+ result = QueuePauseService.new(queue_name).pause
17
+
18
+ set_flash_message(result[:message], result[:success] ? 'success' : 'error')
19
+ redirect_to queues_path
20
+ end
21
+
22
+ def resume
23
+ queue_name = params[:queue_name]
24
+ result = QueuePauseService.new(queue_name).resume
25
+
26
+ set_flash_message(result[:message], result[:success] ? 'success' : 'error')
27
+ redirect_to queues_path
11
28
  end
12
29
  end
13
30
  end
@@ -2,8 +2,9 @@
2
2
 
3
3
  module SolidQueueMonitor
4
4
  class QueuesPresenter < BasePresenter
5
- def initialize(records)
5
+ def initialize(records, paused_queues = [])
6
6
  @records = records
7
+ @paused_queues = paused_queues
7
8
  end
8
9
 
9
10
  def render
@@ -19,10 +20,12 @@ module SolidQueueMonitor
19
20
  <thead>
20
21
  <tr>
21
22
  <th>Queue Name</th>
23
+ <th>Status</th>
22
24
  <th>Total Jobs</th>
23
25
  <th>Ready Jobs</th>
24
26
  <th>Scheduled Jobs</th>
25
27
  <th>Failed Jobs</th>
28
+ <th>Actions</th>
26
29
  </tr>
27
30
  </thead>
28
31
  <tbody>
@@ -34,17 +37,53 @@ module SolidQueueMonitor
34
37
  end
35
38
 
36
39
  def generate_row(queue)
40
+ queue_name = queue.queue_name || 'default'
41
+ paused = @paused_queues.include?(queue_name)
42
+
37
43
  <<-HTML
38
- <tr>
39
- <td>#{queue.queue_name || 'default'}</td>
44
+ <tr class="#{paused ? 'queue-paused' : ''}">
45
+ <td>#{queue_name}</td>
46
+ <td>#{status_badge(paused)}</td>
40
47
  <td>#{queue.job_count}</td>
41
- <td>#{ready_jobs_count(queue.queue_name)}</td>
42
- <td>#{scheduled_jobs_count(queue.queue_name)}</td>
43
- <td>#{failed_jobs_count(queue.queue_name)}</td>
48
+ <td>#{ready_jobs_count(queue_name)}</td>
49
+ <td>#{scheduled_jobs_count(queue_name)}</td>
50
+ <td>#{failed_jobs_count(queue_name)}</td>
51
+ <td class="actions-cell">#{action_button(queue_name, paused)}</td>
44
52
  </tr>
45
53
  HTML
46
54
  end
47
55
 
56
+ def status_badge(paused)
57
+ if paused
58
+ '<span class="status-badge status-paused">Paused</span>'
59
+ else
60
+ '<span class="status-badge status-active">Active</span>'
61
+ end
62
+ end
63
+
64
+ def action_button(queue_name, paused)
65
+ if paused
66
+ <<-HTML
67
+ <form action="#{resume_queue_path}" method="post" class="inline-form">
68
+ <input type="hidden" name="queue_name" value="#{queue_name}">
69
+ <button type="submit" class="action-button resume-button" title="Resume queue processing">
70
+ Resume
71
+ </button>
72
+ </form>
73
+ HTML
74
+ else
75
+ <<-HTML
76
+ <form action="#{pause_queue_path}" method="post" class="inline-form"
77
+ onsubmit="return confirm('Are you sure you want to pause the #{queue_name} queue? Workers will stop processing jobs from this queue.');">
78
+ <input type="hidden" name="queue_name" value="#{queue_name}">
79
+ <button type="submit" class="action-button pause-button" title="Pause queue processing">
80
+ Pause
81
+ </button>
82
+ </form>
83
+ HTML
84
+ end
85
+ end
86
+
48
87
  def ready_jobs_count(queue_name)
49
88
  SolidQueue::ReadyExecution.where(queue_name: queue_name).count
50
89
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueMonitor
4
+ class ChartDataService
5
+ TIME_RANGES = {
6
+ '15m' => { duration: 15.minutes, buckets: 15, label_format: '%H:%M', label: 'Last 15 minutes' },
7
+ '30m' => { duration: 30.minutes, buckets: 15, label_format: '%H:%M', label: 'Last 30 minutes' },
8
+ '1h' => { duration: 1.hour, buckets: 12, label_format: '%H:%M', label: 'Last 1 hour' },
9
+ '3h' => { duration: 3.hours, buckets: 18, label_format: '%H:%M', label: 'Last 3 hours' },
10
+ '6h' => { duration: 6.hours, buckets: 24, label_format: '%H:%M', label: 'Last 6 hours' },
11
+ '12h' => { duration: 12.hours, buckets: 24, label_format: '%H:%M', label: 'Last 12 hours' },
12
+ '1d' => { duration: 1.day, buckets: 24, label_format: '%H:%M', label: 'Last 24 hours' },
13
+ '3d' => { duration: 3.days, buckets: 36, label_format: '%m/%d %H:%M', label: 'Last 3 days' },
14
+ '1w' => { duration: 7.days, buckets: 28, label_format: '%m/%d', label: 'Last 7 days' }
15
+ }.freeze
16
+
17
+ DEFAULT_TIME_RANGE = '1d'
18
+
19
+ def initialize(time_range: DEFAULT_TIME_RANGE)
20
+ @time_range = TIME_RANGES.key?(time_range) ? time_range : DEFAULT_TIME_RANGE
21
+ @config = TIME_RANGES[@time_range]
22
+ end
23
+
24
+ def calculate
25
+ end_time = Time.current
26
+ start_time = end_time - @config[:duration]
27
+ bucket_duration = @config[:duration] / @config[:buckets]
28
+
29
+ buckets = build_buckets(start_time, bucket_duration)
30
+
31
+ created_counts = fetch_created_counts(start_time, end_time)
32
+ completed_counts = fetch_completed_counts(start_time, end_time)
33
+ failed_counts = fetch_failed_counts(start_time, end_time)
34
+
35
+ created_data = assign_to_buckets(created_counts, buckets, bucket_duration)
36
+ completed_data = assign_to_buckets(completed_counts, buckets, bucket_duration)
37
+ failed_data = assign_to_buckets(failed_counts, buckets, bucket_duration)
38
+
39
+ {
40
+ labels: buckets.map { |b| b[:label] }, # rubocop:disable Rails/Pluck
41
+ created: created_data,
42
+ completed: completed_data,
43
+ failed: failed_data,
44
+ totals: {
45
+ created: created_data.sum,
46
+ completed: completed_data.sum,
47
+ failed: failed_data.sum
48
+ },
49
+ time_range: @time_range,
50
+ time_range_label: @config[:label],
51
+ available_ranges: TIME_RANGES.transform_values { |v| v[:label] }
52
+ }
53
+ end
54
+
55
+ private
56
+
57
+ def build_buckets(start_time, bucket_duration)
58
+ @config[:buckets].times.map do |i|
59
+ bucket_start = start_time + (i * bucket_duration)
60
+ {
61
+ start: bucket_start,
62
+ end: bucket_start + bucket_duration,
63
+ label: bucket_start.strftime(@config[:label_format])
64
+ }
65
+ end
66
+ end
67
+
68
+ def fetch_created_counts(start_time, end_time)
69
+ SolidQueue::Job
70
+ .where(created_at: start_time..end_time)
71
+ .pluck(:created_at)
72
+ end
73
+
74
+ def fetch_completed_counts(start_time, end_time)
75
+ SolidQueue::Job
76
+ .where(finished_at: start_time..end_time)
77
+ .where.not(finished_at: nil)
78
+ .pluck(:finished_at)
79
+ end
80
+
81
+ def fetch_failed_counts(start_time, end_time)
82
+ SolidQueue::FailedExecution
83
+ .where(created_at: start_time..end_time)
84
+ .pluck(:created_at)
85
+ end
86
+
87
+ def assign_to_buckets(timestamps, buckets, _bucket_duration)
88
+ counts = Array.new(buckets.size, 0)
89
+
90
+ timestamps.each do |timestamp|
91
+ bucket_index = buckets.find_index do |bucket|
92
+ timestamp >= bucket[:start] && timestamp < bucket[:end]
93
+ end
94
+ counts[bucket_index] += 1 if bucket_index
95
+ end
96
+
97
+ counts
98
+ end
99
+ end
100
+ end
@@ -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