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.
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ module ErrorGroups
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sidekiq/error_groups"
@@ -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
@@ -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}"
@@ -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}"
@@ -0,0 +1,114 @@
1
+ <section>
2
+ <header>
3
+ <h1><%= h eg_selector_label(@selector) %></h1>
4
+ <p><a href="<%= h eg_index_path %>">&larr; <%= 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) %>">&larr; <%= 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') %> &rarr;</a>
111
+ <% end %>
112
+ </p>
113
+ <% end %>
114
+ </section>