solid_queue_monitor 0.5.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 +9 -3
- data/app/controllers/solid_queue_monitor/overview_controller.rb +11 -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 +168 -8
- data/app/services/solid_queue_monitor/stylesheet_generator.rb +401 -33
- data/config/routes.rb +2 -0
- data/lib/solid_queue_monitor/version.rb +1 -1
- metadata +3 -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,6 +16,8 @@ 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
|
|
@@ -33,9 +35,13 @@ A lightweight, zero-dependency web interface for monitoring Solid Queue backgrou
|
|
|
33
35
|
|
|
34
36
|
## Screenshots
|
|
35
37
|
|
|
36
|
-
### Dashboard Overview
|
|
38
|
+
### Dashboard Overview (Light Theme)
|
|
37
39
|
|
|
38
|
-

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

|
|
39
45
|
|
|
40
46
|
### Failed Jobs
|
|
41
47
|
|
|
@@ -46,7 +52,7 @@ A lightweight, zero-dependency web interface for monitoring Solid Queue backgrou
|
|
|
46
52
|
Add this line to your application's Gemfile:
|
|
47
53
|
|
|
48
54
|
```ruby
|
|
49
|
-
gem 'solid_queue_monitor', '~> 0.
|
|
55
|
+
gem 'solid_queue_monitor', '~> 0.6.0'
|
|
50
56
|
```
|
|
51
57
|
|
|
52
58
|
Then execute:
|
|
@@ -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],
|
|
@@ -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
|
|
@@ -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,32 @@ 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
|
+
]
|
|
100
|
+
|
|
101
|
+
nav_links = nav_items.map do |item|
|
|
102
|
+
active_class = @title&.include?(item[:match]) ? 'active' : ''
|
|
103
|
+
"<a href=\"#{item[:path]}\" class=\"nav-link #{active_class}\">#{item[:label]}</a>"
|
|
104
|
+
end.join("\n ")
|
|
105
|
+
|
|
90
106
|
<<-HTML
|
|
91
107
|
<header>
|
|
92
108
|
<div class="header-top">
|
|
93
109
|
<h1>Solid Queue Monitor</h1>
|
|
94
|
-
|
|
110
|
+
<div class="header-controls">
|
|
111
|
+
#{generate_auto_refresh_controls}
|
|
112
|
+
#{generate_theme_toggle}
|
|
113
|
+
</div>
|
|
95
114
|
</div>
|
|
96
115
|
<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>
|
|
116
|
+
#{nav_links}
|
|
104
117
|
</nav>
|
|
105
118
|
</header>
|
|
106
119
|
HTML
|
|
@@ -135,6 +148,27 @@ module SolidQueueMonitor
|
|
|
135
148
|
HTML
|
|
136
149
|
end
|
|
137
150
|
|
|
151
|
+
def generate_theme_toggle
|
|
152
|
+
<<-HTML
|
|
153
|
+
<button class="theme-toggle-btn" id="theme-toggle-btn" title="Toggle dark mode">
|
|
154
|
+
<svg class="theme-icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
155
|
+
<circle cx="12" cy="12" r="5"></circle>
|
|
156
|
+
<line x1="12" y1="1" x2="12" y2="3"></line>
|
|
157
|
+
<line x1="12" y1="21" x2="12" y2="23"></line>
|
|
158
|
+
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
|
159
|
+
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
|
160
|
+
<line x1="1" y1="12" x2="3" y2="12"></line>
|
|
161
|
+
<line x1="21" y1="12" x2="23" y2="12"></line>
|
|
162
|
+
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
|
163
|
+
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
|
164
|
+
</svg>
|
|
165
|
+
<svg class="theme-icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
166
|
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
|
167
|
+
</svg>
|
|
168
|
+
</button>
|
|
169
|
+
HTML
|
|
170
|
+
end
|
|
171
|
+
|
|
138
172
|
def generate_auto_refresh_script
|
|
139
173
|
return '' unless SolidQueueMonitor.auto_refresh_enabled
|
|
140
174
|
|
|
@@ -212,6 +246,132 @@ module SolidQueueMonitor
|
|
|
212
246
|
JS
|
|
213
247
|
end
|
|
214
248
|
|
|
249
|
+
def generate_chart_script
|
|
250
|
+
<<-HTML
|
|
251
|
+
<script>
|
|
252
|
+
#{theme_toggle_javascript}
|
|
253
|
+
#{chart_tooltip_javascript}
|
|
254
|
+
</script>
|
|
255
|
+
HTML
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def theme_toggle_javascript
|
|
259
|
+
<<-JS
|
|
260
|
+
(function() {
|
|
261
|
+
var body = document.body;
|
|
262
|
+
var themeBtn = document.getElementById('theme-toggle-btn');
|
|
263
|
+
var storageKey = 'sqm_dark_theme';
|
|
264
|
+
|
|
265
|
+
// Check for saved preference or system preference
|
|
266
|
+
function getPreferredTheme() {
|
|
267
|
+
var saved = localStorage.getItem(storageKey);
|
|
268
|
+
if (saved !== null) {
|
|
269
|
+
return saved === 'true';
|
|
270
|
+
}
|
|
271
|
+
// Check system preference
|
|
272
|
+
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function setTheme(isDark) {
|
|
276
|
+
if (isDark) {
|
|
277
|
+
body.classList.add('dark-theme');
|
|
278
|
+
} else {
|
|
279
|
+
body.classList.remove('dark-theme');
|
|
280
|
+
}
|
|
281
|
+
localStorage.setItem(storageKey, isDark ? 'true' : 'false');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Initialize theme
|
|
285
|
+
setTheme(getPreferredTheme());
|
|
286
|
+
|
|
287
|
+
// Toggle on button click
|
|
288
|
+
if (themeBtn) {
|
|
289
|
+
themeBtn.addEventListener('click', function() {
|
|
290
|
+
var isDark = body.classList.contains('dark-theme');
|
|
291
|
+
setTheme(!isDark);
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Listen for system preference changes
|
|
296
|
+
if (window.matchMedia) {
|
|
297
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
|
|
298
|
+
// Only auto-switch if user hasn't manually set a preference
|
|
299
|
+
if (localStorage.getItem(storageKey) === null) {
|
|
300
|
+
setTheme(e.matches);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
})();
|
|
305
|
+
JS
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def chart_tooltip_javascript
|
|
309
|
+
<<-JS
|
|
310
|
+
(function() {
|
|
311
|
+
// Chart collapse/expand functionality
|
|
312
|
+
var chartSection = document.getElementById('chart-section');
|
|
313
|
+
var toggleBtn = document.getElementById('chart-toggle-btn');
|
|
314
|
+
|
|
315
|
+
if (chartSection && toggleBtn) {
|
|
316
|
+
var isCollapsed = localStorage.getItem('sqm_chart_collapsed') === 'true';
|
|
317
|
+
|
|
318
|
+
if (isCollapsed) {
|
|
319
|
+
chartSection.classList.add('collapsed');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
toggleBtn.addEventListener('click', function() {
|
|
323
|
+
chartSection.classList.toggle('collapsed');
|
|
324
|
+
var collapsed = chartSection.classList.contains('collapsed');
|
|
325
|
+
localStorage.setItem('sqm_chart_collapsed', collapsed ? 'true' : 'false');
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Chart tooltip functionality
|
|
330
|
+
var tooltip = document.getElementById('chart-tooltip');
|
|
331
|
+
if (!tooltip) return;
|
|
332
|
+
|
|
333
|
+
var dataPoints = document.querySelectorAll('.data-point');
|
|
334
|
+
var seriesNames = { created: 'Created', completed: 'Completed', failed: 'Failed' };
|
|
335
|
+
|
|
336
|
+
dataPoints.forEach(function(point) {
|
|
337
|
+
point.addEventListener('mouseenter', function(e) {
|
|
338
|
+
var series = this.getAttribute('data-series');
|
|
339
|
+
var label = this.getAttribute('data-label');
|
|
340
|
+
var value = this.getAttribute('data-value');
|
|
341
|
+
|
|
342
|
+
tooltip.querySelector('.tooltip-label').textContent = label;
|
|
343
|
+
tooltip.querySelector('.tooltip-value').textContent = seriesNames[series] + ': ' + value;
|
|
344
|
+
tooltip.style.display = 'block';
|
|
345
|
+
positionTooltip(e);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
point.addEventListener('mousemove', function(e) {
|
|
349
|
+
positionTooltip(e);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
point.addEventListener('mouseleave', function() {
|
|
353
|
+
tooltip.style.display = 'none';
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
function positionTooltip(e) {
|
|
358
|
+
var x = e.clientX + 10;
|
|
359
|
+
var y = e.clientY - 30;
|
|
360
|
+
|
|
361
|
+
if (x + tooltip.offsetWidth > window.innerWidth) {
|
|
362
|
+
x = e.clientX - tooltip.offsetWidth - 10;
|
|
363
|
+
}
|
|
364
|
+
if (y < 0) {
|
|
365
|
+
y = e.clientY + 10;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
tooltip.style.left = x + 'px';
|
|
369
|
+
tooltip.style.top = y + 'px';
|
|
370
|
+
}
|
|
371
|
+
})();
|
|
372
|
+
JS
|
|
373
|
+
end
|
|
374
|
+
|
|
215
375
|
def default_url_options
|
|
216
376
|
{ only_path: true }
|
|
217
377
|
end
|
|
@@ -8,9 +8,35 @@ module SolidQueueMonitor
|
|
|
8
8
|
--primary-color: #3b82f6;
|
|
9
9
|
--success-color: #10b981;
|
|
10
10
|
--error-color: #ef4444;
|
|
11
|
+
--warning-color: #f59e0b;
|
|
11
12
|
--text-color: #1f2937;
|
|
13
|
+
--text-muted: #6b7280;
|
|
12
14
|
--border-color: #e5e7eb;
|
|
13
15
|
--background-color: #f9fafb;
|
|
16
|
+
--card-background: #ffffff;
|
|
17
|
+
--card-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
18
|
+
--input-background: #ffffff;
|
|
19
|
+
--input-border: #d1d5db;
|
|
20
|
+
--hover-background: #f3f4f6;
|
|
21
|
+
--code-background: #f5f5f5;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* Dark theme */
|
|
25
|
+
.solid_queue_monitor.dark-theme {
|
|
26
|
+
--primary-color: #60a5fa;
|
|
27
|
+
--success-color: #34d399;
|
|
28
|
+
--error-color: #f87171;
|
|
29
|
+
--warning-color: #fbbf24;
|
|
30
|
+
--text-color: #f9fafb;
|
|
31
|
+
--text-muted: #9ca3af;
|
|
32
|
+
--border-color: #2d2d2d;
|
|
33
|
+
--background-color: #000000;
|
|
34
|
+
--card-background: #121212;
|
|
35
|
+
--card-shadow: 0 1px 3px rgba(0,0,0,0.5);
|
|
36
|
+
--input-background: #1e1e1e;
|
|
37
|
+
--input-border: #3d3d3d;
|
|
38
|
+
--hover-background: #1e1e1e;
|
|
39
|
+
--code-background: #1e1e1e;
|
|
14
40
|
}
|
|
15
41
|
|
|
16
42
|
.solid_queue_monitor * { box-sizing: border-box; margin: 0; padding: 0; }
|
|
@@ -23,7 +49,8 @@ module SolidQueueMonitor
|
|
|
23
49
|
}
|
|
24
50
|
|
|
25
51
|
.solid_queue_monitor .container {
|
|
26
|
-
|
|
52
|
+
width: 95%;
|
|
53
|
+
max-width: 1800px;
|
|
27
54
|
margin: 0 auto;
|
|
28
55
|
padding: 2rem;
|
|
29
56
|
}
|
|
@@ -52,8 +79,8 @@ module SolidQueueMonitor
|
|
|
52
79
|
color: var(--text-color);
|
|
53
80
|
padding: 0.5rem 1rem;
|
|
54
81
|
border-radius: 0.375rem;
|
|
55
|
-
background:
|
|
56
|
-
box-shadow:
|
|
82
|
+
background: var(--card-background);
|
|
83
|
+
box-shadow: var(--card-shadow);
|
|
57
84
|
transition: all 0.2s;
|
|
58
85
|
}
|
|
59
86
|
|
|
@@ -89,15 +116,15 @@ module SolidQueueMonitor
|
|
|
89
116
|
.solid_queue_monitor .stat-card {
|
|
90
117
|
flex: 1 1 0;
|
|
91
118
|
min-width: 150px;
|
|
92
|
-
background:
|
|
119
|
+
background: var(--card-background);
|
|
93
120
|
padding: 1.5rem 1rem;
|
|
94
121
|
border-radius: 0.5rem;
|
|
95
|
-
box-shadow:
|
|
122
|
+
box-shadow: var(--card-shadow);
|
|
96
123
|
text-align: center;
|
|
97
124
|
}
|
|
98
125
|
|
|
99
126
|
.solid_queue_monitor .stat-card h3 {
|
|
100
|
-
color:
|
|
127
|
+
color: var(--text-muted);
|
|
101
128
|
font-size: 0.875rem;
|
|
102
129
|
text-transform: uppercase;
|
|
103
130
|
letter-spacing: 0.05em;
|
|
@@ -137,11 +164,12 @@ module SolidQueueMonitor
|
|
|
137
164
|
}
|
|
138
165
|
|
|
139
166
|
.solid_queue_monitor th {
|
|
140
|
-
background: var(--background
|
|
167
|
+
background: var(--hover-background);
|
|
141
168
|
font-weight: 500;
|
|
142
169
|
font-size: 0.875rem;
|
|
143
170
|
text-transform: uppercase;
|
|
144
171
|
letter-spacing: 0.05em;
|
|
172
|
+
color: var(--text-muted);
|
|
145
173
|
}
|
|
146
174
|
|
|
147
175
|
.solid_queue_monitor .status-badge {
|
|
@@ -242,7 +270,7 @@ module SolidQueueMonitor
|
|
|
242
270
|
.solid_queue_monitor footer {
|
|
243
271
|
text-align: center;
|
|
244
272
|
padding: 2rem 0;
|
|
245
|
-
color:
|
|
273
|
+
color: var(--text-muted);
|
|
246
274
|
}
|
|
247
275
|
|
|
248
276
|
.solid_queue_monitor .pagination {
|
|
@@ -289,7 +317,7 @@ module SolidQueueMonitor
|
|
|
289
317
|
}
|
|
290
318
|
|
|
291
319
|
.solid_queue_monitor .pagination-link {
|
|
292
|
-
background:
|
|
320
|
+
background: var(--card-background);
|
|
293
321
|
color: var(--text-color);
|
|
294
322
|
border: 1px solid var(--border-color);
|
|
295
323
|
}
|
|
@@ -320,7 +348,7 @@ module SolidQueueMonitor
|
|
|
320
348
|
max-height: 100px;
|
|
321
349
|
overflow-y: auto;
|
|
322
350
|
padding: 8px;
|
|
323
|
-
background:
|
|
351
|
+
background: var(--code-background);
|
|
324
352
|
border-radius: 4px;
|
|
325
353
|
font-size: 0.9em;
|
|
326
354
|
}
|
|
@@ -328,7 +356,7 @@ module SolidQueueMonitor
|
|
|
328
356
|
.solid_queue_monitor .args-single-line {
|
|
329
357
|
display: inline-block;
|
|
330
358
|
padding: 4px 8px;
|
|
331
|
-
background:
|
|
359
|
+
background: var(--code-background);
|
|
332
360
|
border-radius: 4px;
|
|
333
361
|
font-size: 0.9em;
|
|
334
362
|
}
|
|
@@ -399,10 +427,10 @@ module SolidQueueMonitor
|
|
|
399
427
|
}
|
|
400
428
|
|
|
401
429
|
.solid_queue_monitor .filter-form-container {
|
|
402
|
-
background:
|
|
430
|
+
background: var(--card-background);
|
|
403
431
|
padding: 1rem;
|
|
404
432
|
border-radius: 0.5rem;
|
|
405
|
-
box-shadow:
|
|
433
|
+
box-shadow: var(--card-shadow);
|
|
406
434
|
flex: 3;
|
|
407
435
|
}
|
|
408
436
|
|
|
@@ -411,9 +439,9 @@ module SolidQueueMonitor
|
|
|
411
439
|
flex-direction: row;
|
|
412
440
|
gap: 0.75rem;
|
|
413
441
|
padding: 1rem;
|
|
414
|
-
background:
|
|
442
|
+
background: var(--card-background);
|
|
415
443
|
border-radius: 0.5rem;
|
|
416
|
-
box-shadow:
|
|
444
|
+
box-shadow: var(--card-shadow);
|
|
417
445
|
flex: 2;
|
|
418
446
|
align-items: center;
|
|
419
447
|
justify-content: center;
|
|
@@ -453,16 +481,18 @@ module SolidQueueMonitor
|
|
|
453
481
|
margin-bottom: 0.5rem;
|
|
454
482
|
font-size: 0.875rem;
|
|
455
483
|
font-weight: 500;
|
|
456
|
-
color:
|
|
484
|
+
color: var(--text-muted);
|
|
457
485
|
}
|
|
458
486
|
|
|
459
487
|
.solid_queue_monitor .filter-group input,
|
|
460
488
|
.solid_queue_monitor .filter-group select {
|
|
461
489
|
width: 100%;
|
|
462
490
|
padding: 0.5rem;
|
|
463
|
-
border: 1px solid
|
|
491
|
+
border: 1px solid var(--input-border);
|
|
464
492
|
border-radius: 0.375rem;
|
|
465
493
|
font-size: 0.875rem;
|
|
494
|
+
background: var(--input-background);
|
|
495
|
+
color: var(--text-color);
|
|
466
496
|
}
|
|
467
497
|
|
|
468
498
|
.solid_queue_monitor .filter-actions {
|
|
@@ -486,9 +516,9 @@ module SolidQueueMonitor
|
|
|
486
516
|
}
|
|
487
517
|
|
|
488
518
|
.solid_queue_monitor .reset-button {
|
|
489
|
-
background:
|
|
490
|
-
color:
|
|
491
|
-
border: 1px solid
|
|
519
|
+
background: var(--hover-background);
|
|
520
|
+
color: var(--text-muted);
|
|
521
|
+
border: 1px solid var(--border-color);
|
|
492
522
|
padding: 0.5rem 1rem;
|
|
493
523
|
border-radius: 0.375rem;
|
|
494
524
|
font-size: 0.875rem;
|
|
@@ -498,7 +528,7 @@ module SolidQueueMonitor
|
|
|
498
528
|
}
|
|
499
529
|
|
|
500
530
|
.solid_queue_monitor .reset-button:hover {
|
|
501
|
-
background:
|
|
531
|
+
background: var(--border-color);
|
|
502
532
|
}
|
|
503
533
|
|
|
504
534
|
.solid_queue_monitor .action-button {
|
|
@@ -560,7 +590,7 @@ module SolidQueueMonitor
|
|
|
560
590
|
white-space: pre-wrap;
|
|
561
591
|
max-height: 200px;
|
|
562
592
|
overflow-y: auto;
|
|
563
|
-
background:
|
|
593
|
+
background: var(--code-background);
|
|
564
594
|
padding: 0.5rem;
|
|
565
595
|
border-radius: 0.25rem;
|
|
566
596
|
margin-top: 0.5rem;
|
|
@@ -572,12 +602,12 @@ module SolidQueueMonitor
|
|
|
572
602
|
|
|
573
603
|
.solid_queue_monitor summary {
|
|
574
604
|
cursor: pointer;
|
|
575
|
-
color:
|
|
605
|
+
color: var(--text-muted);
|
|
576
606
|
font-size: 0.75rem;
|
|
577
607
|
}
|
|
578
608
|
|
|
579
609
|
.solid_queue_monitor summary:hover {
|
|
580
|
-
color:
|
|
610
|
+
color: var(--text-color);
|
|
581
611
|
}
|
|
582
612
|
|
|
583
613
|
.solid_queue_monitor .job-checkbox,
|
|
@@ -590,10 +620,10 @@ module SolidQueueMonitor
|
|
|
590
620
|
display: flex;
|
|
591
621
|
gap: 0.75rem;
|
|
592
622
|
margin: 1rem 0;
|
|
593
|
-
background:
|
|
623
|
+
background: var(--card-background);
|
|
594
624
|
padding: 0.75rem;
|
|
595
625
|
border-radius: 0.5rem;
|
|
596
|
-
box-shadow:
|
|
626
|
+
box-shadow: var(--card-shadow);
|
|
597
627
|
}
|
|
598
628
|
|
|
599
629
|
.solid_queue_monitor .bulk-actions-bar .action-button {
|
|
@@ -625,11 +655,11 @@ module SolidQueueMonitor
|
|
|
625
655
|
align-items: center;
|
|
626
656
|
gap: 0.5rem;
|
|
627
657
|
padding: 0.375rem 0.625rem;
|
|
628
|
-
background:
|
|
658
|
+
background: var(--card-background);
|
|
629
659
|
border-radius: 2rem;
|
|
630
|
-
box-shadow:
|
|
660
|
+
box-shadow: var(--card-shadow);
|
|
631
661
|
font-size: 0.75rem;
|
|
632
|
-
color:
|
|
662
|
+
color: var(--text-muted);
|
|
633
663
|
cursor: default;
|
|
634
664
|
}
|
|
635
665
|
|
|
@@ -682,7 +712,7 @@ module SolidQueueMonitor
|
|
|
682
712
|
width: 6px;
|
|
683
713
|
height: 6px;
|
|
684
714
|
border-radius: 50%;
|
|
685
|
-
background:
|
|
715
|
+
background: var(--border-color);
|
|
686
716
|
flex-shrink: 0;
|
|
687
717
|
}
|
|
688
718
|
|
|
@@ -726,7 +756,7 @@ module SolidQueueMonitor
|
|
|
726
756
|
left: 0;
|
|
727
757
|
right: 0;
|
|
728
758
|
bottom: 0;
|
|
729
|
-
background-color:
|
|
759
|
+
background-color: var(--border-color);
|
|
730
760
|
transition: 0.2s;
|
|
731
761
|
border-radius: 18px;
|
|
732
762
|
}
|
|
@@ -738,7 +768,7 @@ module SolidQueueMonitor
|
|
|
738
768
|
width: 14px;
|
|
739
769
|
left: 2px;
|
|
740
770
|
bottom: 2px;
|
|
741
|
-
background-color:
|
|
771
|
+
background-color: var(--card-background);
|
|
742
772
|
transition: 0.2s;
|
|
743
773
|
border-radius: 50%;
|
|
744
774
|
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
|
@@ -761,7 +791,7 @@ module SolidQueueMonitor
|
|
|
761
791
|
padding: 0.25rem;
|
|
762
792
|
border-radius: 0.25rem;
|
|
763
793
|
cursor: pointer;
|
|
764
|
-
color:
|
|
794
|
+
color: var(--text-muted);
|
|
765
795
|
transition: all 0.2s;
|
|
766
796
|
}
|
|
767
797
|
|
|
@@ -786,6 +816,344 @@ module SolidQueueMonitor
|
|
|
786
816
|
display: none;
|
|
787
817
|
}
|
|
788
818
|
}
|
|
819
|
+
|
|
820
|
+
/* Navigation active state */
|
|
821
|
+
.solid_queue_monitor .nav-link.active {
|
|
822
|
+
background: var(--primary-color);
|
|
823
|
+
color: white;
|
|
824
|
+
border-left: 3px solid #1d4ed8;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/* Chart styles */
|
|
828
|
+
.solid_queue_monitor .chart-section {
|
|
829
|
+
background: var(--card-background);
|
|
830
|
+
border-radius: 0.5rem;
|
|
831
|
+
box-shadow: var(--card-shadow);
|
|
832
|
+
padding: 1rem 1.5rem;
|
|
833
|
+
margin-bottom: 2rem;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
.solid_queue_monitor .chart-section.collapsed {
|
|
837
|
+
padding-bottom: 1rem;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
.solid_queue_monitor .chart-header {
|
|
841
|
+
display: flex;
|
|
842
|
+
justify-content: space-between;
|
|
843
|
+
align-items: center;
|
|
844
|
+
flex-wrap: wrap;
|
|
845
|
+
gap: 0.75rem;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
.solid_queue_monitor .chart-header-left {
|
|
849
|
+
display: flex;
|
|
850
|
+
align-items: center;
|
|
851
|
+
gap: 0.5rem;
|
|
852
|
+
flex-wrap: wrap;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
.solid_queue_monitor .chart-header h3 {
|
|
856
|
+
font-size: 1rem;
|
|
857
|
+
font-weight: 600;
|
|
858
|
+
color: var(--text-color);
|
|
859
|
+
margin: 0;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
.solid_queue_monitor .chart-toggle-btn {
|
|
863
|
+
display: flex;
|
|
864
|
+
align-items: center;
|
|
865
|
+
justify-content: center;
|
|
866
|
+
width: 28px;
|
|
867
|
+
height: 28px;
|
|
868
|
+
background: var(--hover-background);
|
|
869
|
+
border: 1px solid var(--border-color);
|
|
870
|
+
border-radius: 0.375rem;
|
|
871
|
+
cursor: pointer;
|
|
872
|
+
color: var(--text-muted);
|
|
873
|
+
transition: all 0.2s;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
.solid_queue_monitor .chart-toggle-btn:hover {
|
|
877
|
+
background: var(--border-color);
|
|
878
|
+
color: var(--text-color);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
.solid_queue_monitor .chart-toggle-icon {
|
|
882
|
+
transition: transform 0.2s;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
.solid_queue_monitor .chart-section.collapsed .chart-toggle-icon {
|
|
886
|
+
transform: rotate(-90deg);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
.solid_queue_monitor .chart-summary {
|
|
890
|
+
display: flex;
|
|
891
|
+
align-items: center;
|
|
892
|
+
gap: 0.5rem;
|
|
893
|
+
font-size: 0.8rem;
|
|
894
|
+
color: var(--text-muted);
|
|
895
|
+
margin-left: 0.5rem;
|
|
896
|
+
padding-left: 0.75rem;
|
|
897
|
+
border-left: 1px solid var(--border-color);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
.solid_queue_monitor .summary-item {
|
|
901
|
+
white-space: nowrap;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
.solid_queue_monitor .summary-created {
|
|
905
|
+
color: #3b82f6;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
.solid_queue_monitor .summary-completed {
|
|
909
|
+
color: #10b981;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
.solid_queue_monitor .summary-failed {
|
|
913
|
+
color: #ef4444;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
.solid_queue_monitor .summary-separator {
|
|
917
|
+
color: var(--border-color);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
.solid_queue_monitor .chart-time-select-wrapper {
|
|
921
|
+
position: relative;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
.solid_queue_monitor .chart-time-select {
|
|
925
|
+
appearance: none;
|
|
926
|
+
padding: 0.5rem 2rem 0.5rem 0.75rem;
|
|
927
|
+
font-size: 0.8rem;
|
|
928
|
+
color: var(--text-color);
|
|
929
|
+
background: var(--input-background);
|
|
930
|
+
border: 1px solid var(--border-color);
|
|
931
|
+
border-radius: 0.375rem;
|
|
932
|
+
cursor: pointer;
|
|
933
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
|
934
|
+
background-repeat: no-repeat;
|
|
935
|
+
background-position: right 0.5rem center;
|
|
936
|
+
background-size: 14px;
|
|
937
|
+
min-width: 140px;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
.solid_queue_monitor .chart-time-select:hover {
|
|
941
|
+
border-color: var(--text-muted);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
.solid_queue_monitor .chart-time-select:focus {
|
|
945
|
+
outline: none;
|
|
946
|
+
border-color: var(--primary-color);
|
|
947
|
+
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
.solid_queue_monitor .chart-collapsible {
|
|
951
|
+
overflow: hidden;
|
|
952
|
+
transition: max-height 0.3s ease-out, opacity 0.2s ease-out, margin-top 0.3s ease-out;
|
|
953
|
+
max-height: 500px;
|
|
954
|
+
opacity: 1;
|
|
955
|
+
margin-top: 1rem;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
.solid_queue_monitor .chart-section.collapsed .chart-collapsible {
|
|
959
|
+
max-height: 0;
|
|
960
|
+
opacity: 0;
|
|
961
|
+
margin-top: 0;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
.solid_queue_monitor .chart-container {
|
|
965
|
+
width: 100%;
|
|
966
|
+
overflow-x: auto;
|
|
967
|
+
overflow-y: hidden;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
.solid_queue_monitor .chart-container svg {
|
|
971
|
+
display: block;
|
|
972
|
+
width: 100%;
|
|
973
|
+
height: auto;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
.solid_queue_monitor .job-activity-chart {
|
|
977
|
+
width: 100%;
|
|
978
|
+
height: auto;
|
|
979
|
+
min-height: 250px;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
.solid_queue_monitor .grid-line {
|
|
983
|
+
stroke: var(--border-color);
|
|
984
|
+
stroke-width: 1;
|
|
985
|
+
stroke-dasharray: 4 4;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
.solid_queue_monitor .axis-line {
|
|
989
|
+
stroke: var(--border-color);
|
|
990
|
+
stroke-width: 1;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
.solid_queue_monitor .axis-label {
|
|
994
|
+
font-size: 11px;
|
|
995
|
+
fill: var(--text-muted);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
.solid_queue_monitor .x-label {
|
|
999
|
+
text-anchor: middle;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
.solid_queue_monitor .y-label {
|
|
1003
|
+
text-anchor: end;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
.solid_queue_monitor .chart-line {
|
|
1007
|
+
stroke-linecap: round;
|
|
1008
|
+
stroke-linejoin: round;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
.solid_queue_monitor .data-point {
|
|
1012
|
+
cursor: pointer;
|
|
1013
|
+
transition: r 0.2s;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
.solid_queue_monitor .data-point:hover {
|
|
1017
|
+
r: 6;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
.solid_queue_monitor .chart-legend {
|
|
1021
|
+
display: flex;
|
|
1022
|
+
justify-content: center;
|
|
1023
|
+
gap: 1.5rem;
|
|
1024
|
+
margin-top: 1rem;
|
|
1025
|
+
flex-wrap: wrap;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
.solid_queue_monitor .legend-item {
|
|
1029
|
+
display: flex;
|
|
1030
|
+
align-items: center;
|
|
1031
|
+
gap: 0.375rem;
|
|
1032
|
+
font-size: 0.875rem;
|
|
1033
|
+
color: var(--text-muted);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
.solid_queue_monitor .legend-color {
|
|
1037
|
+
width: 12px;
|
|
1038
|
+
height: 12px;
|
|
1039
|
+
border-radius: 2px;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
.solid_queue_monitor .chart-tooltip {
|
|
1043
|
+
position: fixed;
|
|
1044
|
+
background: #1f2937;
|
|
1045
|
+
color: white;
|
|
1046
|
+
padding: 0.5rem 0.75rem;
|
|
1047
|
+
border-radius: 0.375rem;
|
|
1048
|
+
font-size: 0.75rem;
|
|
1049
|
+
pointer-events: none;
|
|
1050
|
+
z-index: 1000;
|
|
1051
|
+
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
.solid_queue_monitor .tooltip-label {
|
|
1055
|
+
font-weight: 500;
|
|
1056
|
+
margin-bottom: 0.25rem;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
.solid_queue_monitor .tooltip-value {
|
|
1060
|
+
color: #d1d5db;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
.solid_queue_monitor .chart-empty {
|
|
1064
|
+
display: flex;
|
|
1065
|
+
align-items: center;
|
|
1066
|
+
justify-content: center;
|
|
1067
|
+
height: 200px;
|
|
1068
|
+
color: var(--text-muted);
|
|
1069
|
+
font-size: 0.875rem;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
@media (max-width: 768px) {
|
|
1073
|
+
.solid_queue_monitor .chart-section {
|
|
1074
|
+
padding: 1rem;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
.solid_queue_monitor .chart-header {
|
|
1078
|
+
flex-direction: column;
|
|
1079
|
+
align-items: flex-start;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
.solid_queue_monitor .chart-header-left {
|
|
1083
|
+
width: 100%;
|
|
1084
|
+
flex-wrap: wrap;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
.solid_queue_monitor .chart-summary {
|
|
1088
|
+
margin-left: 0;
|
|
1089
|
+
padding-left: 0;
|
|
1090
|
+
border-left: none;
|
|
1091
|
+
margin-top: 0.5rem;
|
|
1092
|
+
width: 100%;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
.solid_queue_monitor .chart-time-select {
|
|
1096
|
+
width: 100%;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
.solid_queue_monitor .job-activity-chart {
|
|
1100
|
+
min-height: 200px;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
.solid_queue_monitor .chart-legend {
|
|
1104
|
+
gap: 1rem;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/* Theme toggle button */
|
|
1109
|
+
.solid_queue_monitor .theme-toggle-btn {
|
|
1110
|
+
display: flex;
|
|
1111
|
+
align-items: center;
|
|
1112
|
+
justify-content: center;
|
|
1113
|
+
width: 36px;
|
|
1114
|
+
height: 36px;
|
|
1115
|
+
background: var(--card-background);
|
|
1116
|
+
border: 1px solid var(--border-color);
|
|
1117
|
+
border-radius: 50%;
|
|
1118
|
+
cursor: pointer;
|
|
1119
|
+
color: var(--text-muted);
|
|
1120
|
+
transition: all 0.2s;
|
|
1121
|
+
box-shadow: var(--card-shadow);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
.solid_queue_monitor .theme-toggle-btn:hover {
|
|
1125
|
+
color: var(--text-color);
|
|
1126
|
+
border-color: var(--text-muted);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
.solid_queue_monitor .theme-toggle-btn svg {
|
|
1130
|
+
width: 18px;
|
|
1131
|
+
height: 18px;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/* Hide moon icon in light mode, show sun icon */
|
|
1135
|
+
.solid_queue_monitor .theme-icon-moon {
|
|
1136
|
+
display: none;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
.solid_queue_monitor .theme-icon-sun {
|
|
1140
|
+
display: block;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/* In dark mode, show moon icon, hide sun icon */
|
|
1144
|
+
.solid_queue_monitor.dark-theme .theme-icon-moon {
|
|
1145
|
+
display: block;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
.solid_queue_monitor.dark-theme .theme-icon-sun {
|
|
1149
|
+
display: none;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
.solid_queue_monitor .header-controls {
|
|
1153
|
+
display: flex;
|
|
1154
|
+
align-items: center;
|
|
1155
|
+
gap: 0.75rem;
|
|
1156
|
+
}
|
|
789
1157
|
CSS
|
|
790
1158
|
end
|
|
791
1159
|
end
|
data/config/routes.rb
CHANGED
|
@@ -6,6 +6,8 @@ SolidQueueMonitor::Engine.routes.draw do
|
|
|
6
6
|
|
|
7
7
|
root to: 'overview#index'
|
|
8
8
|
|
|
9
|
+
get 'chart_data', to: 'overview#chart_data', as: :chart_data
|
|
10
|
+
|
|
9
11
|
resources :ready_jobs, only: [:index]
|
|
10
12
|
resources :scheduled_jobs, only: [:index]
|
|
11
13
|
resources :recurring_jobs, only: [:index]
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: solid_queue_monitor
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Vishal Sadriya
|
|
@@ -67,6 +67,8 @@ files:
|
|
|
67
67
|
- app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb
|
|
68
68
|
- app/presenters/solid_queue_monitor/stats_presenter.rb
|
|
69
69
|
- app/services/solid_queue_monitor/authentication_service.rb
|
|
70
|
+
- app/services/solid_queue_monitor/chart_data_service.rb
|
|
71
|
+
- app/services/solid_queue_monitor/chart_presenter.rb
|
|
70
72
|
- app/services/solid_queue_monitor/execute_job_service.rb
|
|
71
73
|
- app/services/solid_queue_monitor/failed_job_service.rb
|
|
72
74
|
- app/services/solid_queue_monitor/html_generator.rb
|