sidekiq 7.1.6 → 7.2.4

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/Changes.md +68 -0
  3. data/README.md +2 -2
  4. data/bin/multi_queue_bench +271 -0
  5. data/lib/sidekiq/api.rb +77 -11
  6. data/lib/sidekiq/cli.rb +3 -1
  7. data/lib/sidekiq/config.rb +4 -4
  8. data/lib/sidekiq/deploy.rb +2 -2
  9. data/lib/sidekiq/job.rb +1 -1
  10. data/lib/sidekiq/job_retry.rb +3 -3
  11. data/lib/sidekiq/launcher.rb +6 -4
  12. data/lib/sidekiq/logger.rb +1 -1
  13. data/lib/sidekiq/metrics/query.rb +4 -1
  14. data/lib/sidekiq/metrics/tracking.rb +7 -3
  15. data/lib/sidekiq/middleware/current_attributes.rb +1 -1
  16. data/lib/sidekiq/paginator.rb +2 -2
  17. data/lib/sidekiq/processor.rb +1 -1
  18. data/lib/sidekiq/rails.rb +7 -3
  19. data/lib/sidekiq/redis_client_adapter.rb +16 -0
  20. data/lib/sidekiq/redis_connection.rb +3 -6
  21. data/lib/sidekiq/scheduled.rb +2 -2
  22. data/lib/sidekiq/testing.rb +9 -3
  23. data/lib/sidekiq/transaction_aware_client.rb +7 -0
  24. data/lib/sidekiq/version.rb +1 -1
  25. data/lib/sidekiq/web/action.rb +5 -0
  26. data/lib/sidekiq/web/application.rb +32 -4
  27. data/lib/sidekiq/web/csrf_protection.rb +8 -5
  28. data/lib/sidekiq/web/helpers.rb +14 -15
  29. data/sidekiq.gemspec +1 -1
  30. data/web/assets/javascripts/application.js +21 -0
  31. data/web/assets/javascripts/dashboard-charts.js +14 -0
  32. data/web/assets/javascripts/dashboard.js +7 -9
  33. data/web/assets/javascripts/metrics.js +34 -0
  34. data/web/assets/stylesheets/application-rtl.css +10 -0
  35. data/web/assets/stylesheets/application.css +22 -0
  36. data/web/views/_footer.erb +13 -1
  37. data/web/views/_metrics_period_select.erb +1 -1
  38. data/web/views/_summary.erb +7 -7
  39. data/web/views/busy.erb +7 -7
  40. data/web/views/dashboard.erb +23 -33
  41. data/web/views/filtering.erb +4 -4
  42. data/web/views/metrics.erb +36 -27
  43. data/web/views/metrics_for_job.erb +26 -35
  44. data/web/views/queues.erb +6 -2
  45. metadata +5 -4
@@ -20,7 +20,8 @@ module Sidekiq
20
20
  end
21
21
 
22
22
  # Get metric data for all jobs from the last hour
23
- def top_jobs(minutes: 60)
23
+ # +class_filter+: return only results for classes matching filter
24
+ def top_jobs(class_filter: nil, minutes: 60)
24
25
  result = Result.new
25
26
 
26
27
  time = @time
@@ -39,6 +40,7 @@ module Sidekiq
39
40
  redis_results.each do |hash|
40
41
  hash.each do |k, v|
41
42
  kls, metric = k.split("|")
43
+ next if class_filter && !class_filter.match?(kls)
42
44
  result.job_results[kls].add_metric metric, time, v.to_i
43
45
  end
44
46
  time -= 60
@@ -117,6 +119,7 @@ module Sidekiq
117
119
 
118
120
  def total_avg(metric = "ms")
119
121
  completed = totals["p"] - totals["f"]
122
+ return 0 if completed.zero?
120
123
  totals[metric].to_f / completed
121
124
  end
122
125
 
@@ -103,12 +103,16 @@ module Sidekiq
103
103
  def reset
104
104
  @lock.synchronize {
105
105
  array = [@totals, @jobs, @grams]
106
- @totals = Hash.new(0)
107
- @jobs = Hash.new(0)
108
- @grams = Hash.new { |hash, key| hash[key] = Histogram.new(key) }
106
+ reset_instance_variables
109
107
  array
110
108
  }
