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 +4 -4
- data/README.md +11 -4
- data/app/controllers/solid_queue_monitor/application_controller.rb +11 -2
- data/app/controllers/solid_queue_monitor/base_controller.rb +15 -7
- data/app/controllers/solid_queue_monitor/overview_controller.rb +11 -0
- data/app/controllers/solid_queue_monitor/queues_controller.rb +18 -1
- data/app/presenters/solid_queue_monitor/queues_presenter.rb +45 -6
- 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 +168 -8
- data/app/services/solid_queue_monitor/queue_pause_service.rb +34 -0
- data/app/services/solid_queue_monitor/stylesheet_generator.rb +425 -33
- data/config/database.yml +3 -0
- data/config/routes.rb +9 -1
- data/lib/solid_queue_monitor/engine.rb +5 -0
- data/lib/solid_queue_monitor/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 385e4d7ff7dc636b9448cefef256495f8c7aefd2e3318c57a6637c69d4cb5c1e
|
|
4
|
+
data.tar.gz: c4f7d1ee427a0cd5255b345bcff159fc88f3120b893fe103fb94701c8a33f446
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-

|
|
41
|
+
|
|
42
|
+
### Dashboard Overview (Dark Theme)
|
|
43
|
+
|
|
44
|
+

|
|
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.
|
|
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
|
-
|
|
14
|
-
|
|
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 =
|
|
12
|
-
message_type =
|
|
13
|
-
|
|
14
|
-
#
|
|
15
|
-
|
|
16
|
-
|
|
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>#{
|
|
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(
|
|
42
|
-
<td>#{scheduled_jobs_count(
|
|
43
|
-
<td>#{failed_jobs_count(
|
|
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
|