active_job_dash 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.
@@ -0,0 +1,207 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>ActiveJob Dashboard</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="color-scheme" content="light dark">
7
+ <style>
8
+ :root {
9
+ color-scheme: light dark;
10
+ /* Light mode */
11
+ --bg: #f8fafc;
12
+ --bg-card: #ffffff;
13
+ --text: #1e293b;
14
+ --text-muted: #64748b;
15
+ --border: #e2e8f0;
16
+ --success: #16a34a;
17
+ --error: #dc2626;
18
+ --warning: #d97706;
19
+ --primary: #2563eb;
20
+ --primary-hover: #1d4ed8;
21
+ --hover-bg: rgba(0,0,0,0.04);
22
+ }
23
+ @media (prefers-color-scheme: dark) {
24
+ :root {
25
+ --bg: #030712;
26
+ --bg-card: #111827;
27
+ --text: #f9fafb;
28
+ --text-muted: #9ca3af;
29
+ --border: #1f2937;
30
+ --success: #22c55e;
31
+ --error: #ef4444;
32
+ --warning: #f59e0b;
33
+ --primary: #3b82f6;
34
+ --primary-hover: #60a5fa;
35
+ --hover-bg: rgba(255,255,255,0.05);
36
+ }
37
+ }
38
+ * { box-sizing: border-box; margin: 0; padding: 0; }
39
+ body {
40
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
41
+ background: var(--bg);
42
+ color: var(--text);
43
+ font-size: 14px;
44
+ line-height: 1.5;
45
+ }
46
+ a { color: var(--primary); }
47
+ a:hover { color: var(--primary-hover); }
48
+ .container { max-width: 1400px; margin: 0 auto; padding: 20px; }
49
+ header {
50
+ display: flex;
51
+ justify-content: space-between;
52
+ align-items: center;
53
+ margin-bottom: 24px;
54
+ padding-bottom: 16px;
55
+ border-bottom: 1px solid var(--border);
56
+ }
57
+ h1 { font-size: 20px; font-weight: 600; }
58
+ h1 a { color: inherit; text-decoration: none; }
59
+ h1 a:hover { opacity: 0.8; }
60
+ h2 { font-size: 14px; font-weight: 600; margin-bottom: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
61
+ .stats-grid {
62
+ display: grid;
63
+ grid-template-columns: repeat(5, 1fr);
64
+ gap: 12px;
65
+ margin-bottom: 24px;
66
+ }
67
+ @media (max-width: 900px) {
68
+ .stats-grid { grid-template-columns: repeat(3, 1fr); }
69
+ }
70
+ @media (max-width: 600px) {
71
+ .stats-grid { grid-template-columns: repeat(2, 1fr); }
72
+ }
73
+ .stat-card {
74
+ background: var(--bg-card);
75
+ border: 1px solid var(--border);
76
+ border-radius: 8px;
77
+ padding: 16px 20px;
78
+ text-align: center;
79
+ }
80
+ .stat-value {
81
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
82
+ font-size: 28px;
83
+ font-weight: 600;
84
+ line-height: 1.2;
85
+ letter-spacing: -0.02em;
86
+ }
87
+ .stat-label {
88
+ font-size: 11px;
89
+ color: var(--text-muted);
90
+ text-transform: uppercase;
91
+ letter-spacing: 0.5px;
92
+ margin-top: 4px;
93
+ }
94
+ .stat-card.success .stat-value { color: var(--success); }
95
+ .stat-card.error .stat-value { color: var(--error); }
96
+ table { width: 100%; border-collapse: collapse; }
97
+ th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border); }
98
+ th { font-size: 11px; text-transform: uppercase; color: var(--text-muted); font-weight: 600; }
99
+ tr:hover td { background: var(--hover-bg); }
100
+ .badge {
101
+ display: inline-block;
102
+ padding: 2px 8px;
103
+ border-radius: 4px;
104
+ font-size: 11px;
105
+ font-weight: 600;
106
+ text-transform: uppercase;
107
+ }
108
+ .badge.success { background: rgba(22,163,74,0.15); color: var(--success); }
109
+ .badge.failed { background: rgba(220,38,38,0.15); color: var(--error); }
110
+ .badge.queued { background: rgba(107,114,128,0.15); color: var(--text-muted); }
111
+ .badge.running { background: rgba(37,99,235,0.15); color: var(--primary); }
112
+ .badge.retried { background: rgba(217,119,6,0.15); color: var(--warning); }
113
+ .card {
114
+ background: var(--bg-card);
115
+ border: 1px solid var(--border);
116
+ border-radius: 8px;
117
+ padding: 16px;
118
+ margin-bottom: 24px;
119
+ }
120
+ .btn {
121
+ display: inline-block;
122
+ padding: 6px 12px;
123
+ border-radius: 6px;
124
+ font-size: 12px;
125
+ font-weight: 500;
126
+ text-decoration: none;
127
+ cursor: pointer;
128
+ border: 1px solid var(--border);
129
+ background: var(--bg-card);
130
+ color: var(--text);
131
+ }
132
+ .btn:hover { background: var(--hover-bg); border-color: var(--text-muted); }
133
+ .btn-primary {
134
+ background: var(--primary);
135
+ border-color: var(--primary);
136
+ color: #fff;
137
+ }
138
+ .btn-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
139
+ .btn-danger { background: var(--error); border-color: var(--error); color: #fff; }
140
+ .flash {
141
+ padding: 12px 16px;
142
+ border-radius: 6px;
143
+ margin-bottom: 16px;
144
+ }
145
+ .flash.notice { background: rgba(22,163,74,0.15); border: 1px solid var(--success); color: var(--success); }
146
+ .flash.alert { background: rgba(220,38,38,0.15); border: 1px solid var(--error); color: var(--error); }
147
+ .period-select { display: flex; gap: 8px; }
148
+ .period-select a {
149
+ padding: 6px 12px;
150
+ border-radius: 6px;
151
+ color: var(--text-muted);
152
+ text-decoration: none;
153
+ border: 1px solid var(--border);
154
+ }
155
+ .period-select a.active, .period-select a:hover {
156
+ background: var(--primary);
157
+ border-color: var(--primary);
158
+ color: #fff;
159
+ }
160
+ .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
161
+ @media (max-width: 768px) { .grid-2 { grid-template-columns: 1fr; } }
162
+ .mono { font-family: inherit; }
163
+ .text-muted { color: var(--text-muted); }
164
+ .text-success { color: var(--success); }
165
+ .text-error { color: var(--error); }
166
+ .text-sm { font-size: 12px; }
167
+ .mb-2 { margin-bottom: 8px; }
168
+ pre {
169
+ background: var(--bg);
170
+ border: 1px solid var(--border);
171
+ padding: 12px;
172
+ border-radius: 6px;
173
+ overflow-x: auto;
174
+ font-size: 13px;
175
+ }
176
+ /* Simple form styling for retry buttons */
177
+ form.inline { display: inline; }
178
+ form.inline button {
179
+ font-family: inherit;
180
+ font-size: 12px;
181
+ }
182
+ </style>
183
+ </head>
184
+ <body>
185
+ <div class="container">
186
+ <header>
187
+ <h1><a href="<%= active_job_dash.dashboard_path %>" style="color: inherit; text-decoration: none;">ActiveJob Dashboard</a></h1>
188
+ <div class="period-select">
189
+ <% [1, 6, 24, 168].each do |hours| %>
190
+ <%= link_to hours == 1 ? "1h" : hours == 6 ? "6h" : hours == 24 ? "24h" : "7d",
191
+ dashboard_path(period: hours),
192
+ class: (@period == hours.hours ? "active" : "") %>
193
+ <% end %>
194
+ </div>
195
+ </header>
196
+
197
+ <% if flash[:notice] %>
198
+ <div class="flash notice"><%= flash[:notice] %></div>
199
+ <% end %>
200
+ <% if flash[:alert] %>
201
+ <div class="flash alert"><%= flash[:alert] %></div>
202
+ <% end %>
203
+
204
+ <%= yield %>
205
+ </div>
206
+ </body>
207
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,10 @@
1
+ ActiveJobDash::Engine.routes.draw do
2
+ root to: "dashboard#index"
3
+
4
+ get "/", to: "dashboard#index", as: :dashboard
5
+ get "/jobs", to: "dashboard#jobs", as: :jobs
6
+ get "/executions/:id", to: "dashboard#show", as: :execution
7
+ post "/executions/:id/retry", to: "dashboard#retry", as: :retry
8
+ post "/retry_all", to: "dashboard#retry_all", as: :retry_all
9
+ post "/purge", to: "dashboard#purge", as: :purge
10
+ end
@@ -0,0 +1,44 @@
1
+ require "rails/engine"
2
+
3
+ module ActiveJobDash
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace ActiveJobDash
6
+
7
+ config.active_job_dash = ActiveSupport::OrderedOptions.new
8
+
9
+ initializer "active_job_dash.config", before: :run_prepare_callbacks do |app|
10
+ app.config.active_job_dash.each do |key, value|
11
+ ActiveJobDash.public_send("#{key}=", value)
12
+ end
13
+ end
14
+
15
+ initializer "active_job_dash.app_executor", before: :run_prepare_callbacks do |app|
16
+ ActiveJobDash.on_thread_error = ->(exception) { Rails.error.report(exception, handled: false) }
17
+ end
18
+
19
+ initializer "active_job_dash.logger", before: :run_prepare_callbacks do
20
+ ActiveSupport.on_load(:active_job_dash) do
21
+ self.logger = Rails.logger
22
+ end
23
+
24
+ ActiveJobDash::LogSubscriber.attach_to :active_job_dash
25
+ end
26
+
27
+ initializer "active_job_dash.instrumentation", after: :load_config_initializers do
28
+ ActiveSupport.on_load(:active_job) do
29
+ unless ActiveJobDash.instance_variable_get(:@subscribed)
30
+ ActiveJobDash::Instrumentation.subscribe!
31
+ ActiveJobDash.instance_variable_set(:@subscribed, true)
32
+ end
33
+ end
34
+ end
35
+
36
+ rake_tasks do
37
+ load File.expand_path("tasks.rb", __dir__)
38
+ end
39
+
40
+ generators do
41
+ require_relative "generators/active_job_dash/install_generator"
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,49 @@
1
+ require "rails/generators"
2
+
3
+ module ActiveJobDash
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ desc "Install ActiveJobDash configuration and migration"
9
+
10
+ def copy_migration
11
+ migration_file = Dir.glob("db/migrate/*_create_active_job_dash_executions.rb").first
12
+ if migration_file
13
+ say_status :skip, "Migration already exists", :yellow
14
+ else
15
+ timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
16
+ copy_file "create_active_job_dash_executions.rb",
17
+ "db/migrate/#{timestamp}_create_active_job_dash_executions.rb"
18
+ end
19
+ end
20
+
21
+ def create_initializer
22
+ template "initializer.rb.erb", "config/initializers/active_job_dash.rb"
23
+ end
24
+
25
+ def mount_engine
26
+ route_line = "mount ActiveJobDash::Engine, at: '/admin/jobs'"
27
+ routes_file = "config/routes.rb"
28
+
29
+ if File.read(routes_file).include?("ActiveJobDash::Engine")
30
+ say_status :skip, "Engine already mounted", :yellow
31
+ else
32
+ route route_line
33
+ end
34
+ end
35
+
36
+ def show_instructions
37
+ say ""
38
+ say "ActiveJobDash installed!", :green
39
+ say ""
40
+ say "Next steps:"
41
+ say " 1. Run: rails db:migrate"
42
+ say " 2. Visit: /admin/jobs"
43
+ say ""
44
+ say "Configure in config/initializers/active_job_dash.rb"
45
+ say ""
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,29 @@
1
+ class CreateActiveJobDashExecutions < ActiveRecord::Migration[7.2]
2
+ def change
3
+ create_table :active_job_dash_executions do |t|
4
+ t.string :job_class, null: false
5
+ t.string :job_id, null: false
6
+ t.string :queue_name
7
+ t.string :event_type, null: false
8
+ t.string :status
9
+ t.integer :duration_ms
10
+ t.integer :executions, default: 0
11
+ t.string :region
12
+ t.datetime :started_at
13
+ t.datetime :finished_at
14
+ t.datetime :scheduled_at
15
+ t.datetime :retried_at
16
+ t.string :error_class
17
+ t.text :error_message
18
+ t.jsonb :arguments
19
+
20
+ t.timestamps
21
+ end
22
+
23
+ add_index :active_job_dash_executions, :job_id
24
+ add_index :active_job_dash_executions, %i[job_class created_at]
25
+ add_index :active_job_dash_executions, %i[status created_at]
26
+ add_index :active_job_dash_executions, %i[event_type created_at]
27
+ add_index :active_job_dash_executions, :created_at
28
+ end
29
+ end
@@ -0,0 +1,6 @@
1
+ ActiveJobDash.enabled = true
2
+ ActiveJobDash.store_arguments = true
3
+ ActiveJobDash.retention_days = 30
4
+ ActiveJobDash.region = ENV.fetch("REGION", nil)
5
+ ActiveJobDash.async_recording = false
6
+ ActiveJobDash.excluded_jobs = []
@@ -0,0 +1,214 @@
1
+ module ActiveJobDash
2
+ class Instrumentation
3
+ EVENTS = %w[
4
+ perform.active_job
5
+ enqueue.active_job
6
+ enqueue_at.active_job
7
+ retry_stopped.active_job
8
+ discard.active_job
9
+ ].freeze
10
+
11
+ class << self
12
+ def subscribe!
13
+ return unless ActiveJobDash.enabled?
14
+ return if @subscribed
15
+
16
+ subscribe_to_enqueue if ActiveJobDash.track_enqueue?
17
+ subscribe_to_perform_start
18
+ subscribe_to_perform
19
+ subscribe_to_retry_stopped
20
+ subscribe_to_discard
21
+ @subscribed = true
22
+ end
23
+
24
+ def subscribed?
25
+ @subscribed
26
+ end
27
+
28
+ private
29
+
30
+ def subscribe_to_enqueue
31
+ ActiveSupport::Notifications.subscribe("enqueue.active_job") do |event|
32
+ record_enqueue(event)
33
+ end
34
+ end
35
+
36
+ def subscribe_to_perform_start
37
+ ActiveSupport::Notifications.subscribe("perform_start.active_job") do |event|
38
+ update_to_running(event)
39
+ end
40
+ end
41
+
42
+ def subscribe_to_perform
43
+ ActiveSupport::Notifications.subscribe("perform.active_job") do |event|
44
+ record_execution(event)
45
+ end
46
+ end
47
+
48
+ def subscribe_to_retry_stopped
49
+ ActiveSupport::Notifications.subscribe("retry_stopped.active_job") do |event|
50
+ record_retry_stopped(event)
51
+ end
52
+ end
53
+
54
+ def subscribe_to_discard
55
+ ActiveSupport::Notifications.subscribe("discard.active_job") do |event|
56
+ record_discard(event)
57
+ end
58
+ end
59
+
60
+ def record_enqueue(event)
61
+ return if excluded?(event.payload[:job])
62
+
63
+ job = event.payload[:job]
64
+ exception = event.payload[:exception_object]
65
+
66
+ attributes = {
67
+ job_class: job.class.name,
68
+ job_id: job.job_id,
69
+ queue_name: job.queue_name,
70
+ event_type: "perform",
71
+ status: exception ? "enqueue_failed" : "queued",
72
+ scheduled_at: job.scheduled_at,
73
+ region: ActiveJobDash.region,
74
+ error_class: exception&.class&.name,
75
+ error_message: exception&.message&.truncate(1000)
76
+ }
77
+
78
+ attributes[:arguments] = serialize_arguments(job.arguments) if ActiveJobDash.store_arguments?
79
+
80
+ record(attributes)
81
+ end
82
+
83
+ def update_to_running(event)
84
+ return if excluded?(event.payload[:job])
85
+
86
+ job = event.payload[:job]
87
+ execution = JobExecution.find_by(job_id: job.job_id, status: "queued")
88
+
89
+ if execution
90
+ execution.update(status: "running", started_at: Time.current)
91
+ else
92
+ JobExecution.create!(
93
+ job_class: job.class.name,
94
+ job_id: job.job_id,
95
+ queue_name: job.queue_name,
96
+ event_type: "perform",
97
+ status: "running",
98
+ started_at: Time.current,
99
+ region: ActiveJobDash.region,
100
+ arguments: ActiveJobDash.store_arguments? ? serialize_arguments(job.arguments) : nil
101
+ )
102
+ end
103
+ rescue StandardError => e
104
+ Rails.error.report(e, handled: true, context: { job_id: job.job_id, phase: "perform_start" })
105
+ end
106
+
107
+ def record_execution(event)
108
+ return if excluded?(event.payload[:job])
109
+
110
+ job = event.payload[:job]
111
+ exception = event.payload[:exception_object]
112
+ finished_at = Time.current
113
+
114
+ execution = JobExecution.find_by(job_id: job.job_id, status: %w[queued running])
115
+
116
+ if execution
117
+ execution.update!(
118
+ status: exception ? "failed" : "success",
119
+ duration_ms: event.duration.to_i,
120
+ finished_at: finished_at,
121
+ started_at: execution.started_at || (finished_at - (event.duration / 1000.0)),
122
+ executions: job.executions,
123
+ error_class: exception&.class&.name,
124
+ error_message: exception&.message&.truncate(1000)
125
+ )
126
+ else
127
+ attributes = {
128
+ job_class: job.class.name,
129
+ job_id: job.job_id,
130
+ queue_name: job.queue_name,
131
+ event_type: "perform",
132
+ status: exception ? "failed" : "success",
133
+ duration_ms: event.duration.to_i,
134
+ started_at: finished_at - (event.duration / 1000.0),
135
+ finished_at: finished_at,
136
+ executions: job.executions,
137
+ region: ActiveJobDash.region,
138
+ error_class: exception&.class&.name,
139
+ error_message: exception&.message&.truncate(1000)
140
+ }
141
+
142
+ attributes[:arguments] = serialize_arguments(job.arguments) if ActiveJobDash.store_arguments?
143
+
144
+ record(attributes)
145
+ end
146
+
147
+ ActiveJobDash.instrument(:record_execution, job_class: job.class.name, job_id: job.job_id, status: exception ? "failed" : "success")
148
+ rescue StandardError => e
149
+ Rails.error.report(e, handled: true, context: { job_id: job.job_id, phase: "perform" })
150
+ ActiveJobDash.instrument(:record_error, error: e.message, job_id: job.job_id)
151
+ end
152
+
153
+ def record_retry_stopped(event)
154
+ return if excluded?(event.payload[:job])
155
+
156
+ job = event.payload[:job]
157
+ error = event.payload[:error]
158
+
159
+ record(
160
+ job_class: job.class.name,
161
+ job_id: job.job_id,
162
+ queue_name: job.queue_name,
163
+ event_type: "retry_stopped",
164
+ status: "exhausted",
165
+ executions: job.executions,
166
+ region: ActiveJobDash.region,
167
+ error_class: error&.class&.name,
168
+ error_message: error&.message&.truncate(1000),
169
+ arguments: ActiveJobDash.store_arguments? ? serialize_arguments(job.arguments) : nil
170
+ )
171
+ end
172
+
173
+ def record_discard(event)
174
+ return if excluded?(event.payload[:job])
175
+
176
+ job = event.payload[:job]
177
+ error = event.payload[:error]
178
+
179
+ record(
180
+ job_class: job.class.name,
181
+ job_id: job.job_id,
182
+ queue_name: job.queue_name,
183
+ event_type: "discard",
184
+ status: "discarded",
185
+ region: ActiveJobDash.region,
186
+ error_class: error&.class&.name,
187
+ error_message: error&.message&.truncate(1000),
188
+ arguments: ActiveJobDash.store_arguments? ? serialize_arguments(job.arguments) : nil
189
+ )
190
+ end
191
+
192
+ def excluded?(job)
193
+ ActiveJobDash.excluded_jobs.include?(job.class.name)
194
+ end
195
+
196
+ def serialize_arguments(arguments)
197
+ arguments.to_json
198
+ rescue StandardError
199
+ nil
200
+ end
201
+
202
+ def record(attributes)
203
+ if ActiveJobDash.async_recording?
204
+ RecordExecutionJob.perform_later(attributes)
205
+ else
206
+ JobExecution.create!(attributes)
207
+ end
208
+ rescue StandardError => e
209
+ Rails.error.report(e, handled: true, context: { job_class: attributes[:job_class], job_id: attributes[:job_id] })
210
+ ActiveJobDash.instrument(:record_error, error: e.message, job_class: attributes[:job_class])
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,24 @@
1
+ module ActiveJobDash
2
+ class LogSubscriber < ActiveSupport::LogSubscriber
3
+ def record_execution(event)
4
+ info do
5
+ job_class = event.payload[:job_class]
6
+ job_id = event.payload[:job_id]
7
+ status = event.payload[:status]
8
+ "Recorded execution for #{job_class} (#{job_id}): #{status}"
9
+ end
10
+ end
11
+
12
+ def record_error(event)
13
+ error do
14
+ "Failed to record job execution: #{event.payload[:error]}"
15
+ end
16
+ end
17
+
18
+ def purge(event)
19
+ info do
20
+ "Purged #{event.payload[:count]} old job execution records"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,103 @@
1
+ module ActiveJobDash
2
+ class Stats
3
+ attr_reader :period
4
+
5
+ def initialize(period: 1.hour)
6
+ @period = period
7
+ end
8
+
9
+ def summary
10
+ performs = scope.where(event_type: "perform")
11
+ {
12
+ total: performs.count,
13
+ queued: performs.where(status: "queued").count,
14
+ running: performs.where(status: "running").count,
15
+ success: performs.where(status: "success").count,
16
+ failed: performs.where(status: "failed").count,
17
+ avg_duration_ms: performs.where(status: "success").average(:duration_ms)&.round(2),
18
+ failure_rate: failure_rate
19
+ }
20
+ end
21
+
22
+ def by_job_class
23
+ scope
24
+ .where(event_type: "perform")
25
+ .group(:job_class)
26
+ .select(
27
+ "job_class",
28
+ "COUNT(*) as total",
29
+ "SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_count",
30
+ "SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_count",
31
+ "AVG(duration_ms) as avg_duration_ms",
32
+ "MAX(duration_ms) as max_duration_ms"
33
+ )
34
+ .order("total DESC")
35
+ end
36
+
37
+ def by_queue
38
+ scope
39
+ .where(event_type: "perform")
40
+ .group(:queue_name)
41
+ .select(
42
+ "queue_name",
43
+ "COUNT(*) as total",
44
+ "SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_count",
45
+ "SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_count"
46
+ )
47
+ .order("total DESC")
48
+ end
49
+
50
+ def recent_failures(limit: 20)
51
+ JobExecution
52
+ .where(status: "failed")
53
+ .where(created_at: start_time..)
54
+ .order(created_at: :desc)
55
+ .limit(limit)
56
+ end
57
+
58
+ def timeline(interval: :hour)
59
+ group_clause = case interval
60
+ when :minute then "date_trunc('minute', created_at)"
61
+ when :hour then "date_trunc('hour', created_at)"
62
+ when :day then "date_trunc('day', created_at)"
63
+ else "date_trunc('hour', created_at)"
64
+ end
65
+
66
+ scope
67
+ .where(event_type: "perform")
68
+ .group(Arel.sql(group_clause))
69
+ .select(
70
+ Arel.sql("#{group_clause} as period"),
71
+ "COUNT(*) as total",
72
+ "SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_count",
73
+ "SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_count"
74
+ )
75
+ .order(Arel.sql(group_clause))
76
+ end
77
+
78
+ def slowest_jobs(limit: 10)
79
+ scope
80
+ .where(event_type: "perform")
81
+ .order(duration_ms: :desc)
82
+ .limit(limit)
83
+ end
84
+
85
+ private
86
+
87
+ def scope
88
+ JobExecution.where(created_at: start_time..)
89
+ end
90
+
91
+ def start_time
92
+ period.ago
93
+ end
94
+
95
+ def failure_rate
96
+ total = scope.where(event_type: "perform").count
97
+ return 0.0 if total.zero?
98
+
99
+ failed = scope.where(event_type: "perform", status: "failed").count
100
+ (failed.to_f / total * 100).round(2)
101
+ end
102
+ end
103
+ end