job_harbor 0.1.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 +7 -0
- data/README.md +98 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/solidqueue_dashboard/application.css +1 -0
- data/app/components/job_harbor/application_component.rb +13 -0
- data/app/components/job_harbor/badge_component.rb +26 -0
- data/app/components/job_harbor/chart_component.rb +82 -0
- data/app/components/job_harbor/empty_state_component.rb +41 -0
- data/app/components/job_harbor/failure_rates_component.rb +84 -0
- data/app/components/job_harbor/job_filters_component.rb +92 -0
- data/app/components/job_harbor/job_row_component.rb +106 -0
- data/app/components/job_harbor/nav_link_component.rb +50 -0
- data/app/components/job_harbor/pagination_component.rb +72 -0
- data/app/components/job_harbor/per_page_selector_component.rb +40 -0
- data/app/components/job_harbor/queue_card_component.rb +59 -0
- data/app/components/job_harbor/refresh_selector_component.rb +57 -0
- data/app/components/job_harbor/stat_card_component.rb +77 -0
- data/app/components/job_harbor/theme_toggle_component.rb +48 -0
- data/app/components/job_harbor/worker_card_component.rb +86 -0
- data/app/controllers/job_harbor/application_controller.rb +44 -0
- data/app/controllers/job_harbor/dashboard_controller.rb +17 -0
- data/app/controllers/job_harbor/jobs_controller.rb +151 -0
- data/app/controllers/job_harbor/queues_controller.rb +40 -0
- data/app/controllers/job_harbor/recurring_tasks_controller.rb +35 -0
- data/app/controllers/job_harbor/workers_controller.rb +12 -0
- data/app/helpers/job_harbor/application_helper.rb +4 -0
- data/app/models/job_harbor/chart_data.rb +104 -0
- data/app/models/job_harbor/dashboard_stats.rb +90 -0
- data/app/models/job_harbor/failure_stats.rb +63 -0
- data/app/models/job_harbor/job_presenter.rb +246 -0
- data/app/models/job_harbor/queue_stats.rb +77 -0
- data/app/views/job_harbor/dashboard/index.html.erb +112 -0
- data/app/views/job_harbor/jobs/index.html.erb +100 -0
- data/app/views/job_harbor/jobs/search.html.erb +43 -0
- data/app/views/job_harbor/jobs/show.html.erb +133 -0
- data/app/views/job_harbor/queues/index.html.erb +13 -0
- data/app/views/job_harbor/queues/show.html.erb +88 -0
- data/app/views/job_harbor/recurring_tasks/index.html.erb +36 -0
- data/app/views/job_harbor/recurring_tasks/show.html.erb +97 -0
- data/app/views/job_harbor/workers/index.html.erb +33 -0
- data/app/views/layouts/job_harbor/application.html.erb +1434 -0
- data/config/routes.rb +39 -0
- data/lib/job_harbor/configuration.rb +31 -0
- data/lib/job_harbor/engine.rb +28 -0
- data/lib/job_harbor/version.rb +3 -0
- data/lib/job_harbor.rb +19 -0
- data/lib/tasks/solidqueue_dashboard_tasks.rake +4 -0
- metadata +134 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobHarbor
|
|
4
|
+
class ChartData
|
|
5
|
+
RANGES = {
|
|
6
|
+
"15m" => { duration: 15.minutes, interval: 1.minute, label: "15 min" },
|
|
7
|
+
"1h" => { duration: 1.hour, interval: 5.minutes, label: "1 hour" },
|
|
8
|
+
"6h" => { duration: 6.hours, interval: 30.minutes, label: "6 hours" },
|
|
9
|
+
"24h" => { duration: 24.hours, interval: 1.hour, label: "24 hours" },
|
|
10
|
+
"7d" => { duration: 7.days, interval: 6.hours, label: "7 days" }
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def initialize(range: "24h")
|
|
14
|
+
@range = RANGES[range] || RANGES["24h"]
|
|
15
|
+
@duration = @range[:duration]
|
|
16
|
+
@interval = @range[:interval]
|
|
17
|
+
@cutoff = Time.current - @duration
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.available_ranges
|
|
21
|
+
RANGES.map { |key, config| { value: key, label: config[:label] } }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def series
|
|
25
|
+
@series ||= calculate_series
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def calculate_series
|
|
31
|
+
buckets = generate_time_buckets
|
|
32
|
+
labels = buckets.map { |t| format_label(t) }
|
|
33
|
+
|
|
34
|
+
completed = count_by_bucket(completed_jobs, :finished_at, buckets)
|
|
35
|
+
failed = count_by_bucket(failed_jobs, :created_at, buckets)
|
|
36
|
+
enqueued = count_by_bucket(enqueued_jobs, :created_at, buckets)
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
labels: labels,
|
|
40
|
+
completed: completed,
|
|
41
|
+
failed: failed,
|
|
42
|
+
enqueued: enqueued
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def generate_time_buckets
|
|
47
|
+
buckets = []
|
|
48
|
+
current = @cutoff
|
|
49
|
+
while current <= Time.current
|
|
50
|
+
buckets << current
|
|
51
|
+
current += @interval
|
|
52
|
+
end
|
|
53
|
+
buckets
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def format_label(time)
|
|
57
|
+
if @duration <= 1.hour
|
|
58
|
+
time.strftime("%H:%M")
|
|
59
|
+
elsif @duration <= 24.hours
|
|
60
|
+
time.strftime("%H:%M")
|
|
61
|
+
else
|
|
62
|
+
time.strftime("%b %d")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def count_by_bucket(scope, time_field, buckets)
|
|
67
|
+
# Get counts grouped by time bucket
|
|
68
|
+
counts = scope
|
|
69
|
+
.where("#{time_field} >= ?", @cutoff)
|
|
70
|
+
.group_by_period(@interval, time_field)
|
|
71
|
+
|
|
72
|
+
# Map to bucket array
|
|
73
|
+
buckets.map do |bucket_start|
|
|
74
|
+
bucket_end = bucket_start + @interval
|
|
75
|
+
counts.count { |time, _| time >= bucket_start && time < bucket_end }
|
|
76
|
+
end
|
|
77
|
+
rescue => e
|
|
78
|
+
# Fallback to simple counting if group_by_period isn't available
|
|
79
|
+
buckets.map do |bucket_start|
|
|
80
|
+
bucket_end = bucket_start + @interval
|
|
81
|
+
scope.where("#{time_field} >= ? AND #{time_field} < ?", bucket_start, bucket_end).count
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def completed_jobs
|
|
86
|
+
SolidQueue::Job.where.not(finished_at: nil)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def failed_jobs
|
|
90
|
+
SolidQueue::FailedExecution.all
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def enqueued_jobs
|
|
94
|
+
SolidQueue::Job.all
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def group_by_period(scope, interval, time_field)
|
|
98
|
+
# Simple implementation - group records by time bucket
|
|
99
|
+
scope.pluck(time_field).group_by do |time|
|
|
100
|
+
time.to_i / interval.to_i * interval.to_i
|
|
101
|
+
end.transform_keys { |ts| Time.at(ts) }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobHarbor
|
|
4
|
+
class DashboardStats
|
|
5
|
+
attr_reader :pending_count, :scheduled_count, :in_progress_count,
|
|
6
|
+
:failed_count, :blocked_count, :finished_count,
|
|
7
|
+
:workers_count, :queues_count, :throughput_per_hour,
|
|
8
|
+
:recent_failures
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
calculate_stats
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def total_jobs
|
|
15
|
+
pending_count + scheduled_count + in_progress_count + failed_count + blocked_count
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def calculate_stats
|
|
21
|
+
@pending_count = ready_executions_count
|
|
22
|
+
@scheduled_count = scheduled_executions_count
|
|
23
|
+
@in_progress_count = claimed_executions_count
|
|
24
|
+
@failed_count = failed_executions_count
|
|
25
|
+
@blocked_count = blocked_executions_count
|
|
26
|
+
@finished_count = finished_jobs_count
|
|
27
|
+
@workers_count = active_workers_count
|
|
28
|
+
@queues_count = queues_count_calc
|
|
29
|
+
@throughput_per_hour = calculate_throughput
|
|
30
|
+
@recent_failures = fetch_recent_failures
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def ready_executions_count
|
|
34
|
+
SolidQueue::ReadyExecution.count
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def scheduled_executions_count
|
|
38
|
+
SolidQueue::ScheduledExecution.count
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def claimed_executions_count
|
|
42
|
+
SolidQueue::ClaimedExecution.count
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def failed_executions_count
|
|
46
|
+
SolidQueue::FailedExecution.count
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def blocked_executions_count
|
|
50
|
+
SolidQueue::BlockedExecution.count
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def finished_jobs_count
|
|
54
|
+
# Count jobs finished in the last 24 hours
|
|
55
|
+
if SolidQueue::Job.column_names.include?("finished_at")
|
|
56
|
+
SolidQueue::Job.where("finished_at > ?", 24.hours.ago).count
|
|
57
|
+
else
|
|
58
|
+
0
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def active_workers_count
|
|
63
|
+
SolidQueue::Process.where("last_heartbeat_at > ?", 5.minutes.ago).count
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def queues_count_calc
|
|
67
|
+
SolidQueue::Queue.count
|
|
68
|
+
rescue
|
|
69
|
+
# Fallback: count unique queue names from jobs
|
|
70
|
+
SolidQueue::Job.distinct.count(:queue_name)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def calculate_throughput
|
|
74
|
+
# Jobs finished in the last hour
|
|
75
|
+
if SolidQueue::Job.column_names.include?("finished_at")
|
|
76
|
+
SolidQueue::Job.where("finished_at > ?", 1.hour.ago).count
|
|
77
|
+
else
|
|
78
|
+
0
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def fetch_recent_failures
|
|
83
|
+
SolidQueue::FailedExecution
|
|
84
|
+
.includes(:job)
|
|
85
|
+
.order(created_at: :desc)
|
|
86
|
+
.limit(5)
|
|
87
|
+
.map { |fe| JobPresenter.new(fe.job, failed_execution: fe) }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobHarbor
|
|
4
|
+
class FailureStats
|
|
5
|
+
WINDOW = 24.hours
|
|
6
|
+
LOW_THRESHOLD = 5
|
|
7
|
+
MEDIUM_THRESHOLD = 20
|
|
8
|
+
|
|
9
|
+
def initialize(window: WINDOW)
|
|
10
|
+
@window = window
|
|
11
|
+
@cutoff = Time.current - @window
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def stats
|
|
15
|
+
@stats ||= calculate_stats
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.rate_badge_class(rate)
|
|
19
|
+
case rate
|
|
20
|
+
when 0...LOW_THRESHOLD
|
|
21
|
+
"sqd-rate-low"
|
|
22
|
+
when LOW_THRESHOLD...MEDIUM_THRESHOLD
|
|
23
|
+
"sqd-rate-medium"
|
|
24
|
+
else
|
|
25
|
+
"sqd-rate-high"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def calculate_stats
|
|
32
|
+
# Get all jobs in the window period
|
|
33
|
+
recent_jobs = SolidQueue::Job.where("created_at >= ?", @cutoff)
|
|
34
|
+
|
|
35
|
+
# Group by class_name and calculate totals
|
|
36
|
+
job_counts = recent_jobs.group(:class_name).count
|
|
37
|
+
|
|
38
|
+
# Get failed job counts (jobs that have failed executions)
|
|
39
|
+
failed_job_ids = SolidQueue::FailedExecution
|
|
40
|
+
.joins(:job)
|
|
41
|
+
.where("solid_queue_jobs.created_at >= ?", @cutoff)
|
|
42
|
+
.pluck(:job_id)
|
|
43
|
+
|
|
44
|
+
failed_counts = SolidQueue::Job
|
|
45
|
+
.where(id: failed_job_ids)
|
|
46
|
+
.group(:class_name)
|
|
47
|
+
.count
|
|
48
|
+
|
|
49
|
+
# Build stats array
|
|
50
|
+
job_counts.map do |class_name, total|
|
|
51
|
+
failed = failed_counts[class_name] || 0
|
|
52
|
+
rate = total > 0 ? (failed.to_f / total * 100).round(1) : 0.0
|
|
53
|
+
|
|
54
|
+
{
|
|
55
|
+
class_name: class_name,
|
|
56
|
+
total: total,
|
|
57
|
+
failed: failed,
|
|
58
|
+
rate: rate
|
|
59
|
+
}
|
|
60
|
+
end.sort_by { |s| -s[:rate] }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ostruct"
|
|
4
|
+
|
|
5
|
+
module JobHarbor
|
|
6
|
+
class JobPresenter
|
|
7
|
+
include ActionView::Helpers::DateHelper
|
|
8
|
+
|
|
9
|
+
delegate :id, :created_at, :updated_at, :queue_name, :priority,
|
|
10
|
+
:active_job_id, :arguments, :scheduled_at, :finished_at,
|
|
11
|
+
to: :@job
|
|
12
|
+
|
|
13
|
+
def initialize(job, failed_execution: nil, claimed_execution: nil)
|
|
14
|
+
@job = job
|
|
15
|
+
@failed_execution = failed_execution
|
|
16
|
+
@claimed_execution = claimed_execution
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def class_name
|
|
20
|
+
@job.class_name
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def status
|
|
24
|
+
@status ||= determine_status
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def arguments_preview
|
|
28
|
+
args = parsed_arguments
|
|
29
|
+
return "No arguments" if args.empty?
|
|
30
|
+
|
|
31
|
+
preview = args.to_json
|
|
32
|
+
preview.length > 100 ? "#{preview[0..100]}..." : preview
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def parsed_arguments
|
|
36
|
+
JSON.parse(@job.arguments.to_s)
|
|
37
|
+
rescue JSON::ParserError
|
|
38
|
+
@job.arguments
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def error_message
|
|
42
|
+
@failed_execution&.error&.dig("message")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def error_class
|
|
46
|
+
@failed_execution&.error&.dig("exception_class")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def error_backtrace
|
|
50
|
+
@failed_execution&.error&.dig("backtrace")&.join("\n")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def failed_at
|
|
54
|
+
@failed_execution&.created_at
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def worker_name
|
|
58
|
+
@claimed_execution&.process&.name
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def execution_count
|
|
62
|
+
args = parsed_arguments
|
|
63
|
+
return 1 unless args.is_a?(Hash)
|
|
64
|
+
|
|
65
|
+
(args["executions"] || args[:executions] || 1).to_i
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def running_duration
|
|
69
|
+
return nil unless status == "in_progress"
|
|
70
|
+
|
|
71
|
+
claimed = @claimed_execution || SolidQueue::ClaimedExecution.find_by(job_id: @job.id)
|
|
72
|
+
return nil unless claimed
|
|
73
|
+
|
|
74
|
+
distance_of_time_in_words(claimed.created_at, Time.current)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def relative_created_at
|
|
78
|
+
time_ago_in_words(@job.created_at) + " ago"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def retry_badge
|
|
82
|
+
count = execution_count
|
|
83
|
+
return nil if count <= 1
|
|
84
|
+
|
|
85
|
+
"(x#{count})"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def can_retry?
|
|
89
|
+
status == "failed"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def can_discard?
|
|
93
|
+
%w[pending scheduled failed blocked].include?(status)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def to_param
|
|
97
|
+
id.to_s
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# ActiveModel compatibility
|
|
101
|
+
def model_name
|
|
102
|
+
ActiveModel::Name.new(self.class, nil, "Job")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def persisted?
|
|
106
|
+
true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def determine_status
|
|
112
|
+
return "failed" if has_failed_execution?
|
|
113
|
+
return "in_progress" if has_claimed_execution?
|
|
114
|
+
return "blocked" if has_blocked_execution?
|
|
115
|
+
return "scheduled" if has_scheduled_execution?
|
|
116
|
+
return "pending" if has_ready_execution?
|
|
117
|
+
return "finished" if @job.finished_at.present?
|
|
118
|
+
|
|
119
|
+
"unknown"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def has_failed_execution?
|
|
123
|
+
@failed_execution.present? || SolidQueue::FailedExecution.exists?(job_id: @job.id)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def has_claimed_execution?
|
|
127
|
+
@claimed_execution.present? || SolidQueue::ClaimedExecution.exists?(job_id: @job.id)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def has_blocked_execution?
|
|
131
|
+
SolidQueue::BlockedExecution.exists?(job_id: @job.id)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def has_scheduled_execution?
|
|
135
|
+
SolidQueue::ScheduledExecution.exists?(job_id: @job.id)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def has_ready_execution?
|
|
139
|
+
SolidQueue::ReadyExecution.exists?(job_id: @job.id)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
class << self
|
|
143
|
+
def find(id)
|
|
144
|
+
job = SolidQueue::Job.find(id)
|
|
145
|
+
failed = SolidQueue::FailedExecution.find_by(job_id: id)
|
|
146
|
+
claimed = SolidQueue::ClaimedExecution.find_by(job_id: id)
|
|
147
|
+
new(job, failed_execution: failed, claimed_execution: claimed)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def all_with_status(status = nil, page: 1, per_page: 25, class_name: nil, queue_name: nil)
|
|
151
|
+
jobs = case status&.to_s
|
|
152
|
+
when "pending"
|
|
153
|
+
pending_jobs
|
|
154
|
+
when "scheduled"
|
|
155
|
+
scheduled_jobs
|
|
156
|
+
when "in_progress"
|
|
157
|
+
in_progress_jobs
|
|
158
|
+
when "failed"
|
|
159
|
+
failed_jobs
|
|
160
|
+
when "blocked"
|
|
161
|
+
blocked_jobs
|
|
162
|
+
when "finished"
|
|
163
|
+
finished_jobs
|
|
164
|
+
else
|
|
165
|
+
all_jobs
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
jobs = apply_filters(jobs, class_name: class_name, queue_name: queue_name)
|
|
169
|
+
jobs = jobs.order(created_at: :desc)
|
|
170
|
+
paginate(jobs, page: page, per_page: per_page)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def apply_filters(scope, class_name: nil, queue_name: nil)
|
|
174
|
+
scope = scope.where(class_name: class_name) if class_name.present?
|
|
175
|
+
scope = scope.where(queue_name: queue_name) if queue_name.present?
|
|
176
|
+
scope
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def search(query, page: 1, per_page: 25)
|
|
180
|
+
jobs = SolidQueue::Job
|
|
181
|
+
.where("class_name LIKE ? OR arguments LIKE ? OR CAST(id AS TEXT) LIKE ?",
|
|
182
|
+
"%#{query}%", "%#{query}%", "%#{query}%")
|
|
183
|
+
.order(created_at: :desc)
|
|
184
|
+
|
|
185
|
+
paginate(jobs, page: page, per_page: per_page)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
def pending_jobs
|
|
191
|
+
SolidQueue::Job.joins(
|
|
192
|
+
"INNER JOIN solid_queue_ready_executions ON solid_queue_ready_executions.job_id = solid_queue_jobs.id"
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def scheduled_jobs
|
|
197
|
+
SolidQueue::Job.joins(
|
|
198
|
+
"INNER JOIN solid_queue_scheduled_executions ON solid_queue_scheduled_executions.job_id = solid_queue_jobs.id"
|
|
199
|
+
)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def in_progress_jobs
|
|
203
|
+
SolidQueue::Job.joins(
|
|
204
|
+
"INNER JOIN solid_queue_claimed_executions ON solid_queue_claimed_executions.job_id = solid_queue_jobs.id"
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def failed_jobs
|
|
209
|
+
SolidQueue::Job.joins(
|
|
210
|
+
"INNER JOIN solid_queue_failed_executions ON solid_queue_failed_executions.job_id = solid_queue_jobs.id"
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def blocked_jobs
|
|
215
|
+
SolidQueue::Job.joins(
|
|
216
|
+
"INNER JOIN solid_queue_blocked_executions ON solid_queue_blocked_executions.job_id = solid_queue_jobs.id"
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def finished_jobs
|
|
221
|
+
SolidQueue::Job.where.not(finished_at: nil)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def all_jobs
|
|
225
|
+
SolidQueue::Job.all
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def paginate(scope, page:, per_page:)
|
|
229
|
+
page = [ page.to_i, 1 ].max
|
|
230
|
+
offset = (page - 1) * per_page
|
|
231
|
+
total = scope.count
|
|
232
|
+
|
|
233
|
+
jobs = scope.limit(per_page).offset(offset).map { |job| new(job) }
|
|
234
|
+
pagy = OpenStruct.new(
|
|
235
|
+
page: page,
|
|
236
|
+
pages: (total.to_f / per_page).ceil,
|
|
237
|
+
count: total,
|
|
238
|
+
prev: page > 1 ? page - 1 : nil,
|
|
239
|
+
next: page < (total.to_f / per_page).ceil ? page + 1 : nil
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
[ pagy, jobs ]
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobHarbor
|
|
4
|
+
class QueueStats
|
|
5
|
+
attr_reader :name
|
|
6
|
+
|
|
7
|
+
def initialize(name)
|
|
8
|
+
@name = name
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def pending_count
|
|
12
|
+
SolidQueue::ReadyExecution.where(queue_name: @name).count
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def scheduled_count
|
|
16
|
+
SolidQueue::ScheduledExecution
|
|
17
|
+
.joins(:job)
|
|
18
|
+
.where(solid_queue_jobs: { queue_name: @name })
|
|
19
|
+
.count
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def in_progress_count
|
|
23
|
+
SolidQueue::ClaimedExecution
|
|
24
|
+
.joins(:job)
|
|
25
|
+
.where(solid_queue_jobs: { queue_name: @name })
|
|
26
|
+
.count
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def failed_count
|
|
30
|
+
SolidQueue::FailedExecution
|
|
31
|
+
.joins(:job)
|
|
32
|
+
.where(solid_queue_jobs: { queue_name: @name })
|
|
33
|
+
.count
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def total_count
|
|
37
|
+
pending_count + scheduled_count + in_progress_count + failed_count
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def paused?
|
|
41
|
+
SolidQueue::Pause.exists?(queue_name: @name)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def pause!
|
|
45
|
+
SolidQueue::Pause.create!(queue_name: @name) unless paused?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def resume!
|
|
49
|
+
SolidQueue::Pause.where(queue_name: @name).delete_all
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def to_param
|
|
53
|
+
@name
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
class << self
|
|
57
|
+
def all
|
|
58
|
+
queue_names.map { |name| new(name) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def find(name)
|
|
62
|
+
new(name) if queue_names.include?(name)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def queue_names
|
|
68
|
+
# Get all unique queue names from jobs and ready executions
|
|
69
|
+
job_queues = SolidQueue::Job.distinct.pluck(:queue_name)
|
|
70
|
+
ready_queues = SolidQueue::ReadyExecution.distinct.pluck(:queue_name)
|
|
71
|
+
paused_queues = SolidQueue::Pause.distinct.pluck(:queue_name)
|
|
72
|
+
|
|
73
|
+
(job_queues + ready_queues + paused_queues).uniq.compact.sort
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
<div class="sqd-stats-grid">
|
|
2
|
+
<%= render JobHarbor::StatCardComponent.new(
|
|
3
|
+
label: "Pending",
|
|
4
|
+
value: @stats.pending_count,
|
|
5
|
+
type: :pending,
|
|
6
|
+
link: pending_jobs_path
|
|
7
|
+
) %>
|
|
8
|
+
|
|
9
|
+
<%= render JobHarbor::StatCardComponent.new(
|
|
10
|
+
label: "Scheduled",
|
|
11
|
+
value: @stats.scheduled_count,
|
|
12
|
+
type: :scheduled,
|
|
13
|
+
link: scheduled_jobs_path
|
|
14
|
+
) %>
|
|
15
|
+
|
|
16
|
+
<%= render JobHarbor::StatCardComponent.new(
|
|
17
|
+
label: "In Progress",
|
|
18
|
+
value: @stats.in_progress_count,
|
|
19
|
+
type: :in_progress,
|
|
20
|
+
link: in_progress_jobs_path
|
|
21
|
+
) %>
|
|
22
|
+
|
|
23
|
+
<%= render JobHarbor::StatCardComponent.new(
|
|
24
|
+
label: "Failed",
|
|
25
|
+
value: @stats.failed_count,
|
|
26
|
+
type: :failed,
|
|
27
|
+
link: failed_jobs_path
|
|
28
|
+
) %>
|
|
29
|
+
|
|
30
|
+
<%= render JobHarbor::StatCardComponent.new(
|
|
31
|
+
label: "Blocked",
|
|
32
|
+
value: @stats.blocked_count,
|
|
33
|
+
type: :blocked,
|
|
34
|
+
link: blocked_jobs_path
|
|
35
|
+
) %>
|
|
36
|
+
|
|
37
|
+
<%= render JobHarbor::StatCardComponent.new(
|
|
38
|
+
label: "Finished (24h)",
|
|
39
|
+
value: @stats.finished_count,
|
|
40
|
+
type: :finished,
|
|
41
|
+
link: finished_jobs_path
|
|
42
|
+
) %>
|
|
43
|
+
|
|
44
|
+
<%= render JobHarbor::StatCardComponent.new(
|
|
45
|
+
label: "Active Workers",
|
|
46
|
+
value: @stats.workers_count,
|
|
47
|
+
type: :workers,
|
|
48
|
+
link: workers_path
|
|
49
|
+
) %>
|
|
50
|
+
|
|
51
|
+
<%= render JobHarbor::StatCardComponent.new(
|
|
52
|
+
label: "Queues",
|
|
53
|
+
value: @stats.queues_count,
|
|
54
|
+
type: :queues,
|
|
55
|
+
link: queues_path
|
|
56
|
+
) %>
|
|
57
|
+
|
|
58
|
+
<%= render JobHarbor::StatCardComponent.new(
|
|
59
|
+
label: "Throughput",
|
|
60
|
+
value: "#{@stats.throughput_per_hour}/hr",
|
|
61
|
+
type: :throughput
|
|
62
|
+
) %>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<% if @chart_data.present? %>
|
|
66
|
+
<div style="margin-top: 2rem;">
|
|
67
|
+
<%= render JobHarbor::ChartComponent.new(series: @chart_data, current_range: @chart_range) %>
|
|
68
|
+
</div>
|
|
69
|
+
<% end %>
|
|
70
|
+
|
|
71
|
+
<% if @failure_stats.present? && @failure_stats.any? %>
|
|
72
|
+
<div style="margin-top: 2rem;">
|
|
73
|
+
<%= render JobHarbor::FailureRatesComponent.new(stats: @failure_stats) %>
|
|
74
|
+
</div>
|
|
75
|
+
<% end %>
|
|
76
|
+
|
|
77
|
+
<% if @stats.recent_failures.any? %>
|
|
78
|
+
<div class="sqd-card" style="margin-top: 2rem;">
|
|
79
|
+
<div class="sqd-card-header">
|
|
80
|
+
<h2 class="sqd-card-title">Recent Failures</h2>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="sqd-table-container">
|
|
83
|
+
<table class="sqd-table">
|
|
84
|
+
<thead>
|
|
85
|
+
<tr>
|
|
86
|
+
<th>ID</th>
|
|
87
|
+
<th>Job Class</th>
|
|
88
|
+
<th>Error</th>
|
|
89
|
+
<th>Failed At</th>
|
|
90
|
+
<th>Actions</th>
|
|
91
|
+
</tr>
|
|
92
|
+
</thead>
|
|
93
|
+
<tbody>
|
|
94
|
+
<% @stats.recent_failures.each do |job| %>
|
|
95
|
+
<tr>
|
|
96
|
+
<td><%= link_to job.id, job_path(job), class: "sqd-table-link" %></td>
|
|
97
|
+
<td><code class="sqd-code"><%= job.class_name %></code></td>
|
|
98
|
+
<td><%= truncate(job.error_message || "Unknown error", length: 60) %></td>
|
|
99
|
+
<td><%= job.failed_at&.strftime("%b %d, %H:%M:%S") || "—" %></td>
|
|
100
|
+
<td class="sqd-actions">
|
|
101
|
+
<%= button_to "Retry", retry_job_path(job), method: :post, class: "sqd-btn sqd-btn-sm sqd-btn-secondary" %>
|
|
102
|
+
</td>
|
|
103
|
+
</tr>
|
|
104
|
+
<% end %>
|
|
105
|
+
</tbody>
|
|
106
|
+
</table>
|
|
107
|
+
</div>
|
|
108
|
+
<div style="padding: 1rem; text-align: center;">
|
|
109
|
+
<%= link_to "View All Failed Jobs →", failed_jobs_path, class: "sqd-table-link" %>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
<% end %>
|