sidekiq 7.2.4 → 7.3.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/Changes.md +35 -0
  3. data/README.md +1 -1
  4. data/lib/sidekiq/api.rb +1 -1
  5. data/lib/sidekiq/capsule.rb +3 -0
  6. data/lib/sidekiq/cli.rb +1 -0
  7. data/lib/sidekiq/client.rb +2 -2
  8. data/lib/sidekiq/config.rb +5 -1
  9. data/lib/sidekiq/fetch.rb +1 -1
  10. data/lib/sidekiq/iterable_job.rb +53 -0
  11. data/lib/sidekiq/job/interrupt_handler.rb +22 -0
  12. data/lib/sidekiq/job/iterable/active_record_enumerator.rb +53 -0
  13. data/lib/sidekiq/job/iterable/csv_enumerator.rb +47 -0
  14. data/lib/sidekiq/job/iterable/enumerators.rb +135 -0
  15. data/lib/sidekiq/job/iterable.rb +231 -0
  16. data/lib/sidekiq/job.rb +13 -2
  17. data/lib/sidekiq/job_logger.rb +23 -10
  18. data/lib/sidekiq/job_retry.rb +6 -1
  19. data/lib/sidekiq/middleware/current_attributes.rb +27 -11
  20. data/lib/sidekiq/processor.rb +11 -1
  21. data/lib/sidekiq/redis_client_adapter.rb +8 -5
  22. data/lib/sidekiq/redis_connection.rb +25 -1
  23. data/lib/sidekiq/version.rb +1 -1
  24. data/lib/sidekiq/web/action.rb +2 -1
  25. data/lib/sidekiq/web/application.rb +8 -4
  26. data/lib/sidekiq/web/helpers.rb +53 -7
  27. data/lib/sidekiq/web.rb +46 -1
  28. data/lib/sidekiq.rb +2 -1
  29. data/sidekiq.gemspec +2 -1
  30. data/web/assets/javascripts/application.js +6 -1
  31. data/web/assets/javascripts/dashboard-charts.js +22 -12
  32. data/web/assets/javascripts/dashboard.js +1 -1
  33. data/web/assets/stylesheets/application.css +13 -1
  34. data/web/locales/tr.yml +101 -0
  35. data/web/views/dashboard.erb +6 -6
  36. data/web/views/layout.erb +6 -6
  37. data/web/views/metrics.erb +4 -4
  38. data/web/views/metrics_for_job.erb +4 -4
  39. metadata +26 -5
data/lib/sidekiq/job.rb CHANGED
@@ -69,7 +69,11 @@ module Sidekiq
69
69
  # In practice, any option is allowed. This is the main mechanism to configure the
70
70
  # options for a specific job.
71
71
  def sidekiq_options(opts = {})
72
- opts = opts.transform_keys(&:to_s) # stringify
72
+ # stringify 2 levels of keys
73
+ opts = opts.to_h do |k, v|
74
+ [k.to_s, (Hash === v) ? v.transform_keys(&:to_s) : v]
75
+ end
76
+
73
77
  self.sidekiq_options_hash = get_sidekiq_options.merge(opts)
74
78
  end
75
79
 
@@ -155,6 +159,9 @@ module Sidekiq
155
159
 
156
160
  attr_accessor :jid
157
161
 
162
+ # This attribute is implementation-specific and not a public API
163
+ attr_accessor :_context
164
+
158
165
  def self.included(base)
159
166
  raise ArgumentError, "Sidekiq::Job cannot be included in an ActiveJob: #{base.name}" if base.ancestors.any? { |c| c.name == "ActiveJob::Base" }
160
167
 
@@ -166,6 +173,10 @@ module Sidekiq
166
173
  Sidekiq.logger
167
174
  end
168
175
 
176
+ def interrupted?
177
+ @_context&.stopping?
178
+ end
179
+
169
180
  # This helper class encapsulates the set options for `set`, e.g.
170
181
  #
171
182
  # SomeJob.set(queue: 'foo').perform_async(....)
@@ -366,7 +377,7 @@ module Sidekiq
366
377
 