111
109
  end
110
+
111
+ def reset_instance_variables
112
+ @totals = Hash.new(0)
113
+ @jobs = Hash.new(0)
114
+ @grams = Hash.new { |hash, key| hash[key] = Histogram.new(key) }
115
+ end
112
116
  end
113
117
 
114
118
  class Middleware
@@ -54,7 +54,7 @@ module Sidekiq
54
54
  cattrs_to_reset << constklass
55
55
 
56
56
  job[key].each do |(attribute, value)|
57
- constklass.public_send("#{attribute}=", value)
57
+ constklass.public_send(:"#{attribute}=", value)
58
58
  end
59
59
  end
60
60
  end
@@ -19,9 +19,9 @@ module Sidekiq
19
19
  total_size, items = conn.multi { |transaction|
20
20
  transaction.zcard(key)
21
21
  if rev
22
- transaction.zrange(key, starting, ending, "REV", withscores: true)
22
+ transaction.zrange(key, starting, ending, "REV", "withscores")
23
23
  else
24
- transaction.zrange(key, starting, ending, withscores: true)
24
+ transaction.zrange(key, starting, ending, "withscores")
25
25
  end
26
26
  }
27
27
  [current_page, total_size, items]
@@ -187,7 +187,7 @@ module Sidekiq
187
187
  # we didn't properly finish it.
188
188
  rescue Sidekiq::JobRetry::Handled => h
189
189
  # this is the common case: job raised error and Sidekiq::JobRetry::Handled
190
- # signals that we created a retry successfully. We can acknowlege the job.
190
+ # signals that we created a retry successfully. We can acknowledge the job.
191
191
  ack = true
192
192
  e = h.cause || h
193
193
  handle_exception(e, {context: "Job raised exception", job: job_hash})
data/lib/sidekiq/rails.rb CHANGED
@@ -20,6 +20,10 @@ module Sidekiq
20
20
  def inspect
21
21
  "#<Sidekiq::Rails::Reloader @app=#{@app.class.name}>"
22
22
  end
23
+
24
+ def to_hash
25
+ {app: @app.class.name}
26
+ end
23
27
  end
24
28
 
25
29
  # By including the Options module, we allow AJs to directly control sidekiq features
@@ -56,10 +60,10 @@ module Sidekiq
56
60
  # This is the integration code necessary so that if a job uses `Rails.logger.info "Hello"`,
57
61
  # it will appear in the Sidekiq console with all of the job context.
58
62
  unless ::Rails.logger == config.logger || ::ActiveSupport::Logger.logger_outputs_to?(::Rails.logger, $stdout)
59
- if ::Rails::VERSION::STRING < "7.1"
60
- ::Rails.logger.extend(::ActiveSupport::Logger.broadcast(config.logger))
61
- else
63
+ if ::Rails.logger.respond_to?(:broadcast_to)
62
64
  ::Rails.logger.broadcast_to(config.logger)
65
+ else
66
+ ::Rails.logger.extend(::ActiveSupport::Logger.broadcast(config.logger))
63
67
  end
64
68
  end
65
69
  end
@@ -21,6 +21,22 @@ module Sidekiq
21
21
  @client.call("EVALSHA", sha, keys.size, *keys, *argv)
22
22
  end
23
23
 
24
+ # this is the set of Redis commands used by Sidekiq. Not guaranteed
25
+ # to be comprehensive, we use this as a performance enhancement to
26
+ # avoid calling method_missing on most commands
27
+ USED_COMMANDS = %w[bitfield bitfield_ro del exists expire flushdb
28
+ get hdel hget hgetall hincrby hlen hmget hset hsetnx incr incrby
29
+ lindex llen lmove lpop lpush lrange lrem mget mset ping pttl
30
+ publish rpop rpush sadd scard script set sismember smembers
31
+ srem ttl type unlink zadd zcard zincrby zrange zrem
32
+ zremrangebyrank zremrangebyscore]
33
+
34
+ USED_COMMANDS.each do |name|
35
+ define_method(name) do |*args, **kwargs|
36
+ @client.call(name, *args, **kwargs)
37
+ end
38
+ end
39
+
24
40
  private
25
41
 
26
42
  # this allows us to use methods like `conn.hmset(...)` instead of having to use
