anomonitor 0.6.1

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 (65) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +90 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +275 -0
  5. data/RELEASE.md +32 -0
  6. data/Rakefile +13 -0
  7. data/SECURITY.md +24 -0
  8. data/app/assets/config/anomonitor_manifest.js +1 -0
  9. data/app/assets/stylesheets/anomonitor/application.css +370 -0
  10. data/app/controllers/anomonitor/anomalies_controller.rb +44 -0
  11. data/app/controllers/anomonitor/application_controller.rb +21 -0
  12. data/app/controllers/anomonitor/dashboard_controller.rb +69 -0
  13. data/app/controllers/anomonitor/jobs_controller.rb +61 -0
  14. data/app/controllers/anomonitor/metrics_controller.rb +18 -0
  15. data/app/controllers/anomonitor/mutes_controller.rb +41 -0
  16. data/app/helpers/anomonitor/application_helper.rb +71 -0
  17. data/app/models/anomonitor/anomaly.rb +105 -0
  18. data/app/models/anomonitor/application_record.rb +5 -0
  19. data/app/models/anomonitor/metric_sample.rb +25 -0
  20. data/app/models/anomonitor/mute.rb +56 -0
  21. data/app/views/anomonitor/anomalies/index.html.erb +54 -0
  22. data/app/views/anomonitor/anomalies/show.html.erb +54 -0
  23. data/app/views/anomonitor/dashboard/show.html.erb +124 -0
  24. data/app/views/anomonitor/jobs/index.html.erb +135 -0
  25. data/app/views/anomonitor/metrics/index.html.erb +33 -0
  26. data/app/views/anomonitor/mutes/index.html.erb +56 -0
  27. data/app/views/layouts/anomonitor/application.html.erb +39 -0
  28. data/config/routes.rb +15 -0
  29. data/db/migrate/20260807100000_create_anomonitor_metric_samples.rb +17 -0
  30. data/db/migrate/20260807100001_create_anomonitor_anomalies.rb +23 -0
  31. data/db/migrate/20260808100000_add_resolved_at_to_anomonitor_anomalies.rb +6 -0
  32. data/db/migrate/20260808120000_create_anomonitor_mutes.rb +16 -0
  33. data/lib/anomonitor/collectors/base.rb +25 -0
  34. data/lib/anomonitor/collectors/delayed_job.rb +55 -0
  35. data/lib/anomonitor/collectors/schema_drift.rb +144 -0
  36. data/lib/anomonitor/collectors/sidekiq.rb +47 -0
  37. data/lib/anomonitor/collectors/solid_queue.rb +57 -0
  38. data/lib/anomonitor/collectors/table.rb +116 -0
  39. data/lib/anomonitor/configuration.rb +184 -0
  40. data/lib/anomonitor/configuration_validator.rb +40 -0
  41. data/lib/anomonitor/detector.rb +195 -0
  42. data/lib/anomonitor/digester.rb +79 -0
  43. data/lib/anomonitor/engine.rb +35 -0
  44. data/lib/anomonitor/jobs/browser.rb +89 -0
  45. data/lib/anomonitor/jobs/browsers/delayed_job.rb +143 -0
  46. data/lib/anomonitor/jobs/browsers/sidekiq.rb +164 -0
  47. data/lib/anomonitor/jobs/browsers/solid_queue.rb +149 -0
  48. data/lib/anomonitor/jobs/browsers/table.rb +206 -0
  49. data/lib/anomonitor/jobs/row.rb +30 -0
  50. data/lib/anomonitor/metric_point.rb +42 -0
  51. data/lib/anomonitor/metrics_export.rb +55 -0
  52. data/lib/anomonitor/notifiers/callable.rb +44 -0
  53. data/lib/anomonitor/notifiers/composite.rb +33 -0
  54. data/lib/anomonitor/notifiers/rate_limited.rb +44 -0
  55. data/lib/anomonitor/notifiers/webhook.rb +108 -0
  56. data/lib/anomonitor/notifiers.rb +59 -0
  57. data/lib/anomonitor/poll_lock.rb +67 -0
  58. data/lib/anomonitor/poller.rb +164 -0
  59. data/lib/anomonitor/tenancy.rb +46 -0
  60. data/lib/anomonitor/version.rb +3 -0
  61. data/lib/anomonitor.rb +52 -0
  62. data/lib/generators/anomonitor/install/install_generator.rb +27 -0
  63. data/lib/generators/anomonitor/install/templates/anomonitor.rb +87 -0
  64. data/lib/tasks/anomonitor_tasks.rake +27 -0
  65. metadata +126 -0
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anomonitor
4
+ module Jobs
5
+ module Browsers
6
+ class DelayedJob
7
+ PER_TENANT = 50
8
+
9
+ def source_key
10
+ "delayed_job"
11
+ end
12
+
13
+ def fetch(filters)
14
+ unless defined?(::Delayed::Job)
15
+ Anomonitor.logger.warn("[Anomonitor] Jobs browser skipped delayed_job: Delayed::Job not loaded")
16
+ return []
17
+ end
18
+
19
+ if Tenancy.multi_tenant?
20
+ fetch_per_tenant(filters)
21
+ else
22
+ fetch_scope(::Delayed::Job, filters, nil)
23
+ end
24
+ rescue StandardError => e
25
+ Anomonitor.logger.warn("[Anomonitor] Delayed Job browser error: #{e.message}")
26
+ []
27
+ end
28
+
29
+ private
30
+
31
+ def fetch_per_tenant(filters)
32
+ rows = []
33
+ tenants = Tenancy.tenant_names
34
+ tenants = tenants.select { |t| t == filters[:tenant] } if filters[:tenant]
35
+ tenants.each do |tenant|
36
+ Tenancy.switch(tenant) do
37
+ rows.concat(fetch_scope(::Delayed::Job, filters, tenant))
38
+ end
39
+ rescue StandardError => e
40
+ Anomonitor.logger.warn("[Anomonitor] Delayed Job browser (#{tenant}) error: #{e.message}")
41
+ end
42
+ rows
43
+ end
44
+
45
+ def fetch_scope(klass, filters, tenant)
46
+ scope = apply_status(klass.all, filters[:status])
47
+ if filters[:queue] && klass.column_names.include?("queue")
48
+ scope = scope.where(queue: filters[:queue])
49
+ end
50
+ scope = scope.order(Arel.sql("COALESCE(failed_at, run_at, created_at) DESC"))
51
+ scope.limit(PER_TENANT).map { |job| row_for(job, tenant) }
52
+ rescue StandardError
53
+ # SQLite / adapters without Arel.sql coalesce ordering
54
+ scope = apply_status(klass.all, filters[:status])
55
+ if filters[:queue] && klass.column_names.include?("queue")
56
+ scope = scope.where(queue: filters[:queue])
57
+ end
58
+ scope.limit(PER_TENANT).map { |job| row_for(job, tenant) }
59
+ end
60
+
61
+ def apply_status(scope, status)
62
+ case status
63
+ when "pending"
64
+ scope = scope.where(failed_at: nil).where(locked_at: nil)
65
+ if scope.klass.column_names.include?("run_at")
66
+ scope = scope.where("run_at <= ?", Time.current)
67
+ end
68
+ scope
69
+ when "failed"
70
+ scope.where.not(failed_at: nil)
71
+ when "locked"
72
+ scope.where.not(locked_at: nil).where(failed_at: nil)
73
+ else
74
+ scope
75
+ end
76
+ end
77
+
78
+ def row_for(job, tenant)
79
+ name =
80
+ if job.respond_to?(:name) && job.name.present?
81
+ job.name
82
+ elsif job.respond_to?(:handler)
83
+ handler_class(job.handler)
84
+ else
85
+ "Delayed::Job"
86
+ end
87
+
88
+ error = nil
89
+ full_error = nil
90
+ if job.respond_to?(:last_error) && job.last_error
91
+ full_error = job.last_error.to_s
92
+ error = full_error.lines.first&.strip
93
+ end
94
+ handler = job.respond_to?(:handler) ? job.handler.to_s : nil
95
+
96
+ Row.new(
97
+ source: source_key,
98
+ id: job.id,
99
+ name: name,
100
+ status: status_for(job),
101
+ queue: job.respond_to?(:queue) ? job.queue : nil,
102
+ tenant: tenant,
103
+ run_at: job.respond_to?(:run_at) ? job.run_at : nil,
104
+ failed_at: job.respond_to?(:failed_at) ? job.failed_at : nil,
105
+ error: truncate(error),
106
+ tags: tenant ? { tenant: tenant } : {},
107
+ detail: {
108
+ "handler" => truncate(handler, 800),
109
+ "error" => truncate(full_error, 1000),
110
+ "attempts" => (job.respond_to?(:attempts) ? job.attempts : nil)
111
+ }.compact
112
+ )
113
+ end
114
+
115
+ def status_for(job)
116
+ return "failed" if job.respond_to?(:failed_at) && job.failed_at
117
+ return "locked" if job.respond_to?(:locked_at) && job.locked_at
118
+
119
+ "pending"
120
+ end
121
+
122
+ def handler_class(handler)
123
+ return "Delayed::Job" if handler.blank?
124
+
125
+ if handler =~ /!ruby\/object:(\S+)/
126
+ Regexp.last_match(1)
127
+ elsif handler =~ /job_class:\s*(\S+)/
128
+ Regexp.last_match(1)
129
+ else
130
+ handler.to_s.lines.first.to_s.strip[0, 80]
131
+ end
132
+ end
133
+
134
+ def truncate(text, max = 200)
135
+ return nil if text.nil?
136
+
137
+ s = text.to_s
138
+ s.length > max ? "#{s[0, max]}…" : s
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anomonitor
4
+ module Jobs
5
+ module Browsers
6
+ class Sidekiq
7
+ PER_SET = 50
8
+
9
+ def source_key
10
+ "sidekiq"
11
+ end
12
+
13
+ def fetch(filters)
14
+ unless defined?(::Sidekiq)
15
+ Anomonitor.logger.warn("[Anomonitor] Jobs browser skipped sidekiq: Sidekiq not loaded")
16
+ return []
17
+ end
18
+
19
+ status = filters[:status]
20
+ rows = []
21
+ rows.concat(pending_rows(filters)) if status == "all" || status == "pending"
22
+ rows.concat(locked_rows(filters)) if status == "all" || status == "locked"
23
+ rows.concat(failed_rows(filters)) if status == "all" || status == "failed"
24
+ rows
25
+ rescue StandardError => e
26
+ Anomonitor.logger.warn("[Anomonitor] Sidekiq jobs browser error: #{e.message}")
27
+ []
28
+ end
29
+
30
+ private
31
+
32
+ def pending_rows(filters)
33
+ queues = ::Sidekiq::Queue.all
34
+ queues = queues.select { |q| q.name == filters[:queue] } if filters[:queue]
35
+ rows = []
36
+ queues.each do |queue|
37
+ queue.each do |job|
38
+ rows << row_from_job(job, status: "pending", queue: queue.name)
39
+ break if rows.size >= PER_SET
40
+ end
41
+ break if rows.size >= PER_SET
42
+ end
43
+ rows
44
+ end
45
+
46
+ def locked_rows(filters)
47
+ return [] unless defined?(::Sidekiq::Workers)
48
+
49
+ rows = []
50
+ ::Sidekiq::Workers.new.each do |_process_id, _thread_id, work|
51
+ payload = work.is_a?(Hash) ? work : {}
52
+ job = payload["payload"] || payload[:payload] || payload
53
+ queue = payload["queue"] || payload[:queue] || job["queue"]
54
+ next if filters[:queue] && queue.to_s != filters[:queue]
55
+
56
+ rows << Row.new(
57
+ source: source_key,
58
+ id: job["jid"] || job[:jid],
59
+ name: job["class"] || job[:class] || "Sidekiq::Job",
60
+ status: "locked",
61
+ queue: queue,
62
+ tenant: nil,
63
+ run_at: parse_time(payload["run_at"] || payload[:run_at] || job["enqueued_at"]),
64
+ failed_at: nil,
65
+ error: nil,
66
+ tags: {}
67
+ )
68
+ break if rows.size >= PER_SET
69
+ end
70
+ rows
71
+ rescue StandardError
72
+ []
73
+ end
74
+
75
+ def failed_rows(filters)
76
+ rows = []
77
+ if defined?(::Sidekiq::RetrySet)
78
+ ::Sidekiq::RetrySet.new.each do |job|
79
+ next if filters[:queue] && job.queue.to_s != filters[:queue]
80
+
81
+ rows << row_from_job(job, status: "failed", queue: job.queue, error: job["error_message"])
82
+ break if rows.size >= PER_SET
83
+ end
84
+ end
85
+ if defined?(::Sidekiq::DeadSet) && rows.size < PER_SET
86
+ ::Sidekiq::DeadSet.new.each do |job|
87
+ next if filters[:queue] && job.queue.to_s != filters[:queue]
88
+
89
+ rows << row_from_job(job, status: "failed", queue: job.queue, error: job["error_message"])
90
+ break if rows.size >= PER_SET
91
+ end
92
+ end
93
+ rows
94
+ end
95
+
96
+ def row_from_job(job, status:, queue:, error: nil)
97
+ klass =
98
+ if job.respond_to?(:display_class)
99
+ job.display_class
100
+ elsif job.respond_to?(:klass)
101
+ job.klass
102
+ else
103
+ job["class"]
104
+ end
105
+ jid = job.respond_to?(:jid) ? job.jid : job["jid"]
106
+ at =
107
+ if job.respond_to?(:enqueued_at) && job.enqueued_at
108
+ parse_time(job.enqueued_at)
109
+ elsif job["enqueued_at"]
110
+ parse_time(job["enqueued_at"])
111
+ elsif job.respond_to?(:created_at)
112
+ parse_time(job.created_at)
113
+ end
114
+
115
+ err = error
116
+ err ||= job["error_message"] if job.respond_to?(:[])
117
+
118
+ args =
119
+ if job.respond_to?(:args)
120
+ job.args
121
+ elsif job.respond_to?(:[]) && job["args"]
122
+ job["args"]
123
+ end
124
+
125
+ Row.new(
126
+ source: source_key,
127
+ id: jid,
128
+ name: klass.to_s,
129
+ status: status,
130
+ queue: queue,
131
+ tenant: nil,
132
+ run_at: at,
133
+ failed_at: status == "failed" ? at : nil,
134
+ error: truncate(err),
135
+ tags: {},
136
+ detail: {
137
+ "class" => klass.to_s,
138
+ "jid" => jid,
139
+ "args" => truncate(args.inspect, 500),
140
+ "error" => truncate(err, 1000)
141
+ }.compact
142
+ )
143
+ end
144
+
145
+ def parse_time(value)
146
+ return nil if value.nil?
147
+ return value if value.is_a?(Time) || value.is_a?(DateTime)
148
+ return Time.at(value) if value.is_a?(Numeric)
149
+
150
+ Time.parse(value.to_s)
151
+ rescue StandardError
152
+ nil
153
+ end
154
+
155
+ def truncate(text, max = 200)
156
+ return nil if text.nil?
157
+
158
+ s = text.to_s
159
+ s.length > max ? "#{s[0, max]}…" : s
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anomonitor
4
+ module Jobs
5
+ module Browsers
6
+ class SolidQueue
7
+ LIMIT = 50
8
+
9
+ def source_key
10
+ "solid_queue"
11
+ end
12
+
13
+ def fetch(filters)
14
+ unless solid_queue_available?
15
+ Anomonitor.logger.warn("[Anomonitor] Jobs browser skipped solid_queue: tables not available")
16
+ return []
17
+ end
18
+
19
+ status = filters[:status]
20
+ rows = []
21
+ rows.concat(ready_rows(filters)) if status == "all" || status == "pending"
22
+ rows.concat(scheduled_rows(filters)) if status == "all" || status == "pending"
23
+ rows.concat(claimed_rows(filters)) if status == "all" || status == "locked"
24
+ rows.concat(failed_rows(filters)) if status == "all" || status == "failed"
25
+ rows.first(LIMIT)
26
+ rescue StandardError => e
27
+ Anomonitor.logger.warn("[Anomonitor] Solid Queue jobs browser error: #{e.message}")
28
+ []
29
+ end
30
+
31
+ private
32
+
33
+ def solid_queue_available?
34
+ return false unless defined?(ActiveRecord::Base)
35
+
36
+ table_exists?("solid_queue_jobs")
37
+ end
38
+
39
+ def table_exists?(name)
40
+ connection.data_source_exists?(name)
41
+ rescue StandardError
42
+ false
43
+ end
44
+
45
+ def connection
46
+ ActiveRecord::Base.connection
47
+ end
48
+
49
+ def ready_rows(filters)
50
+ return [] unless table_exists?("solid_queue_ready_executions")
51
+
52
+ map_jobs(select_joined("solid_queue_ready_executions", filters), "pending")
53
+ end
54
+
55
+ def scheduled_rows(filters)
56
+ return [] unless table_exists?("solid_queue_scheduled_executions")
57
+
58
+ sql = <<~SQL.squish
59
+ SELECT j.id, j.class_name, j.queue_name, j.created_at, j.scheduled_at,
60
+ e.scheduled_at AS exec_at
61
+ FROM solid_queue_jobs j
62
+ INNER JOIN solid_queue_scheduled_executions e ON e.job_id = j.id
63
+ #{queue_clause(filters)}
64
+ ORDER BY j.id DESC
65
+ LIMIT #{LIMIT}
66
+ SQL
67
+ map_jobs(connection.select_all(sql), "pending")
68
+ end
69
+
70
+ def claimed_rows(filters)
71
+ return [] unless table_exists?("solid_queue_claimed_executions")
72
+
73
+ map_jobs(select_joined("solid_queue_claimed_executions", filters), "locked")
74
+ end
75
+
76
+ def failed_rows(filters)
77
+ return [] unless table_exists?("solid_queue_failed_executions")
78
+
79
+ sql = <<~SQL.squish
80
+ SELECT j.id, j.class_name, j.queue_name, j.created_at, j.scheduled_at,
81
+ e.error, e.created_at AS failed_at
82
+ FROM solid_queue_jobs j
83
+ INNER JOIN solid_queue_failed_executions e ON e.job_id = j.id
84
+ #{queue_clause(filters)}
85
+ ORDER BY e.created_at DESC
86
+ LIMIT #{LIMIT}
87
+ SQL
88
+ map_jobs(connection.select_all(sql), "failed")
89
+ end
90
+
91
+ def select_joined(exec_table, filters)
92
+ sql = <<~SQL.squish
93
+ SELECT j.id, j.class_name, j.queue_name, j.created_at, j.scheduled_at
94
+ FROM solid_queue_jobs j
95
+ INNER JOIN #{exec_table} e ON e.job_id = j.id
96
+ #{queue_clause(filters)}
97
+ ORDER BY j.id DESC
98
+ LIMIT #{LIMIT}
99
+ SQL
100
+ connection.select_all(sql)
101
+ end
102
+
103
+ def queue_clause(filters)
104
+ return "" unless filters[:queue]
105
+
106
+ "WHERE j.queue_name = #{connection.quote(filters[:queue])}"
107
+ end
108
+
109
+ def map_jobs(result, status)
110
+ result.map do |row|
111
+ error = row["error"]
112
+ error = error.to_s.lines.first&.strip if error
113
+ run_at = parse_time(row["exec_at"] || row["scheduled_at"] || row["created_at"])
114
+ failed_at = status == "failed" ? parse_time(row["failed_at"] || row["created_at"]) : nil
115
+
116
+ Row.new(
117
+ source: source_key,
118
+ id: row["id"],
119
+ name: row["class_name"].presence || "SolidQueue::Job",
120
+ status: status,
121
+ queue: row["queue_name"],
122
+ tenant: nil,
123
+ run_at: run_at,
124
+ failed_at: failed_at,
125
+ error: truncate(error),
126
+ tags: {}
127
+ )
128
+ end
129
+ end
130
+
131
+ def parse_time(value)
132
+ return nil if value.nil?
133
+ return value if value.is_a?(Time) || value.is_a?(DateTime)
134
+
135
+ Time.parse(value.to_s)
136
+ rescue StandardError
137
+ nil
138
+ end
139
+
140
+ def truncate(text, max = 200)
141
+ return nil if text.nil?
142
+
143
+ s = text.to_s
144
+ s.length > max ? "#{s[0, max]}…" : s
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anomonitor
4
+ module Jobs
5
+ module Browsers
6
+ class Table
7
+ LIMIT = 100
8
+
9
+ def initialize(source)
10
+ @source = source
11
+ end
12
+
13
+ def source_key
14
+ "table:#{@source.name}"
15
+ end
16
+
17
+ def fetch(filters)
18
+ klass = @source.model_class
19
+ scope = klass.all
20
+ scope = scope.where(@source.tenant => filters[:tenant]) if filters[:tenant] && tenant_column?(klass)
21
+
22
+ if @source.delayed_job_style?
23
+ delayed_job_rows(scope, filters)
24
+ else
25
+ status_rows(scope, filters)
26
+ end
27
+ rescue NameError => e
28
+ Anomonitor.logger.warn("[Anomonitor] Jobs browser skipped #{source_key}: #{e.message}")
29
+ []
30
+ rescue StandardError => e
31
+ Anomonitor.logger.warn("[Anomonitor] Table jobs browser (#{@source.name}) error: #{e.message}")
32
+ []
33
+ end
34
+
35
+ private
36
+
37
+ def tenant_column?(klass)
38
+ col = @source.tenant
39
+ col && klass.column_names.include?(col.to_s)
40
+ end
41
+
42
+ def delayed_job_rows(scope, filters)
43
+ cols = scope.klass.column_names
44
+ scope = apply_delayed_status(scope, filters[:status], cols)
45
+ if filters[:queue] && cols.include?("queue")
46
+ scope = scope.where(queue: filters[:queue])
47
+ end
48
+ order_col = %w[failed_at run_at created_at updated_at].find { |c| cols.include?(c) }
49
+ scope = scope.order(order_col => :desc) if order_col
50
+ scope.limit(LIMIT).map { |record| delayed_row(record, cols) }
51
+ end
52
+
53
+ def apply_delayed_status(scope, status, cols)
54
+ case status
55
+ when "pending"
56
+ scope = scope.where(failed_at: nil) if cols.include?("failed_at")
57
+ if cols.include?("locked_by")
58
+ scope = scope.where(locked_by: nil)
59
+ elsif cols.include?("locked_at")
60
+ scope = scope.where(locked_at: nil)
61
+ end
62
+ scope = scope.where("run_at <= ?", Time.current) if cols.include?("run_at")
63
+ scope
64
+ when "failed"
65
+ cols.include?("failed_at") ? scope.where.not(failed_at: nil) : scope.none
66
+ when "locked"
67
+ return scope.none unless cols.include?("locked_at")
68
+
69
+ scope = scope.where.not(locked_at: nil)
70
+ scope = scope.where(failed_at: nil) if cols.include?("failed_at")
71
+ scope
72
+ else
73
+ scope
74
+ end
75
+ end
76
+
77
+ def delayed_row(record, cols)
78
+ tenant = cols.include?(@source.tenant.to_s) ? record.public_send(@source.tenant) : nil
79
+ error = cols.include?("last_error") ? record.public_send(:last_error) : nil
80
+ error = error.to_s.lines.first&.strip if error
81
+
82
+ Row.new(
83
+ source: source_key,
84
+ id: record.id,
85
+ name: record_name(record),
86
+ status: delayed_status(record, cols),
87
+ queue: cols.include?("queue") ? record.queue : nil,
88
+ tenant: tenant,
89
+ run_at: cols.include?("run_at") ? record.run_at : nil,
90
+ failed_at: cols.include?("failed_at") ? record.failed_at : nil,
91
+ error: truncate(error),
92
+ tags: { table: @source.name.to_s }.tap { |t| t[:tenant] = tenant if tenant },
93
+ detail: {
94
+ "table" => @source.name.to_s,
95
+ "handler" => truncate(cols.include?("handler") ? record.handler.to_s : nil, 800),
96
+ "error" => truncate(cols.include?("last_error") ? record.last_error.to_s : nil, 1000)
97
+ }.compact
98
+ )
99
+ end
100
+
101
+ def delayed_status(record, cols)
102
+ return "failed" if cols.include?("failed_at") && record.failed_at
103
+ return "locked" if cols.include?("locked_at") && record.locked_at
104
+
105
+ "pending"
106
+ end
107
+
108
+ def status_rows(scope, filters)
109
+ cols = scope.klass.column_names
110
+ status_col = @source.status
111
+ if status_col && cols.include?(status_col.to_s)
112
+ scope = apply_status_filter(scope, filters[:status], status_col)
113
+ elsif filters[:status] != "all"
114
+ return []
115
+ end
116
+
117
+ if filters[:queue] && cols.include?("queue")
118
+ scope = scope.where(queue: filters[:queue])
119
+ end
120
+
121
+ order_col = %w[updated_at created_at].find { |c| cols.include?(c) }
122
+ scope = scope.order(order_col => :desc) if order_col
123
+ scope.limit(LIMIT).map { |record| status_row(record, cols, status_col) }
124
+ end
125
+
126
+ def apply_status_filter(scope, status, status_col)
127
+ active = Array(@source.active).map(&:to_s)
128
+ case status
129
+ when "pending", "locked"
130
+ # status-style tables: active statuses map to pending; no distinct locked
131
+ return scope.none if status == "locked"
132
+ return scope.where(status_col => active) if active.any?
133
+
134
+ scope
135
+ when "failed"
136
+ failed = infer_failed_statuses(scope.klass, status_col, active)
137
+ failed.any? ? scope.where(status_col => failed) : scope.none
138
+ else
139
+ scope
140
+ end
141
+ end
142
+
143
+ def infer_failed_statuses(klass, status_col, active)
144
+ known = %w[failed error dead]
145
+ values = klass.distinct.limit(50).pluck(status_col).compact.map(&:to_s)
146
+ values.select { |v| known.include?(v.downcase) || (active.any? && !active.include?(v)) }
147
+ .select { |v| %w[failed error dead].include?(v.downcase) }
148
+ end
149
+
150
+ def status_row(record, cols, status_col)
151
+ raw_status = status_col && cols.include?(status_col.to_s) ? record.public_send(status_col).to_s : "pending"
152
+ active = Array(@source.active).map(&:to_s)
153
+ mapped =
154
+ if %w[failed error dead].include?(raw_status.downcase)
155
+ "failed"
156
+ elsif active.include?(raw_status)
157
+ "pending"
158
+ else
159
+ raw_status.presence || "pending"
160
+ end
161
+
162
+ tenant = @source.tenant && cols.include?(@source.tenant.to_s) ? record.public_send(@source.tenant) : nil
163
+ run_at =
164
+ if @source.timestamp && cols.include?(@source.timestamp.to_s)
165
+ record.public_send(@source.timestamp)
166
+ elsif cols.include?("created_at")
167
+ record.created_at
168
+ end
169
+
170
+ Row.new(
171
+ source: source_key,
172
+ id: record.id,
173
+ name: record_name(record),
174
+ status: mapped,
175
+ queue: cols.include?("queue") ? record.queue : nil,
176
+ tenant: tenant,
177
+ run_at: run_at,
178
+ failed_at: mapped == "failed" ? run_at : nil,
179
+ error: nil,
180
+ tags: { table: @source.name.to_s, status: raw_status }.tap { |t| t[:tenant] = tenant if tenant },
181
+ detail: { "table" => @source.name.to_s, "status" => raw_status }
182
+ )
183
+ end
184
+
185
+ def record_name(record)
186
+ if record.respond_to?(:name) && record.name.present?
187
+ record.name.to_s
188
+ elsif record.respond_to?(:handler) && record.handler.present?
189
+ record.handler.to_s.lines.first.to_s.strip[0, 80]
190
+ elsif record.respond_to?(:job_class) && record.job_class.present?
191
+ record.job_class.to_s
192
+ else
193
+ @source.model.to_s
194
+ end
195
+ end
196
+
197
+ def truncate(text, max = 200)
198
+ return nil if text.nil?
199
+
200
+ s = text.to_s
201
+ s.length > max ? "#{s[0, max]}…" : s
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anomonitor
4
+ module Jobs
5
+ Row = Struct.new(
6
+ :source,
7
+ :id,
8
+ :name,
9
+ :status,
10
+ :queue,
11
+ :tenant,
12
+ :run_at,
13
+ :failed_at,
14
+ :error,
15
+ :tags,
16
+ :detail,
17
+ keyword_init: true
18
+ ) do
19
+ def sort_at
20
+ failed_at || run_at || Time.at(0)
21
+ end
22
+
23
+ def detail_text
24
+ return nil if detail.nil? || detail.empty?
25
+
26
+ detail.map { |k, v| "#{k}: #{v}" }.join("\n")
27
+ end
28
+ end
29
+ end
30
+ end