367
378
  def build_client # :nodoc:
368
379
  pool = Thread.current[:sidekiq_redis_pool] || get_sidekiq_options["pool"] || Sidekiq.default_configuration.redis_pool
369
- client_class = get_sidekiq_options["client_class"] || Sidekiq::Client
380
+ client_class = Thread.current[:sidekiq_client_class] || get_sidekiq_options["client_class"] || Sidekiq::Client
370
381
  client_class.new(pool: pool)
371
382
  end
372
383
  end
@@ -2,23 +2,36 @@
2
2
 
3
3
  module Sidekiq
4
4
  class JobLogger
5
- def initialize(logger)
5
+ include Sidekiq::Component
6
+
7
+ def initialize(config)
8
+ @config = config
6
9
  @logger = logger
7
10
  end
8
11
 
12
+ # If true we won't do any job logging out of the box.
13
+ # The user is responsible for any logging.
14
+ def skip_default_logging?
15
+ config[:skip_default_job_logging]
16
+ end
17
+
9
18
  def call(item, queue)
10
- start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
11
- @logger.info("start")
19
+ return yield if skip_default_logging?
12
20
 
13
- yield
21
+ begin
22
+ start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
23
+ @logger.info("start")
14
24
 
15
- Sidekiq::Context.add(:elapsed, elapsed(start))
16
- @logger.info("done")
17
- rescue Exception
18
- Sidekiq::Context.add(:elapsed, elapsed(start))
19
- @logger.info("fail")
25
+ yield
26
+
27
+ Sidekiq::Context.add(:elapsed, elapsed(start))
28
+ @logger.info("done")
29
+ rescue Exception
30
+ Sidekiq::Context.add(:elapsed, elapsed(start))
31
+ @logger.info("fail")
20
32
 
21
- raise
33
+ raise
34
+ end
22
35
  end
23
36
 
24
37
  def prepare(job_hash, &block)
@@ -59,8 +59,13 @@ module Sidekiq
59
59
  # end
60
60
  #
61
61
  class JobRetry
62
+ # Handled means the job failed but has been dealt with
63
+ # (by creating a retry, rescheduling it, etc). It still
64
+ # needs to be logged and dispatched to error_handlers.
62
65
  class Handled < ::RuntimeError; end
63
66
 
67
+ # Skip means the job failed but Sidekiq does not need to
68
+ # create a retry, log it or send to error_handlers.
64
69
  class Skip < Handled; end
65
70
 
66
71
  include Sidekiq::Component
@@ -129,7 +134,7 @@ module Sidekiq
129
134
  process_retry(jobinst, msg, queue, e)
130
135
  # We've handled this error associated with this job, don't
131
136
  # need to handle it at the global level
132
- raise Skip
137
+ raise Handled
133
138
  end
134
139
 
135
140
  private
@@ -46,22 +46,38 @@ module Sidekiq
46
46
  end
47
47
 
48
48
  def call(_, job, _, &block)
49
- cattrs_to_reset = []
49
+ klass_attrs = {}
50
50
 
51
51
  @cattrs.each do |(key, strklass)|
52
- if job.has_key?(key)
53
- constklass = strklass.constantize
54
- cattrs_to_reset << constklass
52
+ next unless job.has_key?(key)
55
53
 
56
- job[key].each do |(attribute, value)|
57
- constklass.public_send(:"#{attribute}=", value)
58
- end
59
- end
54
+ klass_attrs[strklass.constantize] = job[key]
60
55
  end
61
56
 
62
- yield
63
- ensure
64
- cattrs_to_reset.each(&:reset)
57
+ wrap(klass_attrs.to_a, &block)
58
+ end
59
+
60
+ private
61
+
62
+ def wrap(klass_attrs, &block)
63
+ klass, attrs = klass_attrs.shift
64
+ return block.call unless klass
65
+
66
+ retried = false
67
+
68
+ begin
69
+ klass.set(attrs) do
70
+ wrap(klass_attrs, &block)
71
+ end
72
+ rescue NoMethodError
73
+ raise if retried
74
+
75
+ # It is possible that the `CurrentAttributes` definition
76
+ # was changed before the job started processing.
77
+ attrs = attrs.select { |attr| klass.respond_to?(attr) }
78
+ retried = true
79
+ retry
80
+ end
65
81
  end