@@ -39,9 +39,8 @@ module Sidekiq
39
39
  uri.password = redacted
40
40
  scrubbed_options[:url] = uri.to_s
41
41
  end
42
- if scrubbed_options[:password]
43
- scrubbed_options[:password] = redacted
44
- end
42
+ scrubbed_options[:password] = redacted if scrubbed_options[:password]
43
+ scrubbed_options[:sentinel_password] = redacted if scrubbed_options[:sentinel_password]
45
44
  scrubbed_options[:sentinels]&.each do |sentinel|
46
45
  sentinel[:password] = redacted if sentinel[:password]
47
46
  end
@@ -67,9 +66,7 @@ module Sidekiq
67
66
  EOM
68
67
  end
69
68
 
70
- ENV[
71
- p || "REDIS_URL"
72
- ]
69
+ ENV[p.to_s] || ENV["REDIS_URL"]
73
70
  end
74
71
  end
75
72
  end
@@ -144,7 +144,7 @@ module Sidekiq
144
144
  # In the example above, each process should schedule every 10 seconds on average. We special
145
145
  # case smaller clusters to add 50% so they would sleep somewhere between 5 and 15 seconds.
146
146
  # As we run more processes, the scheduling interval average will approach an even spread
147
- # between 0 and poll interval so we don't need this artifical boost.
147
+ # between 0 and poll interval so we don't need this artificial boost.
148
148
  #
149
149
  count = process_count
150
150
  interval = poll_interval_average(count)
@@ -193,7 +193,7 @@ module Sidekiq
193
193
  # should never depend on sidekiq/api.
194
194
  def cleanup
195
195
  # dont run cleanup more than once per minute
196
- return 0 unless redis { |conn| conn.set("process_cleanup", "1", nx: true, ex: 60) }
196
+ return 0 unless redis { |conn| conn.set("process_cleanup", "1", "NX", "EX", "60") }
197
197
 
198
198
  count = 0
199
199
  redis do |conn|
@@ -5,6 +5,7 @@ require "sidekiq"
5
5
 
6
6
  module Sidekiq
7
7
  class Testing
8
+ class TestModeAlreadySetError < RuntimeError; end
8
9
  class << self
9
10
  attr_accessor :__global_test_mode
10
11
 
@@ -12,8 +13,13 @@ module Sidekiq
12
13
  # all threads. Calling with a block only affects the current Thread.
13
14
  def __set_test_mode(mode)
14
15
  if block_given?
16
+ # Reentrant testing modes will lead to a rat's nest of code which is
17
+ # hard to reason about. You can set the testing mode once globally and
18
+ # you can override that global setting once per-thread.
19
+ raise TestModeAlreadySetError, "Nesting test modes is not supported" if __local_test_mode
20
+
21
+ self.__local_test_mode = mode
15
22
  begin
16
- self.__local_test_mode = mode
17
23
  yield
18
24
  ensure
19
25
  self.__local_test_mode = nil
@@ -106,7 +112,7 @@ module Sidekiq
106
112
  # The Queues class is only for testing the fake queue implementation.
107
113
  # There are 2 data structures involved in tandem. This is due to the
108
114
  # Rspec syntax of change(HardJob.jobs, :size). It keeps a reference
109
- # to the array. Because the array was dervied from a filter of the total
115
+ # to the array. Because the array was derived from a filter of the total
110
116
  # jobs enqueued, it appeared as though the array didn't change.
111
117
  #
112
118
  # To solve this, we'll keep 2 hashes containing the jobs. One with keys based
@@ -272,7 +278,7 @@ module Sidekiq
272
278
  def perform_one
273
279
  raise(EmptyQueueError, "perform_one called with empty job queue") if jobs.empty?
274
280
  next_job = jobs.first
275
- Queues.delete_for(next_job["jid"], queue, to_s)
281
+ Queues.delete_for(next_job["jid"], next_job["queue"], to_s)
276
282
  process_job(next_job)
277
283
  end
278
284
 
@@ -9,7 +9,14 @@ module Sidekiq
9
9
  @redis_client = Client.new(pool: pool, config: config)
10
10
  end
11
11
 
12
+ def batching?
13
+ Thread.current[:sidekiq_batch]
14
+ end
15
+
12
16
  def push(item)
