sidekiq 7.2.3 → 7.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/Changes.md +41 -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 +5 -5
  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.3"
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
  }