66
82
  end
67
83
 
@@ -36,7 +36,7 @@ module Sidekiq
36
36
  @job = nil
37
37
  @thread = nil
38
38
  @reloader = Sidekiq.default_configuration[:reloader]
39
- @job_logger = (capsule.config[:job_logger] || Sidekiq::JobLogger).new(logger)
39
+ @job_logger = (capsule.config[:job_logger] || Sidekiq::JobLogger).new(capsule.config)
40
40
  @retrier = Sidekiq::JobRetry.new(capsule)
41
41
  end
42
42
 
@@ -58,6 +58,10 @@ module Sidekiq
58
58
  @thread.value if wait
59
59
  end
60
60
 
61
+ def stopping?
62
+ @done
63
+ end
64
+
61
65
  def start
62
66
  @thread ||= safe_thread("#{config.name}/processor", &method(:run))
63
67
  end
@@ -136,6 +140,7 @@ module Sidekiq
136
140
  klass = Object.const_get(job_hash["class"])
137
141
  inst = klass.new
138
142
  inst.jid = job_hash["jid"]
143
+ inst._context = self
139
144
  @retrier.local(inst, jobstr, queue) do
140
145
  yield inst
141
146
  end
@@ -185,6 +190,11 @@ module Sidekiq
185
190
  # Had to force kill this job because it didn't finish
186
191
  # within the timeout. Don't acknowledge the work since
187
192
  # we didn't properly finish it.
193
+ rescue Sidekiq::JobRetry::Skip => s
194
+ # Skip means we handled this error elsewhere. We don't
195
+ # need to log or report the error.
196
+ ack = true
197
+ raise s
188
198
  rescue Sidekiq::JobRetry::Handled => h
189
199
  # this is the common case: job raised error and Sidekiq::JobRetry::Handled
190
200
  # signals that we created a retry successfully. We can acknowledge the job.
@@ -64,6 +64,13 @@ module Sidekiq
64
64
  opts = client_opts(options)
65
65
  @config = if opts.key?(:sentinels)
66
66
  RedisClient.sentinel(**opts)
67
+ elsif opts.key?(:nodes)
68
+ # Sidekiq does not support Redis clustering but Sidekiq Enterprise's
69
+ # rate limiters are cluster-safe so we can scale to millions
70
+ # of rate limiters using a Redis cluster. This requires the
71
+ # `redis-cluster-client` gem.
72
+ # Sidekiq::Limiter.redis = { nodes: [...] }
73
+ RedisClient.cluster(**opts)
67
74
  else
68
75
  RedisClient.config(**opts)
69
76
  end
@@ -90,13 +97,9 @@ module Sidekiq
90
97
  opts.delete(:network_timeout)
91
98
  end
92
99
 
93
- if opts[:driver]
94
- opts[:driver] = opts[:driver].to_sym
95
- end
96
-
97
100
  opts[:name] = opts.delete(:master_name) if opts.key?(:master_name)
98
101
  opts[:role] = opts[:role].to_sym if opts.key?(:role)
99
- opts.delete(:url) if opts.key?(:sentinels)
102
+ opts[:driver] = opts[:driver].to_sym if opts.key?(:driver)
100
103
 
101
104
  # Issue #3303, redis-rb will silently retry an operation.
102
105
  # This can lead to duplicate jobs if Sidekiq::Client's LPUSH
@@ -8,17 +8,28 @@ module Sidekiq
8
8
  module RedisConnection
9
9
  class << self
10
10
  def create(options = {})
11
- symbolized_options = options.transform_keys(&:to_sym)
11
+ symbolized_options = deep_symbolize_keys(options)
12
12
  symbolized_options[:url] ||= determine_redis_provider
13
13
 
14
14
  logger = symbolized_options.delete(:logger)
15
15
  logger&.info { "Sidekiq #{Sidekiq::VERSION} connecting to Redis with options #{scrub(symbolized_options)}" }