17
+ # 6160 we can't support both Sidekiq::Batch and transactions.
18
+ return @redis_client.push(item) if batching?
19
+
13
20
  # pre-allocate the JID so we can return it immediately and
14
21
  # save it to the database as part of the transaction.
15
22
  item["jid"] ||= SecureRandom.hex(12)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sidekiq
4
- VERSION = "7.1.6"
4
+ VERSION = "7.2.4"
5
5
  MAJOR = 7
6
6
  end
@@ -22,6 +22,11 @@ module Sidekiq
22
22
  throw :halt, [302, {Web::LOCATION => "#{request.base_url}#{location}"}, []]
23
23
  end
24
24
 
25
+ def reload_page
26
+ current_location = request.referer.gsub(request.base_url, "")
27
+ redirect current_location
28
+ end
29
+
25
30
  def params
26
31
  indifferent_hash = Hash.new { |hash, key| hash[key.to_s] if Symbol === key }
27
32
 
@@ -15,7 +15,7 @@ module Sidekiq
15
15
  "manifest-src 'self'",
16
16
  "media-src 'self'",
17
17
  "object-src 'none'",
18
- "script-src 'self' https: http: 'unsafe-inline'",
18
+ "script-src 'self' https: http:",
19
19
  "style-src 'self' https: http: 'unsafe-inline'",
20
20
  "worker-src 'self'",
21
21
  "base-uri 'self'"
@@ -49,9 +49,9 @@ module Sidekiq
49
49
 
50
50
  head "/" do
51
51
  # HEAD / is the cheapest heartbeat possible,
52
- # it hits Redis to ensure connectivity
53
- Sidekiq.redis { |c| c.llen("queue:default") }
54
- ""
52
+ # it hits Redis to ensure connectivity and returns
53
+ # the size of the default queue
54
+ Sidekiq.redis { |c| c.llen("queue:default") }.to_s
55
55
  end
56
56
 
57
57
  get "/" do
@@ -330,6 +330,22 @@ module Sidekiq
330
330
 
331
331
  ########
332
332
  # Filtering
333
+
334
+ get "/filter/metrics" do
335
+ redirect "#{root_path}metrics"
336
+ end
337
+
338
+ post "/filter/metrics" do
339
+ x = params[:substr]
340
+ q = Sidekiq::Metrics::Query.new
341
+ @period = h((params[:period] || "")[0..1])
342
+ @periods = METRICS_PERIODS
343
+ minutes = @periods.fetch(@period, @periods.values.first)
344
+ @query_result = q.top_jobs(minutes: minutes, class_filter: Regexp.new(Regexp.escape(x), Regexp::IGNORECASE))
345
+
346
+ erb :metrics
347
+ end
348
+
333
349
  get "/filter/retries" do
334
350
  x = params[:substr]
335
351
  return redirect "#{root_path}retries" unless x && x != ""
@@ -378,6 +394,18 @@ module Sidekiq
378
394
  erb :morgue
379
395
  end
380
396
 
397
+ post "/change_locale" do
398
+ locale = params["locale"]
399
+
400
+ match = available_locales.find { |available|
401
+ locale == available
402
+ }
403
+
404
+ session[:locale] = match if match
405
+
406
+ reload_page
407
+ end
408
+
381
409
  def call(env)
382
410
  action = self.class.match(env)
383
411
  return [404, {Rack::CONTENT_TYPE => "text/plain", Web::X_CASCADE => "pass"}, ["Not Found"]] unless action
@@ -27,7 +27,6 @@
27
27
  # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
28
28
 
29
29
  require "securerandom"
30
- require "base64"
31
30
  require "rack/request"
32
31
 
33
32
  module Sidekiq
@@ -57,7 +56,7 @@ module Sidekiq
57
56
  end
58
57
 
59
58
  def logger(env)
60
- @logger ||= (env["rack.logger"] || ::Logger.new(env["rack.errors"]))
59
+ @logger ||= env["rack.logger"] || ::Logger.new(env["rack.errors"])
61
60
  end
62
61
 
63
62
  def deny(env)
@@ -116,7 +115,7 @@ module Sidekiq
116
115
  sess = session(env)
117
116
  localtoken = sess[:csrf]
118
117
 
