sidekiq-error-groups 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/CHANGELOG.md +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +238 -0
- data/exe/sidekiq-error-groups +6 -0
- data/lib/sidekiq/error_groups/actions.rb +124 -0
- data/lib/sidekiq/error_groups/aggregator.rb +40 -0
- data/lib/sidekiq/error_groups/cli.rb +155 -0
- data/lib/sidekiq/error_groups/compat.rb +36 -0
- data/lib/sidekiq/error_groups/config.rb +69 -0
- data/lib/sidekiq/error_groups/formatter.rb +90 -0
- data/lib/sidekiq/error_groups/group.rb +129 -0
- data/lib/sidekiq/error_groups/job.rb +137 -0
- data/lib/sidekiq/error_groups/job_query.rb +69 -0
- data/lib/sidekiq/error_groups/railtie.rb +13 -0
- data/lib/sidekiq/error_groups/scanner.rb +53 -0
- data/lib/sidekiq/error_groups/selector.rb +101 -0
- data/lib/sidekiq/error_groups/summary.rb +57 -0
- data/lib/sidekiq/error_groups/tasks.rake +17 -0
- data/lib/sidekiq/error_groups/version.rb +7 -0
- data/lib/sidekiq/error_groups/web.rb +28 -0
- data/lib/sidekiq/error_groups/web_extension.rb +178 -0
- data/lib/sidekiq/error_groups.rb +116 -0
- data/lib/sidekiq-error-groups.rb +3 -0
- data/sidekiq-error-groups.gemspec +37 -0
- data/web/locales/en.yml +35 -0
- data/web/locales/ru.yml +35 -0
- data/web/views/group.erb +114 -0
- data/web/views/index.erb +83 -0
- data/web/views/legacy/group.erb +114 -0
- data/web/views/legacy/index.erb +83 -0
- metadata +93 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sidekiq
|
|
4
|
+
module ErrorGroups
|
|
5
|
+
# Result of one aggregation pass.
|
|
6
|
+
class Summary
|
|
7
|
+
attr_reader :groups, :group_by, :scanned, :set_sizes, :generated_at
|
|
8
|
+
|
|
9
|
+
def initialize(groups:, scanned:, set_sizes:, group_by: nil, generated_at: Time.now.utc)
|
|
10
|
+
@groups = groups
|
|
11
|
+
@group_by = group_by || ErrorGroups.config.group_by
|
|
12
|
+
@scanned = scanned
|
|
13
|
+
@set_sizes = set_sizes
|
|
14
|
+
@generated_at = generated_at
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def total
|
|
18
|
+
set_sizes.values.sum
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def sets
|
|
22
|
+
set_sizes.keys
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def size_of(set)
|
|
26
|
+
set_sizes.fetch(set, 0)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# True when `max_scan` stopped us before the end of a set, i.e. the numbers
|
|
30
|
+
# below are a lower bound.
|
|
31
|
+
def truncated?
|
|
32
|
+
scanned < total
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def empty?
|
|
36
|
+
groups.empty?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def find(error_class = nil, job_class: nil)
|
|
40
|
+
selector = Selector.new(error_class: error_class, job_class: job_class)
|
|
41
|
+
groups.find { |group| group.selector == selector }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def to_h
|
|
45
|
+
{
|
|
46
|
+
generated_at: generated_at.iso8601,
|
|
47
|
+
group_by: group_by,
|
|
48
|
+
scanned: scanned,
|
|
49
|
+
total: total,
|
|
50
|
+
truncated: truncated?,
|
|
51
|
+
sets: set_sizes,
|
|
52
|
+
groups: groups.map(&:to_h)
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :sidekiq do
|
|
4
|
+
desc "Show failed Sidekiq jobs grouped by error class and/or job class " \
|
|
5
|
+
"(GROUP_BY=error_job SETS=retry,dead LIMIT=20 MAX_SCAN=10000)"
|
|
6
|
+
task error_groups: (defined?(Rails) ? [:environment] : []) do
|
|
7
|
+
require "sidekiq/error_groups"
|
|
8
|
+
|
|
9
|
+
summary = Sidekiq::ErrorGroups.summary(
|
|
10
|
+
sets: ENV["SETS"]&.split(","),
|
|
11
|
+
group_by: ENV.fetch("GROUP_BY", nil),
|
|
12
|
+
max_scan: ENV["MAX_SCAN"]&.to_i,
|
|
13
|
+
refresh: true
|
|
14
|
+
)
|
|
15
|
+
puts Sidekiq::ErrorGroups::Formatter.render(summary, limit: ENV["LIMIT"]&.to_i)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "web_extension"
|
|
4
|
+
|
|
5
|
+
# Registers the "Error groups" tab in the Sidekiq Web UI.
|
|
6
|
+
#
|
|
7
|
+
# Require this where the Web UI is mounted (`config/routes.rb` in Rails, or the
|
|
8
|
+
# rackup file), *not* in an initializer: Sidekiq worker processes should not load
|
|
9
|
+
# the Web UI.
|
|
10
|
+
#
|
|
11
|
+
# require "sidekiq/error_groups/web"
|
|
12
|
+
# mount Sidekiq::Web => "/sidekiq"
|
|
13
|
+
if defined?(Sidekiq::Web)
|
|
14
|
+
extension = Sidekiq::ErrorGroups::WebExtension
|
|
15
|
+
tab = "ErrorGroups" # a locale key, see web/locales
|
|
16
|
+
index = "error_groups"
|
|
17
|
+
|
|
18
|
+
if Sidekiq::ErrorGroups::Compat.sidekiq_8?
|
|
19
|
+
Sidekiq::Web.configure do |config|
|
|
20
|
+
config.register(extension, name: index, tab: tab, index: index)
|
|
21
|
+
end
|
|
22
|
+
elsif Sidekiq::ErrorGroups::Compat.sidekiq_73?
|
|
23
|
+
Sidekiq::Web.register(extension, name: index, tab: tab, index: index)
|
|
24
|
+
else
|
|
25
|
+
Sidekiq::Web.register(extension)
|
|
26
|
+
Sidekiq::Web.tabs[tab] = index
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require_relative "../error_groups"
|
|
5
|
+
|
|
6
|
+
module Sidekiq
|
|
7
|
+
module ErrorGroups
|
|
8
|
+
# Routes and view helpers for the "Error groups" tab.
|
|
9
|
+
#
|
|
10
|
+
# Registered through {Sidekiq::ErrorGroups::Web}, which knows how to talk to
|
|
11
|
+
# every supported Sidekiq release. Everything used here (`app.get`,
|
|
12
|
+
# `app.post`, `app.helpers`, `erb(string)`, `root_path`, `csrf_tag`, `t`,
|
|
13
|
+
# `relative_time`, `job_params`, `h`) exists in Sidekiq 6.0 through 8.x.
|
|
14
|
+
#
|
|
15
|
+
# A group is addressed by query parameters rather than path segments: the key
|
|
16
|
+
# can hold two class names, and `error_class` + `job_class` survive every
|
|
17
|
+
# Sidekiq router version unchanged.
|
|
18
|
+
module WebExtension
|
|
19
|
+
WEB_ROOT = File.expand_path("../../../web", __dir__)
|
|
20
|
+
NOTICES = %w[retry delete kill].freeze
|
|
21
|
+
|
|
22
|
+
module Helpers
|
|
23
|
+
# Sidekiq 8 turned both param readers into methods; before that they were
|
|
24
|
+
# hashes.
|
|
25
|
+
def eg_route_param(key)
|
|
26
|
+
Compat.sidekiq_8? ? route_params(key) : route_params[key]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def eg_url_param(key)
|
|
30
|
+
value = Compat.sidekiq_8? ? url_params(key.to_s) : params[key.to_s]
|
|
31
|
+
value.nil? || value.to_s.empty? ? nil : value.to_s
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Sidekiq 8 ships a completely different UI markup, so each view exists
|
|
35
|
+
# twice: `web/views` for 8.x and `web/views/legacy` for 6.x/7.x.
|
|
36
|
+
def eg_render(view)
|
|
37
|
+
dir = Compat.sidekiq_8? ? "views" : File.join("views", "legacy")
|
|
38
|
+
erb(File.read(File.join(WEB_ROOT, dir, "#{view}.erb")))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def eg_root
|
|
42
|
+
"#{root_path}error_groups"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def eg_group_by
|
|
46
|
+
@eg_group_by ||= begin
|
|
47
|
+
Config.normalize_group_by(eg_url_param(:group_by) || ErrorGroups.config.group_by)
|
|
48
|
+
rescue ArgumentError
|
|
49
|
+
ErrorGroups.config.group_by
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def eg_index_path(group_by = eg_group_by, extra = {})
|
|
54
|
+
eg_path(eg_root, {"group_by" => group_by.to_s}.merge(extra))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def eg_group_path(selector, action = nil, extra = {})
|
|
58
|
+
base = action ? "#{eg_root}/group/#{action}" : "#{eg_root}/group"
|
|
59
|
+
eg_path(base, selector.to_params.merge(extra))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def eg_page_path(selector, page)
|
|
63
|
+
eg_group_path(selector, nil, {"page" => page.to_s})
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def eg_path(base, params)
|
|
67
|
+
query = params.reject { |_, value| value.nil? || value.to_s.empty? }
|
|
68
|
+
.map { |key, value| "#{ERB::Util.url_encode(key)}=#{ERB::Util.url_encode(value)}" }
|
|
69
|
+
query.empty? ? base : "#{base}?#{query.join("&")}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def eg_label(error_class)
|
|
73
|
+
error_class == Selector::UNKNOWN ? t("NoErrorClass") : error_class.to_s
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def eg_selector_label(selector)
|
|
77
|
+
[
|
|
78
|
+
selector.error_class && eg_label(selector.error_class),
|
|
79
|
+
selector.job_class
|
|
80
|
+
].compact.join(" · ")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def eg_group_modes
|
|
84
|
+
Selector::MODES.map { |mode| [mode, t("GroupBy_#{mode}")] }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Link to the job on Sidekiq's own Retries/Dead detail page.
|
|
88
|
+
def eg_job_path(job)
|
|
89
|
+
"#{root_path}#{job.web_segment}/#{job_params(job.item, job.score)}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def eg_time(time)
|
|
93
|
+
return "-" unless time
|
|
94
|
+
|
|
95
|
+
relative_time(time)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def eg_breakdown(group)
|
|
99
|
+
group.breakdown.map { |name, count| "#{eg_label(name)} (#{count})" }.join(", ")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def eg_backtrace(job)
|
|
103
|
+
lines = job&.backtrace
|
|
104
|
+
lines ? lines.first(ErrorGroups.config.backtrace_lines) : []
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Hidden fields that keep a bulk action scoped to the group it was fired
|
|
108
|
+
# from, and bring the viewer back to the grouping they were looking at.
|
|
109
|
+
def eg_selector_fields(selector)
|
|
110
|
+
fields = selector.to_params.merge("group_by" => eg_group_by.to_s)
|
|
111
|
+
fields.map do |name, value|
|
|
112
|
+
"<input type='hidden' name='#{h name}' value='#{h value}'/>"
|
|
113
|
+
end.join
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Flash replacement: Sidekiq's `flash` helper needs a session and only
|
|
117
|
+
# exists on 8.x, so the result of a bulk action travels in the query
|
|
118
|
+
# string instead.
|
|
119
|
+
def eg_notice
|
|
120
|
+
operation = eg_url_param(:done)
|
|
121
|
+
return nil unless NOTICES.include?(operation)
|
|
122
|
+
|
|
123
|
+
count = eg_url_param(:count).to_i
|
|
124
|
+
group = eg_url_param(:group).to_s
|
|
125
|
+
t("GroupAction_#{operation}", count: count, group: group)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def self.registered(app)
|
|
130
|
+
locales(app) << File.join(WEB_ROOT, "locales")
|
|
131
|
+
app.helpers(Helpers)
|
|
132
|
+
|
|
133
|
+
app.get "/error_groups" do
|
|
134
|
+
@summary = Sidekiq::ErrorGroups.summary(group_by: eg_group_by)
|
|
135
|
+
eg_render(:index)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
app.get "/error_groups/group" do
|
|
139
|
+
@page = Sidekiq::ErrorGroups.jobs(
|
|
140
|
+
eg_url_param(:error_class),
|
|
141
|
+
job_class: eg_url_param(:job_class),
|
|
142
|
+
page: eg_url_param(:page) || 1
|
|
143
|
+
)
|
|
144
|
+
@group = @page.group
|
|
145
|
+
@selector = @group.selector
|
|
146
|
+
eg_render(:group)
|
|
147
|
+
rescue ArgumentError
|
|
148
|
+
redirect eg_index_path
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
NOTICES.each do |operation|
|
|
152
|
+
app.post "/error_groups/group/#{operation}" do
|
|
153
|
+
selector = Selector.build(
|
|
154
|
+
error_class: eg_url_param(:error_class),
|
|
155
|
+
job_class: eg_url_param(:job_class)
|
|
156
|
+
)
|
|
157
|
+
result = Sidekiq::ErrorGroups::Actions.call(
|
|
158
|
+
selector.error_class,
|
|
159
|
+
job_class: selector.job_class,
|
|
160
|
+
operation: operation.to_sym
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
redirect eg_index_path(eg_group_by,
|
|
164
|
+
"done" => operation, "count" => result.total.to_s, "group" => eg_selector_label(selector))
|
|
165
|
+
rescue ArgumentError
|
|
166
|
+
redirect eg_index_path
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Where to append our locale directory. Sidekiq 8 moved the registry from
|
|
172
|
+
# the application class onto the Web config object.
|
|
173
|
+
def self.locales(app)
|
|
174
|
+
Compat.sidekiq_8? ? Sidekiq::Web.configure.locales : app.settings.locales
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "forwardable"
|
|
4
|
+
|
|
5
|
+
require "sidekiq"
|
|
6
|
+
require "sidekiq/api"
|
|
7
|
+
|
|
8
|
+
require_relative "error_groups/version"
|
|
9
|
+
require_relative "error_groups/compat"
|
|
10
|
+
require_relative "error_groups/selector"
|
|
11
|
+
require_relative "error_groups/config"
|
|
12
|
+
require_relative "error_groups/job"
|
|
13
|
+
require_relative "error_groups/group"
|
|
14
|
+
require_relative "error_groups/summary"
|
|
15
|
+
require_relative "error_groups/scanner"
|
|
16
|
+
require_relative "error_groups/aggregator"
|
|
17
|
+
require_relative "error_groups/job_query"
|
|
18
|
+
require_relative "error_groups/actions"
|
|
19
|
+
require_relative "error_groups/formatter"
|
|
20
|
+
|
|
21
|
+
module Sidekiq
|
|
22
|
+
# Groups failed Sidekiq jobs (retries + dead set) by error class, by job class,
|
|
23
|
+
# or by the pair of them.
|
|
24
|
+
#
|
|
25
|
+
# Sidekiq::ErrorGroups.summary.groups.each do |group|
|
|
26
|
+
# puts "#{group.error_class} in #{group.job_class}: #{group.count}"
|
|
27
|
+
# end
|
|
28
|
+
module ErrorGroups
|
|
29
|
+
class Error < StandardError; end
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
def config
|
|
33
|
+
@config ||= Config.new
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def configure
|
|
37
|
+
yield config if block_given?
|
|
38
|
+
config
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Sidekiq::ErrorGroups::Summary]
|
|
42
|
+
def summary(sets: nil, group_by: nil, max_scan: nil, sources: nil, refresh: false)
|
|
43
|
+
options = {sets: sets, group_by: group_by, max_scan: max_scan, sources: sources}
|
|
44
|
+
ttl = config.cache_ttl.to_f
|
|
45
|
+
return Aggregator.call(**options) if sources || ttl <= 0
|
|
46
|
+
|
|
47
|
+
key = [
|
|
48
|
+
Config.normalize_sets(sets || config.sets),
|
|
49
|
+
Config.normalize_group_by(group_by || config.group_by),
|
|
50
|
+
max_scan || config.max_scan
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
cache_mutex.synchronize do
|
|
54
|
+
entry = cache[key]
|
|
55
|
+
return entry[:summary] if !refresh && entry && entry[:expires_at] > monotonic_now
|
|
56
|
+
|
|
57
|
+
summary = Aggregator.call(**options)
|
|
58
|
+
cache[key] = {summary: summary, expires_at: monotonic_now + ttl}
|
|
59
|
+
summary
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @return [Array<Sidekiq::ErrorGroups::Group>]
|
|
64
|
+
def groups(**kwargs)
|
|
65
|
+
summary(**kwargs).groups
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Sidekiq::ErrorGroups::JobQuery::Page]
|
|
69
|
+
def jobs(error_class = nil, **kwargs)
|
|
70
|
+
JobQuery.call(error_class, **kwargs)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def retry_all(error_class = nil, **kwargs)
|
|
74
|
+
Actions.retry_all(error_class, **kwargs)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def delete_all(error_class = nil, **kwargs)
|
|
78
|
+
Actions.delete_all(error_class, **kwargs)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def kill_all(error_class = nil, **kwargs)
|
|
82
|
+
Actions.kill_all(error_class, **kwargs)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def job_set(kind)
|
|
86
|
+
Scanner.source_for(kind)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def reset_cache!
|
|
90
|
+
cache_mutex.synchronize { cache.clear }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @api private
|
|
94
|
+
def reset!
|
|
95
|
+
@config = nil
|
|
96
|
+
reset_cache!
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def cache
|
|
102
|
+
@cache ||= {}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def cache_mutex
|
|
106
|
+
@cache_mutex ||= Mutex.new
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def monotonic_now
|
|
110
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
require_relative "error_groups/railtie" if defined?(Rails::Railtie)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/sidekiq/error_groups/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "sidekiq-error-groups"
|
|
7
|
+
spec.version = Sidekiq::ErrorGroups::VERSION
|
|
8
|
+
spec.authors = ["r3cha"]
|
|
9
|
+
spec.email = ["frontandstart@gmail.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Sidekiq Web UI tab with failed jobs grouped by error class."
|
|
12
|
+
spec.description = "Adds an \"Error groups\" tab to the Sidekiq Web UI that groups retrying and " \
|
|
13
|
+
"dead jobs by their error class, with per-group retry/delete/kill actions, " \
|
|
14
|
+
"plus a Ruby API, a rake task and a CLI. Works with Sidekiq 6, 7 and 8."
|
|
15
|
+
spec.homepage = "https://github.com/r3cha/sidekiq-error-groups"
|
|
16
|
+
spec.license = "MIT"
|
|
17
|
+
spec.required_ruby_version = ">= 2.7.0"
|
|
18
|
+
|
|
19
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
20
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
21
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
22
|
+
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
|
23
|
+
spec.metadata["documentation_uri"] = "#{spec.homepage}#readme"
|
|
24
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
25
|
+
|
|
26
|
+
spec.files = Dir.glob("lib/**/*") + Dir.glob("web/**/*") + %w[
|
|
27
|
+
CHANGELOG.md
|
|
28
|
+
LICENSE.txt
|
|
29
|
+
README.md
|
|
30
|
+
sidekiq-error-groups.gemspec
|
|
31
|
+
]
|
|
32
|
+
spec.bindir = "exe"
|
|
33
|
+
spec.executables = ["sidekiq-error-groups"]
|
|
34
|
+
spec.require_paths = ["lib"]
|
|
35
|
+
|
|
36
|
+
spec.add_dependency "sidekiq", ">= 6.0"
|
|
37
|
+
end
|
data/web/locales/en.yml
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
en:
|
|
2
|
+
ErrorGroups: Error groups
|
|
3
|
+
ErrorClass: Error class
|
|
4
|
+
JobClass: Job
|
|
5
|
+
ErrorMessage: Error message
|
|
6
|
+
Backtrace: Backtrace
|
|
7
|
+
Total: Total
|
|
8
|
+
Attempts: Attempts
|
|
9
|
+
Set: Set
|
|
10
|
+
GroupBy: Group by
|
|
11
|
+
GroupBy_error: error class
|
|
12
|
+
GroupBy_job: job
|
|
13
|
+
GroupBy_error_job: error class + job
|
|
14
|
+
AffectedJobs: Job classes
|
|
15
|
+
AffectedErrors: Error classes
|
|
16
|
+
FirstFailure: First failure
|
|
17
|
+
LastFailure: Last failure
|
|
18
|
+
NoErrorClass: "(no error class)"
|
|
19
|
+
NoFailedJobs: No failed jobs. Nothing to group.
|
|
20
|
+
GroupEmpty: No jobs left in this group.
|
|
21
|
+
BackToErrorGroups: All error groups
|
|
22
|
+
ScannedJobs: "Scanned %{scanned} of %{total} failed job(s)"
|
|
23
|
+
ScanTruncated: "Scanning stopped at max_scan (%{max_scan}), the numbers below are a lower bound."
|
|
24
|
+
RetryGroup: Retry all in group
|
|
25
|
+
DeleteGroup: Delete all in group
|
|
26
|
+
KillGroup: Kill all in group
|
|
27
|
+
AreYouSureRetryGroup: "Are you sure you want to retry %{count} job(s)?"
|
|
28
|
+
AreYouSureDeleteGroup: "Are you sure you want to delete %{count} job(s)?"
|
|
29
|
+
AreYouSureKillGroup: "Are you sure you want to move %{count} job(s) to the dead set?"
|
|
30
|
+
GroupAction_retry: "Retried %{count} job(s) of %{group}"
|
|
31
|
+
GroupAction_delete: "Deleted %{count} job(s) of %{group}"
|
|
32
|
+
GroupAction_kill: "Moved %{count} job(s) of %{group} to the dead set"
|
|
33
|
+
PreviousPage: Previous
|
|
34
|
+
NextPage: Next
|
|
35
|
+
PageOf: "page %{page} of %{pages}"
|
data/web/locales/ru.yml
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
ru:
|
|
2
|
+
ErrorGroups: Группы ошибок
|
|
3
|
+
ErrorClass: Класс ошибки
|
|
4
|
+
JobClass: Джоба
|
|
5
|
+
ErrorMessage: Сообщение об ошибке
|
|
6
|
+
Backtrace: Трейс
|
|
7
|
+
Total: Всего
|
|
8
|
+
Attempts: Попыток
|
|
9
|
+
Set: Набор
|
|
10
|
+
GroupBy: Группировать по
|
|
11
|
+
GroupBy_error: классу ошибки
|
|
12
|
+
GroupBy_job: джобе
|
|
13
|
+
GroupBy_error_job: ошибке и джобе
|
|
14
|
+
AffectedJobs: Классы джоб
|
|
15
|
+
AffectedErrors: Классы ошибок
|
|
16
|
+
FirstFailure: Первое падение
|
|
17
|
+
LastFailure: Последнее падение
|
|
18
|
+
NoErrorClass: "(без класса ошибки)"
|
|
19
|
+
NoFailedJobs: Упавших джоб нет — группировать нечего.
|
|
20
|
+
GroupEmpty: В этой группе больше нет джоб.
|
|
21
|
+
BackToErrorGroups: Все группы ошибок
|
|
22
|
+
ScannedJobs: "Просмотрено %{scanned} из %{total} упавших джоб"
|
|
23
|
+
ScanTruncated: "Просмотр остановлен на max_scan (%{max_scan}), числа ниже — нижняя оценка."
|
|
24
|
+
RetryGroup: Перезапустить всю группу
|
|
25
|
+
DeleteGroup: Удалить всю группу
|
|
26
|
+
KillGroup: Отправить группу в dead
|
|
27
|
+
AreYouSureRetryGroup: "Перезапустить %{count} джоб(ы)?"
|
|
28
|
+
AreYouSureDeleteGroup: "Удалить %{count} джоб(ы)?"
|
|
29
|
+
AreYouSureKillGroup: "Перенести %{count} джоб(ы) в dead-набор?"
|
|
30
|
+
GroupAction_retry: "Перезапущено джоб: %{count} (%{group})"
|
|
31
|
+
GroupAction_delete: "Удалено джоб: %{count} (%{group})"
|
|
32
|
+
GroupAction_kill: "Перенесено в dead: %{count} (%{group})"
|
|
33
|
+
PreviousPage: Назад
|
|
34
|
+
NextPage: Вперёд
|
|
35
|
+
PageOf: "страница %{page} из %{pages}"
|
data/web/views/group.erb
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
<section>
|
|
2
|
+
<header>
|
|
3
|
+
<h1><%= h eg_selector_label(@selector) %></h1>
|
|
4
|
+
<p><a href="<%= h eg_index_path %>">← <%= t('BackToErrorGroups') %></a></p>
|
|
5
|
+
</header>
|
|
6
|
+
|
|
7
|
+
<% if @group.count.zero? %>
|
|
8
|
+
<p><%= t('GroupEmpty') %></p>
|
|
9
|
+
<% else %>
|
|
10
|
+
<div class="table_container">
|
|
11
|
+
<table>
|
|
12
|
+
<tbody>
|
|
13
|
+
<% if @selector.error_class %>
|
|
14
|
+
<tr><th><%= t('ErrorClass') %></th><td><%= h eg_label(@selector.error_class) %></td></tr>
|
|
15
|
+
<% end %>
|
|
16
|
+
<% if @selector.job_class %>
|
|
17
|
+
<tr><th><%= t('JobClass') %></th><td><%= h @selector.job_class %></td></tr>
|
|
18
|
+
<% end %>
|
|
19
|
+
<tr><th><%= t('Total') %></th><td><%= @group.count %></td></tr>
|
|
20
|
+
<tr><th><%= t('Retries') %></th><td><%= @group.retry_count %></td></tr>
|
|
21
|
+
<tr><th><%= t('Dead') %></th><td><%= @group.dead_count %></td></tr>
|
|
22
|
+
<tr><th><%= t('FirstFailure') %></th><td><%= eg_time(@group.first_failed_at) %></td></tr>
|
|
23
|
+
<tr><th><%= t('LastFailure') %></th><td><%= eg_time(@group.last_failed_at) %></td></tr>
|
|
24
|
+
<% unless @group.breakdown.empty? %>
|
|
25
|
+
<tr>
|
|
26
|
+
<th><%= (@group.mode == :error) ? t('AffectedJobs') : t('AffectedErrors') %></th>
|
|
27
|
+
<td><%= h eg_breakdown(@group) %></td>
|
|
28
|
+
</tr>
|
|
29
|
+
<% end %>
|
|
30
|
+
<% if @group.sample && !@group.sample.error_message.empty? %>
|
|
31
|
+
<tr><th><%= t('ErrorMessage') %></th><td><code><%= h @group.sample.error_message %></code></td></tr>
|
|
32
|
+
<% end %>
|
|
33
|
+
<% backtrace = eg_backtrace(@group.sample) %>
|
|
34
|
+
<% unless backtrace.empty? %>
|
|
35
|
+
<tr>
|
|
36
|
+
<th><%= t('Backtrace') %></th>
|
|
37
|
+
<td><code><%= backtrace.map { |line| h(line) }.join("<br/>") %></code></td>
|
|
38
|
+
</tr>
|
|
39
|
+
<% end %>
|
|
40
|
+
</tbody>
|
|
41
|
+
</table>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div class="buttons-row">
|
|
45
|
+
<form action="<%= h eg_group_path(@selector, 'retry') %>" method="post">
|
|
46
|
+
<%= csrf_tag %>
|
|
47
|
+
<%= eg_selector_fields(@selector) %>
|
|
48
|
+
<input class="btn btn-primary" type="submit" value="<%= t('RetryGroup') %>"
|
|
49
|
+
data-confirm="<%= t('AreYouSureRetryGroup', count: @group.count) %>" />
|
|
50
|
+
</form>
|
|
51
|
+
<% if @group.retry_count.positive? %>
|
|
52
|
+
<form action="<%= h eg_group_path(@selector, 'kill') %>" method="post">
|
|
53
|
+
<%= csrf_tag %>
|
|
54
|
+
<%= eg_selector_fields(@selector) %>
|
|
55
|
+
<input class="btn btn-danger" type="submit" value="<%= t('KillGroup') %>"
|
|
56
|
+
data-confirm="<%= t('AreYouSureKillGroup', count: @group.retry_count) %>" />
|
|
57
|
+
</form>
|
|
58
|
+
<% end %>
|
|
59
|
+
<form action="<%= h eg_group_path(@selector, 'delete') %>" method="post">
|
|
60
|
+
<%= csrf_tag %>
|
|
61
|
+
<%= eg_selector_fields(@selector) %>
|
|
62
|
+
<input class="btn btn-danger" type="submit" value="<%= t('DeleteGroup') %>"
|
|
63
|
+
data-confirm="<%= t('AreYouSureDeleteGroup', count: @group.count) %>" />
|
|
64
|
+
</form>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<% if @page.truncated %>
|
|
68
|
+
<p><strong><%= t('ScanTruncated', max_scan: Sidekiq::ErrorGroups.config.max_scan) %></strong></p>
|
|
69
|
+
<% end %>
|
|
70
|
+
|
|
71
|
+
<div class="table_container">
|
|
72
|
+
<table>
|
|
73
|
+
<thead>
|
|
74
|
+
<tr>
|
|
75
|
+
<th><%= t('Set') %></th>
|
|
76
|
+
<th><%= t('LastFailure') %></th>
|
|
77
|
+
<th><%= t('Queue') %></th>
|
|
78
|
+
<th><%= t('Job') %></th>
|
|
79
|
+
<% unless @selector.error_class %>
|
|
80
|
+
<th><%= t('ErrorClass') %></th>
|
|
81
|
+
<% end %>
|
|
82
|
+
<th><%= t('Arguments') %></th>
|
|
83
|
+
<th><%= t('Attempts') %></th>
|
|
84
|
+
</tr>
|
|
85
|
+
</thead>
|
|
86
|
+
<tbody>
|
|
87
|
+
<% @page.jobs.each do |job| %>
|
|
88
|
+
<tr>
|
|
89
|
+
<td><%= h job.set.to_s %></td>
|
|
90
|
+
<td><a href="<%= eg_job_path(job) %>"><%= eg_time(job.last_failure_at) %></a></td>
|
|
91
|
+
<td><a href="<%= root_path %>queues/<%= h job.queue.to_s %>"><%= h job.queue.to_s %></a></td>
|
|
92
|
+
<td><%= h job.display_class %></td>
|
|
93
|
+
<% unless @selector.error_class %>
|
|
94
|
+
<td><%= h eg_label(job.error_class) %></td>
|
|
95
|
+
<% end %>
|
|
96
|
+
<td><%= display_args(job.item["args"]) %></td>
|
|
97
|
+
<td><%= job.retry_count %></td>
|
|
98
|
+
</tr>
|
|
99
|
+
<% end %>
|
|
100
|
+
</tbody>
|
|
101
|
+
</table>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<p>
|
|
105
|
+
<% if @page.page > 1 %>
|
|
106
|
+
<a href="<%= h eg_page_path(@selector, @page.page - 1) %>">← <%= t('PreviousPage') %></a>
|
|
107
|
+
<% end %>
|
|
108
|
+
<%= t('PageOf', page: @page.page, pages: @page.pages) %>
|
|
109
|
+
<% if @page.page < @page.pages %>
|
|
110
|
+
<a href="<%= h eg_page_path(@selector, @page.page + 1) %>"><%= t('NextPage') %> →</a>
|
|
111
|
+
<% end %>
|
|
112
|
+
</p>
|
|
113
|
+
<% end %>
|
|
114
|
+
</section>
|