16
16
 
17
17
  raise "Sidekiq 7+ does not support Redis protocol 2" if symbolized_options[:protocol] == 2
18
+
19
+ safe = !!symbolized_options.delete(:cluster_safe)
20
+ raise ":nodes not allowed, Sidekiq is not safe to run on Redis Cluster" if !safe && symbolized_options.key?(:nodes)
21
+
18
22
  size = symbolized_options.delete(:size) || 5
19
23
  pool_timeout = symbolized_options.delete(:pool_timeout) || 1
20
24
  pool_name = symbolized_options.delete(:pool_name)
21
25
 
26
+ # Default timeout in redis-client is 1 second, which can be too aggressive
27
+ # if the Sidekiq process is CPU-bound. With 10-15 threads and a thread quantum of 100ms,
28
+ # it can be easy to get the occasional ReadTimeoutError. You can still provide
29
+ # a smaller timeout explicitly:
30
+ # config.redis = { url: "...", timeout: 1 }
31
+ symbolized_options[:timeout] ||= 3
32
+
22
33
  redis_config = Sidekiq::RedisClientAdapter.new(symbolized_options)
23
34
  ConnectionPool.new(timeout: pool_timeout, size: size, name: pool_name) do
24
35
  redis_config.new_client
@@ -27,6 +38,19 @@ module Sidekiq
27
38
 
28
39
  private
29
40
 
41
+ def deep_symbolize_keys(object)
42
+ case object
43
+ when Hash
44
+ object.each_with_object({}) do |(key, value), result|
45
+ result[key.to_sym] = deep_symbolize_keys(value)
46
+ end
47
+ when Array
48
+ object.map { |e| deep_symbolize_keys(e) }
49
+ else
50
+ object
51
+ end
52
+ end
53
+
30
54
  def scrub(options)
31
55
  redacted = "REDACTED"
32
56
 
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sidekiq
4
- VERSION = "7.2.4"
4
+ VERSION = "7.3.0"
5
5
  MAJOR = 7
6
6
  end
@@ -47,7 +47,8 @@ module Sidekiq
47
47
  def erb(content, options = {})
48
48
  if content.is_a? Symbol
49
49
  unless respond_to?(:"_erb_#{content}")
50
- src = ERB.new(File.read("#{Web.settings.views}/#{content}.erb")).src
50
+ views = options[:views] || Web.settings.views
51
+ src = ERB.new(File.read("#{views}/#{content}.erb")).src
51
52
  WebAction.class_eval <<-RUBY, __FILE__, __LINE__ + 1
52
53
  def _erb_#{content}
53
54
  #{src}
@@ -5,7 +5,7 @@ module Sidekiq
5
5
  extend WebRouter
6
6
 
7
7
  REDIS_KEYS = %w[redis_version uptime_in_days connected_clients used_memory_human used_memory_peak_human]
8
- CSP_HEADER = [
8
+ CSP_HEADER_TEMPLATE = [
9
9
  "default-src 'self' https: http:",
10
10
  "child-src 'self'",
11
11
  "connect-src 'self' https: http: wss: ws:",
@@ -15,8 +15,8 @@ module Sidekiq
15
15
  "manifest-src 'self'",
16
16
  "media-src 'self'",
17
17
  "object-src 'none'",
18
- "script-src 'self' https: http:",
19
- "style-src 'self' https: http: 'unsafe-inline'",
18
+ "script-src 'self' 'nonce-!placeholder!'",
19
+ "style-src 'self' https: http: 'unsafe-inline'", # TODO Nonce in 8.0
20
20
  "worker-src 'self'",
21
21
  "base-uri 'self'"
22
22
  ].join("; ").freeze
@@ -428,13 +428,17 @@ module Sidekiq
428
428
  Rack::CONTENT_TYPE => "text/html",
429
429
  Rack::CACHE_CONTROL => "private, no-store",
430
430
  Web::CONTENT_LANGUAGE => action.locale,
431
- Web::CONTENT_SECURITY_POLICY => CSP_HEADER
431
+ Web::CONTENT_SECURITY_POLICY => process_csp(env, CSP_HEADER_TEMPLATE)
432
432
  }