119
- # Checks that Rack::Session::Cookie actualy contains the csrf toekn
118
+ # Checks that Rack::Session::Cookie actually contains the csrf token
120
119
  return false if localtoken.nil?
121
120
 
122
121
  # Rotate the session token after every use
@@ -143,7 +142,7 @@ module Sidekiq
143
142
  one_time_pad = SecureRandom.random_bytes(token.length)
144
143
  encrypted_token = xor_byte_strings(one_time_pad, token)
145
144
  masked_token = one_time_pad + encrypted_token
146
- Base64.urlsafe_encode64(masked_token)
145
+ encode_token(masked_token)
147
146
  end
148
147
 
149
148
  # Essentially the inverse of +mask_token+.
@@ -168,8 +167,12 @@ module Sidekiq
168
167
  ::Rack::Utils.secure_compare(token.to_s, decode_token(local).to_s)
169
168
  end
170
169
 
170
+ def encode_token(token)
171
+ [token].pack("m0").tr("+/", "-_")
172
+ end
173
+
171
174
  def decode_token(token)
172
- Base64.urlsafe_decode64(token)
175
+ token.tr("-_", "+/").unpack1("m0")
173
176
  end
174
177
 
175
178
  def xor_byte_strings(s1, s2)
@@ -21,6 +21,10 @@ module Sidekiq
21
21
  end
22
22
  end
23
23
 
24
+ def to_json(x)
25
+ Sidekiq.dump_json(x)
26
+ end
27
+
24
28
  def singularize(str, count)
25
29
  if count == 1 && str.respond_to?(:singularize) # rails
26
30
  str.singularize
@@ -117,6 +121,10 @@ module Sidekiq
117
121
  #
118
122
  # Inspiration taken from https://github.com/iain/http_accept_language/blob/master/lib/http_accept_language/parser.rb
119
123
  def locale
124
+ # session[:locale] is set via the locale selector from the footer
125
+ # defined?(session) && session are used to avoid exceptions when running tests
126
+ return session[:locale] if defined?(session) && session&.[](:locale)
127
+
120
128
  @locale ||= begin
121
129
  matched_locale = user_preferred_languages.map { |preferred|
122
130
  preferred_language = preferred.split("-", 2).first
@@ -292,23 +300,13 @@ module Sidekiq
292
300
  elsif rss_kb < 10_000_000
293
301
  "#{number_with_delimiter((rss_kb / 1024.0).to_i)} MB"
294
302
  else
295
- "#{number_with_delimiter((rss_kb / (1024.0 * 1024.0)).round(1))} GB"
303
+ "#{number_with_delimiter((rss_kb / (1024.0 * 1024.0)), precision: 1)} GB"
296
304
  end
297
305
  end
298
306
 
299
- def number_with_delimiter(number)
300
- return "" if number.nil?
301
-
302
- begin
303
- Float(number)
304
- rescue ArgumentError, TypeError
305
- return number
306
- end
307
-
308
- options = {delimiter: ",", separator: "."}
309
- parts = number.to_s.to_str.split(".")
310
- parts[0].gsub!(/(\d)(?=(\d\d\d)+(?!\d))/, "\\1#{options[:delimiter]}")
311
- parts.join(options[:separator])
307
+ def number_with_delimiter(number, options = {})
308
+ precision = options[:precision] || 0
309
+ %(<span data-nwp="#{precision}">#{number.round(precision)}</span>)
312
310
  end
313
311
 
314
312
  def h(text)
@@ -346,7 +344,8 @@ module Sidekiq
346
344
  end
347
345
 
348
346
  def pollable?
349
- !(current_path == "" || current_path.start_with?("metrics"))
347
+ # there's no point to refreshing the metrics pages every N seconds
348
+ !(current_path == "" || current_path.index("metrics"))
350
349
  end
351
350
 
352
351
  def retry_or_delete_or_kill(job, params)
data/sidekiq.gemspec CHANGED
@@ -23,7 +23,7 @@ Gem::Specification.new do |gem|
23
23
  "rubygems_mfa_required" => "true"
24
24
  }
25
25
 
26
- gem.add_dependency "redis-client", ">= 0.14.0"
26
+ gem.add_dependency "redis-client", ">= 0.19.0"
27
27
  gem.add_dependency "connection_pool", ">= 2.3.0"
28
28
  gem.add_dependency "rack", ">= 2.2.4"
29
29
  gem.add_dependency "concurrent-ruby", "< 2"
@@ -33,6 +33,7 @@ function addListeners() {
33
33
 
34
34
  addShiftClickListeners()
35
35
  updateFuzzyTimes();
36
+ updateNumbers();
36
37
  setLivePollFromUrl();
37
38
 
38
39
  var buttons = document.querySelectorAll(".live-poll");
@@ -46,6 +47,8 @@ function addListeners() {
46
47
  scheduleLivePoll();
47
48
  }
48
49
  }
50
+
51
+ document.getElementById("locale-select").addEventListener("change", updateLocale);
49
52
  }
