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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +98 -0
  3. data/Rakefile +6 -0
  4. data/app/assets/stylesheets/solidqueue_dashboard/application.css +1 -0
  5. data/app/components/job_harbor/application_component.rb +13 -0
  6. data/app/components/job_harbor/badge_component.rb +26 -0
  7. data/app/components/job_harbor/chart_component.rb +82 -0
  8. data/app/components/job_harbor/empty_state_component.rb +41 -0
  9. data/app/components/job_harbor/failure_rates_component.rb +84 -0
  10. data/app/components/job_harbor/job_filters_component.rb +92 -0
  11. data/app/components/job_harbor/job_row_component.rb +106 -0
  12. data/app/components/job_harbor/nav_link_component.rb +50 -0
  13. data/app/components/job_harbor/pagination_component.rb +72 -0
  14. data/app/components/job_harbor/per_page_selector_component.rb +40 -0
  15. data/app/components/job_harbor/queue_card_component.rb +59 -0
  16. data/app/components/job_harbor/refresh_selector_component.rb +57 -0
  17. data/app/components/job_harbor/stat_card_component.rb +77 -0
  18. data/app/components/job_harbor/theme_toggle_component.rb +48 -0
  19. data/app/components/job_harbor/worker_card_component.rb +86 -0
  20. data/app/controllers/job_harbor/application_controller.rb +44 -0
  21. data/app/controllers/job_harbor/dashboard_controller.rb +17 -0
  22. data/app/controllers/job_harbor/jobs_controller.rb +151 -0
  23. data/app/controllers/job_harbor/queues_controller.rb +40 -0
  24. data/app/controllers/job_harbor/recurring_tasks_controller.rb +35 -0
  25. data/app/controllers/job_harbor/workers_controller.rb +12 -0
  26. data/app/helpers/job_harbor/application_helper.rb +4 -0
  27. data/app/models/job_harbor/chart_data.rb +104 -0
  28. data/app/models/job_harbor/dashboard_stats.rb +90 -0
  29. data/app/models/job_harbor/failure_stats.rb +63 -0
  30. data/app/models/job_harbor/job_presenter.rb +246 -0
  31. data/app/models/job_harbor/queue_stats.rb +77 -0
  32. data/app/views/job_harbor/dashboard/index.html.erb +112 -0
  33. data/app/views/job_harbor/jobs/index.html.erb +100 -0
  34. data/app/views/job_harbor/jobs/search.html.erb +43 -0
  35. data/app/views/job_harbor/jobs/show.html.erb +133 -0
  36. data/app/views/job_harbor/queues/index.html.erb +13 -0
  37. data/app/views/job_harbor/queues/show.html.erb +88 -0
  38. data/app/views/job_harbor/recurring_tasks/index.html.erb +36 -0
  39. data/app/views/job_harbor/recurring_tasks/show.html.erb +97 -0
  40. data/app/views/job_harbor/workers/index.html.erb +33 -0
  41. data/app/views/layouts/job_harbor/application.html.erb +1434 -0
  42. data/config/routes.rb +39 -0
  43. data/lib/job_harbor/configuration.rb +31 -0
  44. data/lib/job_harbor/engine.rb +28 -0
  45. data/lib/job_harbor/version.rb +3 -0
  46. data/lib/job_harbor.rb +19 -0
  47. data/lib/tasks/solidqueue_dashboard_tasks.rake +4 -0
  48. 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 %>