433
433
  # we'll let Rack calculate Content-Length for us.
434
434
  [200, headers, [resp]]
435
435
  end
436
436
  end
437
437
 
438
+ def process_csp(env, input)
439
+ input.gsub("!placeholder!", env[:csp_nonce])
440
+ end
441
+
438
442
  def self.helpers(mod = nil, &block)
439
443
  if block
440
444
  WebAction.class_eval(&block)
@@ -6,8 +6,51 @@ require "yaml"
6
6
  require "cgi"
7
7
 
8
8
  module Sidekiq
9
- # This is not a public API
9
+ # These methods are available to pages within the Web UI and UI extensions.
10
+ # They are not public APIs for applications to use.
10
11
  module WebHelpers
12
+ def style_tag(location, **kwargs)
13
+ global = location.match?(/:\/\//)
14
+ location = root_path + location if !global && !location.start_with?(root_path)
15
+ attrs = {
16
+ type: "text/css",
17
+ media: "screen",
18
+ rel: "stylesheet",
19
+ nonce: csp_nonce,
20
+ href: location
21
+ }
22
+ html_tag(:link, attrs.merge(kwargs))
23
+ end
24
+
25
+ def script_tag(location, **kwargs)
26
+ global = location.match?(/:\/\//)
27
+ location = root_path + location if !global && !location.start_with?(root_path)
28
+ attrs = {
29
+ type: "text/javascript",
30
+ nonce: csp_nonce,
31
+ src: location
32
+ }
33
+ html_tag(:script, attrs.merge(kwargs)) {}
34
+ end
35
+
36
+ # NB: keys and values are not escaped; do not allow user input
37
+ # in the attributes
38
+ private def html_tag(tagname, attrs)
39
+ s = "<#{tagname}"
40
+ attrs.each_pair do |k, v|
41
+ next unless v
42
+ s << " #{k}=\"#{v}\""
43
+ end
44
+ if block_given?
45
+ s << ">"
46
+ yield s
47
+ s << "</#{tagname}>"
48
+ else
49
+ s << " />"
50
+ end
51
+ s
52
+ end
53
+
11
54
  def strings(lang)
12
55
  @strings ||= {}
13
56
 
@@ -46,7 +89,7 @@ module Sidekiq
46
89
  end
47
90
 
48
91
  def available_locales
49
- @available_locales ||= locale_files.map { |path| File.basename(path, ".yml") }.uniq
92
+ @available_locales ||= Set.new(locale_files.map { |path| File.basename(path, ".yml") })
50
93
  end
51
94
 
52
95
  def find_locale_files(lang)
@@ -122,10 +165,9 @@ module Sidekiq
122
165
  # Inspiration taken from https://github.com/iain/http_accept_language/blob/master/lib/http_accept_language/parser.rb
123
166
  def locale
124
167
  # 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
-
128
- @locale ||= begin
168
+ @locale ||= if (l = session&.fetch(:locale, nil)) && available_locales.include?(l)
169
+ l
170
+ else
129
171
  matched_locale = user_preferred_languages.map { |preferred|
130
172
  preferred_language = preferred.split("-", 2).first
131
173
 
@@ -267,6 +309,10 @@ module Sidekiq
267
309
  "<input type='hidden' name='authenticity_token' value='#{env[:csrf_token]}'/>"
268
310
  end
269
311
 
312
+ def csp_nonce
313
+ env[:csp_nonce]
314
+ end
315
+
270
316
  def to_display(arg)
271
317
  arg.inspect
272
318
  rescue
@@ -310,7 +356,7 @@ module Sidekiq
310
356
  end
311
357
 
312
358
  def h(text)
313
- ::Rack::Utils.escape_html(text)
359
+ ::Rack::Utils.escape_html(text.to_s)
314
360
  rescue ArgumentError => e
315
361
  raise unless e.message.eql?("invalid byte sequence in UTF-8")
316
362
  text.encode!("UTF-16", "UTF-8", invalid: :replace, replace: "").encode!("UTF-8", "UTF-16")
data/lib/sidekiq/web.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "erb"
4
+ require "securerandom"
4
5
 
5
6
  require "sidekiq"
6
7
  require "sidekiq/api"
@@ -114,6 +115,7 @@ module Sidekiq
114
115
  end
115
116
 
116
117
  def call(env)
118
+ env[:csp_nonce] = SecureRandom.base64(16)
117
119
  app.call(env)
118
120
  end
119
121
 
@@ -138,7 +140,50 @@ module Sidekiq
138
140
  send(:"#{attribute}=", value)
139
141
  end
140
142
 
141
- def self.register(extension)
143
+ # Register a class as a Sidekiq Web UI extension. The class should
144
+ # provide one or more tabs which map to an index route. Options:
145
+ #
146
+ # @param extension [Class] Class which contains the HTTP actions, required
147
+ # @param name [String] the name of the extension, used to namespace assets
148
+ # @param tab [String | Array] labels(s) of the UI tabs
149
+ # @param index [String | Array] index route(s) for each tab
150
+ # @param root_dir [String] directory location to find assets, locales and views, typically `web/` within the gemfile
151
+ # @param asset_paths [Array] one or more directories under {root}/assets/{name} to be publicly served, e.g. ["js", "css", "img"]
152
+ # @param cache_for [Integer] amount of time to cache assets, default one day
153
+ #
154
+ # TODO name, tab and index will be mandatory in 8.0
155
+ #
156
+ # Web extensions will have a root `web/` directory with `locales/`, `assets/`
157
+ # and `views/` subdirectories.
158
+ def self.register(extension, name: nil, tab: nil, index: nil, root_dir: nil, cache_for: 86400, asset_paths: nil)
159
+ tab = Array(tab)
160
+ index = Array(index)
161
+ tab.zip(index).each do |tab, index|
162
+ tabs[tab] = index
163
+ end
164
+ if root_dir
165
+ locdir = File.join(root_dir, "locales")
166
+ locales << locdir if File.directory?(locdir)
167
+
168
+ if asset_paths && name
169
+ # if you have {root}/assets/{name}/js/scripts.js
170
+ # and {root}/assets/{name}/css/styles.css
171
+ # you would pass in:
172
+ # asset_paths: ["js", "css"]
173
+ # See script_tag and style_tag in web/helpers.rb
174
+ assdir = File.join(root_dir, "assets")
175
+ assurls = Array(asset_paths).map { |x| "/#{name}/#{x}" }
176
+ assetprops = {
177
+ urls: assurls,
178
+ root: assdir,
179
+ cascade: true
180
+ }
181
+ assetprops[:header_rules] = [[:all, {Rack::CACHE_CONTROL => "private, max-age=#{cache_for.to_i}"}]] if cache_for
182
+ middlewares << [[Rack::Static, assetprops], nil]
183
+ end
184
+ end
185
+
186
+ yield self if block_given?
142
187
  extension.registered(WebApplication)
143
188
  end
144
189
 
data/lib/sidekiq.rb CHANGED
@@ -32,6 +32,7 @@ require "sidekiq/logger"
32
32
  require "sidekiq/client"
33
33
  require "sidekiq/transaction_aware_client"
34
34
  require "sidekiq/job"
35
+ require "sidekiq/iterable_job"
35
36
  require "sidekiq/worker_compatibility_alias"
36
37
  require "sidekiq/redis_client_adapter"
37
38
 
@@ -112,7 +113,7 @@ module Sidekiq
112
113
  # end
113
114
  # inst.run
114
115
  # sleep 10
115
- # inst.terminate
116
+ # inst.stop
116
117
  #
117
118
  # NB: it is really easy to overload a Ruby process with threads due to the GIL.
118
119
  # I do not recommend setting concurrency higher than 2-3.
data/sidekiq.gemspec CHANGED
@@ -23,8 +23,9 @@ Gem::Specification.new do |gem|
23
23
  "rubygems_mfa_required" => "true"
24
24
  }
25
25
 
26
- gem.add_dependency "redis-client", ">= 0.19.0"
26
+ gem.add_dependency "redis-client", ">= 0.22.2"
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"
30
+ gem.add_dependency "logger"
30
31
  end
@@ -34,6 +34,7 @@ function addListeners() {
34
34
  addShiftClickListeners()
35
35
  updateFuzzyTimes();
36
36
  updateNumbers();
37
+ updateProgressBars();
37
38
  setLivePollFromUrl();
38
39
 
39
40
  var buttons = document.querySelectorAll(".live-poll");
@@ -180,4 +181,8 @@ function showError(error) {
180
181
 
181
182
  function updateLocale(event) {
182
183
  event.target.form.submit();
183
- };
184
+ }
185
+
186
+ function updateProgressBars() {
187
+ document.querySelectorAll('.progress-bar').forEach(bar => { bar.style.width = bar.dataset.width + "%"})
188
+ }
@@ -108,17 +108,27 @@ class RealtimeChart extends DashboardChart {
108
108
  }
109
109
 
110
110
  renderLegend(dp) {
111
- this.legend.innerHTML = `
112
- <span>
113
- <span class="swatch" style="background-color: ${dp[0].dataset.borderColor};"></span>
114
- <span>${dp[0].dataset.label}: ${dp[0].formattedValue}</span>
115
- </span>
116
- <span>
117
- <span class="swatch" style="background-color: ${dp[1].dataset.borderColor};"></span>
118
- <span>${dp[1].dataset.label}: ${dp[1].formattedValue}</span>
119
- </span>
120
- <span class="time">${dp[0].label}</span>
121
- `;
111
+ const entry1 = this.legendEntry(dp[0]);
112
+ const entry2 = this.legendEntry(dp[1]);
113
+ const time = document.createElement("span");
114
+ time.classList.add("time");
115
+ time.innerText = dp[0].label;
116
+
117
+ this.legend.replaceChildren(entry1, entry2, time)
118
+ }
119
+
120
+ legendEntry(dp) {
121
+ const wrapper = document.createElement("span");
122
+
123
+ const swatch = document.createElement("span");
124
+ swatch.classList.add("swatch");
125
+ swatch.style.backgroundColor = dp.dataset.borderColor;
126
+ wrapper.appendChild(swatch)
127
+
128
+ const label = document.createElement("span");
129
+ label.innerText = `${dp.dataset.label}: ${dp.formattedValue}`;
130
+ wrapper.appendChild(label)
131
+ return wrapper;
122
132
  }
123
133
 
124
134
  renderCursor(dp) {
@@ -179,4 +189,4 @@ class RealtimeChart extends DashboardChart {
179
189
  if (hc != null) {
180
190
  var htc = new DashboardChart(hc, JSON.parse(hc.textContent))
181
191
  window.historyChart = htc
182
- }
192
+ }
@@ -28,7 +28,7 @@ var pulseBeacon = function() {
28
28
  }
29
29
 
30
30
  var setSliderLabel = function(val) {
31
- document.getElementById('sldr-text').innerText = Math.round(parseFloat(val) / 1000) + ' sec';
31
+ document.getElementById('sldr-text').innerText = Math.round(parseFloat(val) / 1000) + ' s';
32
32
  }
33
33
 
34
34
  var ready = (callback) => {
@@ -72,6 +72,14 @@ h1, h2, h3 {
72
72
  line-height: 45px;
73
73
  }
74
74
 
75
+ .progress {
76
+ margin-bottom: 0;
77
+ }
78
+
79
+ .w-50 {
80
+ width: 50%;
81
+ }
82
+
75
83
  .header-container, .header-container .page-title-container {
76
84
  display: flex;
77
85
  justify-content: space-between;
@@ -626,8 +634,12 @@ div.interval-slider input {
626
634
  .container {
627
635
  padding: 0;
628
636
  }
637
+ .navbar-fixed-bottom {
638
+ position: relative;
639
+ top: auto;
640
+ }
629
641
  @media (max-width: 767px) {
630
- .navbar-fixed-top, .navbar-fixed-bottom {
642
+ .navbar-fixed-top {
631
643
  position: relative;
632
644
  top: auto;
633
645
  }