50
53
 
51
54
  function addPollingListeners(_event) {
@@ -102,6 +105,20 @@ function updateFuzzyTimes() {
102
105
  t.cancel();
103
106
  }
104
107
 
108
+ function updateNumbers() {
109
+ document.querySelectorAll("[data-nwp]").forEach(node => {
110
+ let number = parseFloat(node.textContent);
111
+ let precision = parseInt(node.dataset["nwp"] || 0);
112
+ if (typeof number === "number") {
113
+ let formatted = number.toLocaleString(undefined, {
114
+ minimumFractionDigits: precision,
115
+ maximumFractionDigits: precision,
116
+ });
117
+ node.textContent = formatted;
118
+ }
119
+ });
120
+ }
121
+
105
122
  function setLivePollFromUrl() {
106
123
  var url_params = new URL(window.location.href).searchParams
107
124
 
@@ -160,3 +177,7 @@ function replacePage(text) {
160
177
  function showError(error) {
161
178
  console.error(error)
162
179
  }
180
+
181
+ function updateLocale(event) {
182
+ event.target.form.submit();
183
+ };
@@ -86,6 +86,7 @@ class RealtimeChart extends DashboardChart {
86
86
  updateStatsSummary(this.stats.sidekiq);
87
87
  updateRedisStats(this.stats.redis);
88
88
  updateFooterUTCTime(this.stats.server_utc_time);
89
+ updateNumbers();
89
90
  pulseBeacon();
90
91
 
91
92
  this.stats = stats;
@@ -166,3 +167,16 @@ class RealtimeChart extends DashboardChart {
166
167
  };
167
168
  }
168
169
  }
170
+
171
+ var rc = document.getElementById("realtime-chart")
172
+ if (rc != null) {
173
+ var rtc = new RealtimeChart(rc, JSON.parse(rc.textContent))
174
+ rtc.registerLegend(document.getElementById("realtime-legend"))
175
+ window.realtimeChart = rtc
176
+ }
177
+
178
+ var hc = document.getElementById("history-chart")
179
+ if (hc != null) {
180
+ var htc = new DashboardChart(hc, JSON.parse(hc.textContent))
181
+ window.historyChart = htc
182
+ }
@@ -1,15 +1,13 @@
1
1
  Sidekiq = {};
2
2
 
3
- var nf = new Intl.NumberFormat();
4
-
5
3
  var updateStatsSummary = function(data) {
6
- document.getElementById("txtProcessed").innerText = nf.format(data.processed);
7
- document.getElementById("txtFailed").innerText = nf.format(data.failed);
8
- document.getElementById("txtBusy").innerText = nf.format(data.busy);
9
- document.getElementById("txtScheduled").innerText = nf.format(data.scheduled);
10
- document.getElementById("txtRetries").innerText = nf.format(data.retries);
11
- document.getElementById("txtEnqueued").innerText = nf.format(data.enqueued);
12
- document.getElementById("txtDead").innerText = nf.format(data.dead);
4
+ document.getElementById("txtProcessed").innerText = data.processed;
5
+ document.getElementById("txtFailed").innerText = data.failed;
6
+ document.getElementById("txtBusy").innerText = data.busy;
7
+ document.getElementById("txtScheduled").innerText = data.scheduled;
8
+ document.getElementById("txtRetries").innerText = data.retries;
9
+ document.getElementById("txtEnqueued").innerText = data.enqueued;
10
+ document.getElementById("txtDead").innerText = data.dead;
13
11
  }
14
12
 
15
13
  var updateRedisStats = function(data) {
@@ -262,3 +262,37 @@ class HistBubbleChart extends BaseChart {
262
262
  };
263
263
  }
264
264
  }
265
+
266
+ var ch = document.getElementById("job-metrics-overview-chart");
267
+ if (ch != null) {
268
+ var jm = new JobMetricsOverviewChart(ch, JSON.parse(ch.textContent));
269
+ document.querySelectorAll(".metrics-swatch-wrapper > input[type=checkbox]").forEach((imp) => {
270
+ jm.registerSwatch(imp.id)
271
+ });
272
+ window.jobMetricsChart = jm;
273
+ }
274
+
275
+ var htc = document.getElementById("hist-totals-chart");
276
+ if (htc != null) {
277
+ var tc = new HistTotalsChart(htc, JSON.parse(htc.textContent));
278
+ window.histTotalsChart = tc
279
+ }
280
+
281
+ var hbc = document.getElementById("hist-bubble-chart");
282
+ if (hbc != null) {
283
+ var bc = new HistBubbleChart(hbc, JSON.parse(hbc.textContent));
284
+ window.histBubbleChart = bc
285
+ }
286
+
287
+ var form = document.getElementById("metrics-form")
288
+ document.querySelectorAll("#period-selector").forEach(node => {
289
+ node.addEventListener("input", debounce(() => form.submit()))
290
+ })
291
+
292
+ function debounce(func, timeout = 300) {
293
+ let timer;
294
+ return (...args) => {
295
+ clearTimeout(timer);
296
+ timer = setTimeout(() => { func.apply(this, args); }, timeout);
297
+ };
298
+ }
@@ -151,3 +151,13 @@ div.interval-slider {
151
151
  padding-left: 5px;
152
152
  }
153
153
  }
154
+
155
+ #locale-select {
156
+ float: right;
157
+ }
158
+
159
+ @media (max-width: 767px) {
160
+ #locale-select {
161
+ float: none;
162
+ }
163
+ }
@@ -370,6 +370,15 @@ img.smallogo {
370
370
  .stat p {
371
371
  font-size: 0.9em;
372
372
  }
373
+
374
+ .num {
375
+ font-family: monospace;
376
+ }
377
+
378
+ td.num {
379
+ text-align: right;
380
+ }
381
+
373
382
  @media (max-width: 767px) {
374
383
  .stats-container {
375
384
  display: block;
@@ -722,3 +731,16 @@ div.interval-slider input {
722
731
  canvas {
723
732
  margin: 20px 0 30px;
724
733
  }
734
+
735
+ #locale-select {
736
+ float: left;
737
+ margin: 8px 15px;
738
+ }
739
+
740
+ @media (max-width: 767px) {
741
+ #locale-select {
742
+ float: none;
743
+ width: auto;
744
+ margin: 15px auto;
745
+ }
746
+ }
@@ -15,7 +15,19 @@
15
15
  <p class="navbar-text"><a rel=help href="https://github.com/sidekiq/sidekiq/wiki">docs</a></p>
16
16
  </li>
17
17
  <li>
18
- <p class="navbar-text"><a rel=external href="https://github.com/sidekiq/sidekiq/tree/main/web/locales"><%= locale %></a></p>
18
+ <form id="locale-form" class="form-inline" action="<%= root_path %>change_locale" method="post">
19
+ <%= csrf_tag %>
20
+ <label class="sr-only" for="locale">Language</label>
21
+ <select id="locale-select" class="form-control" name="locale">
22
+ <% available_locales.each do |locale_option| %>
23
+ <% if locale_option == locale %>
24
+ <option selected value="<%= locale_option %>"><%= locale_option %></option>
25
+ <% else %>
26
+ <option value="<%= locale_option %>"><%= locale_option %></option>
27
+ <% end %>
28
+ <% end %>
29
+ </select>
30
+ </form>
19
31
  </li>
20
32
  </ul>
21
33
  </div>
@@ -1,5 +1,5 @@
1
1
  <div>
2
- <select class="form-control" onchange="window.location.href = '<%= path %>?period=' + event.target.value">
2
+ <select class="form-control" data-metric-period="<%= path %>">
3
3
  <% periods.each_key do |code| %>
4
4
 
5
5
  <% if code == period %>