sidekiq 6.5.12 → 7.2.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/Changes.md +224 -20
  3. data/README.md +43 -35
  4. data/bin/multi_queue_bench +271 -0
  5. data/bin/sidekiq +3 -8
  6. data/bin/sidekiqload +204 -118
  7. data/bin/sidekiqmon +3 -0
  8. data/lib/sidekiq/api.rb +187 -135
  9. data/lib/sidekiq/capsule.rb +127 -0
  10. data/lib/sidekiq/cli.rb +59 -75
  11. data/lib/sidekiq/client.rb +66 -37
  12. data/lib/sidekiq/component.rb +4 -1
  13. data/lib/sidekiq/config.rb +287 -0
  14. data/lib/sidekiq/deploy.rb +62 -0
  15. data/lib/sidekiq/embedded.rb +61 -0
  16. data/lib/sidekiq/fetch.rb +11 -14
  17. data/lib/sidekiq/job.rb +371 -10
  18. data/lib/sidekiq/job_logger.rb +2 -2
  19. data/lib/sidekiq/job_retry.rb +36 -18
  20. data/lib/sidekiq/job_util.rb +51 -15
  21. data/lib/sidekiq/launcher.rb +71 -65
  22. data/lib/sidekiq/logger.rb +2 -27
  23. data/lib/sidekiq/manager.rb +9 -11
  24. data/lib/sidekiq/metrics/query.rb +7 -4
  25. data/lib/sidekiq/metrics/shared.rb +8 -7
  26. data/lib/sidekiq/metrics/tracking.rb +27 -21
  27. data/lib/sidekiq/middleware/chain.rb +19 -18
  28. data/lib/sidekiq/middleware/current_attributes.rb +52 -20
  29. data/lib/sidekiq/monitor.rb +16 -3
  30. data/lib/sidekiq/paginator.rb +2 -2
  31. data/lib/sidekiq/processor.rb +46 -51
  32. data/lib/sidekiq/rails.rb +15 -10
  33. data/lib/sidekiq/redis_client_adapter.rb +23 -66
  34. data/lib/sidekiq/redis_connection.rb +15 -117
  35. data/lib/sidekiq/scheduled.rb +22 -23
  36. data/lib/sidekiq/testing.rb +32 -41
  37. data/lib/sidekiq/transaction_aware_client.rb +11 -5
  38. data/lib/sidekiq/version.rb +2 -1
  39. data/lib/sidekiq/web/action.rb +8 -3
  40. data/lib/sidekiq/web/application.rb +108 -15
  41. data/lib/sidekiq/web/csrf_protection.rb +10 -7
  42. data/lib/sidekiq/web/helpers.rb +52 -38
  43. data/lib/sidekiq/web.rb +17 -16
  44. data/lib/sidekiq/worker_compatibility_alias.rb +13 -0
  45. data/lib/sidekiq.rb +76 -274
  46. data/sidekiq.gemspec +12 -10
  47. data/web/assets/javascripts/application.js +39 -0
  48. data/web/assets/javascripts/base-charts.js +106 -0
  49. data/web/assets/javascripts/dashboard-charts.js +182 -0
  50. data/web/assets/javascripts/dashboard.js +10 -232
  51. data/web/assets/javascripts/metrics.js +151 -115
  52. data/web/assets/stylesheets/application-dark.css +4 -0
  53. data/web/assets/stylesheets/application-rtl.css +10 -89
  54. data/web/assets/stylesheets/application.css +45 -298
  55. data/web/locales/ar.yml +70 -70
  56. data/web/locales/cs.yml +62 -62
  57. data/web/locales/da.yml +60 -53
  58. data/web/locales/de.yml +65 -65
  59. data/web/locales/el.yml +2 -7
  60. data/web/locales/en.yml +78 -70
  61. data/web/locales/es.yml +68 -68
  62. data/web/locales/fa.yml +65 -65
  63. data/web/locales/fr.yml +81 -67
  64. data/web/locales/gd.yml +99 -0
  65. data/web/locales/he.yml +65 -64
  66. data/web/locales/hi.yml +59 -59
  67. data/web/locales/it.yml +53 -53
  68. data/web/locales/ja.yml +67 -69
  69. data/web/locales/ko.yml +52 -52
  70. data/web/locales/lt.yml +66 -66
  71. data/web/locales/nb.yml +61 -61
  72. data/web/locales/nl.yml +52 -52
  73. data/web/locales/pl.yml +45 -45
  74. data/web/locales/pt-br.yml +79 -69
  75. data/web/locales/pt.yml +51 -51
  76. data/web/locales/ru.yml +67 -66
  77. data/web/locales/sv.yml +53 -53
  78. data/web/locales/ta.yml +60 -60
  79. data/web/locales/uk.yml +62 -61
  80. data/web/locales/ur.yml +64 -64
  81. data/web/locales/vi.yml +67 -67
  82. data/web/locales/zh-cn.yml +20 -18
  83. data/web/locales/zh-tw.yml +10 -1
  84. data/web/views/_footer.erb +17 -2
  85. data/web/views/_job_info.erb +18 -2
  86. data/web/views/_metrics_period_select.erb +12 -0
  87. data/web/views/_paging.erb +2 -0
  88. data/web/views/_poll_link.erb +1 -1
  89. data/web/views/_summary.erb +7 -7
  90. data/web/views/busy.erb +46 -35
  91. data/web/views/dashboard.erb +26 -5
  92. data/web/views/filtering.erb +7 -0
  93. data/web/views/metrics.erb +46 -24
  94. data/web/views/metrics_for_job.erb +41 -69
  95. data/web/views/morgue.erb +5 -9
  96. data/web/views/queue.erb +10 -14
  97. data/web/views/queues.erb +9 -3
  98. data/web/views/retries.erb +5 -9
  99. data/web/views/scheduled.erb +12 -13
  100. metadata +44 -38
  101. data/lib/sidekiq/delay.rb +0 -43
  102. data/lib/sidekiq/extensions/action_mailer.rb +0 -48
  103. data/lib/sidekiq/extensions/active_record.rb +0 -43
  104. data/lib/sidekiq/extensions/class_methods.rb +0 -43
  105. data/lib/sidekiq/extensions/generic_proxy.rb +0 -33
  106. data/lib/sidekiq/metrics/deploy.rb +0 -47
  107. data/lib/sidekiq/worker.rb +0 -370
  108. data/web/assets/javascripts/graph.js +0 -16
  109. /data/{LICENSE → LICENSE.txt} +0 -0
data/lib/sidekiq.rb CHANGED
@@ -1,15 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "sidekiq/version"
4
- fail "Sidekiq #{Sidekiq::VERSION} does not support Ruby versions below 2.5.0." if RUBY_PLATFORM != "java" && Gem::Version.new(RUBY_VERSION) < Gem::Version.new("2.5.0")
4
+ fail "Sidekiq #{Sidekiq::VERSION} does not support Ruby versions below 2.7.0." if RUBY_PLATFORM != "java" && Gem::Version.new(RUBY_VERSION) < Gem::Version.new("2.7.0")
5
5
 
6
+ begin
7
+ require "sidekiq-ent/version"
8
+ fail <<~EOM if Gem::Version.new(Sidekiq::Enterprise::VERSION).segments[0] != Sidekiq::MAJOR
9
+
10
+ Sidekiq Enterprise #{Sidekiq::Enterprise::VERSION} does not work with Sidekiq #{Sidekiq::VERSION}.
11
+ Starting with Sidekiq 7, major versions are synchronized so Sidekiq Enterprise 7 works with Sidekiq 7.
12
+ Use `bundle up sidekiq-ent` to upgrade.
13
+
14
+ EOM
15
+ rescue LoadError
16
+ end
17
+
18
+ begin
19
+ require "sidekiq/pro/version"
20
+ fail <<~EOM if Gem::Version.new(Sidekiq::Pro::VERSION).segments[0] != Sidekiq::MAJOR
21
+
22
+ Sidekiq Pro #{Sidekiq::Pro::VERSION} does not work with Sidekiq #{Sidekiq::VERSION}.
23
+ Starting with Sidekiq 7, major versions are synchronized so Sidekiq Pro 7 works with Sidekiq 7.
24
+ Use `bundle up sidekiq-pro` to upgrade.
25
+
26
+ EOM
27
+ rescue LoadError
28
+ end
29
+
30
+ require "sidekiq/config"
6
31
  require "sidekiq/logger"
7
32
  require "sidekiq/client"
8
33
  require "sidekiq/transaction_aware_client"
9
- require "sidekiq/worker"
10
34
  require "sidekiq/job"
11
- require "sidekiq/redis_connection"
12
- require "sidekiq/delay"
35
+ require "sidekiq/worker_compatibility_alias"
36
+ require "sidekiq/redis_client_adapter"
13
37
 
14
38
  require "json"
15
39
 
@@ -17,320 +41,98 @@ module Sidekiq
17
41
  NAME = "Sidekiq"
18
42
  LICENSE = "See LICENSE and the LGPL-3.0 for licensing details."
19
43
 
20
- DEFAULTS = {
21
- queues: [],
22
- labels: [],
23
- concurrency: 10,
24
- require: ".",
25
- strict: true,
26
- environment: nil,
27
- timeout: 25,
28
- poll_interval_average: nil,
29
- average_scheduled_poll_interval: 5,
30
- on_complex_arguments: :warn,
31
- error_handlers: [],
32
- death_handlers: [],
33
- lifecycle_events: {
34
- startup: [],
35
- quiet: [],
36
- shutdown: [],
37
- # triggers when we fire the first heartbeat on startup OR repairing a network partition
38
- heartbeat: [],
39
- # triggers on EVERY heartbeat call, every 10 seconds
40
- beat: []
41
- },
42
- dead_max_jobs: 10_000,
43
- dead_timeout_in_seconds: 180 * 24 * 60 * 60, # 6 months
44
- reloader: proc { |&block| block.call }
45
- }
46
-
47
- FAKE_INFO = {
48
- "redis_version" => "9.9.9",
49
- "uptime_in_days" => "9999",
50
- "connected_clients" => "9999",
51
- "used_memory_human" => "9P",
52
- "used_memory_peak_human" => "9P"
53
- }
54
-
55
44
  def self.❨╯°□°❩╯︵┻━┻
56
- puts "Calm down, yo."
57
- end
58
-
59
- # config.concurrency = 5
60
- def self.concurrency=(val)
61
- self[:concurrency] = Integer(val)
62
- end
63
-
64
- # config.queues = %w( high default low ) # strict
65
- # config.queues = %w( high,3 default,2 low,1 ) # weighted
66
- # config.queues = %w( feature1,1 feature2,1 feature3,1 ) # random
67
- #
68
- # With weighted priority, queue will be checked first (weight / total) of the time.
69
- # high will be checked first (3/6) or 50% of the time.
70
- # I'd recommend setting weights between 1-10. Weights in the hundreds or thousands
71
- # are ridiculous and unnecessarily expensive. You can get random queue ordering
72
- # by explicitly setting all weights to 1.
73
- def self.queues=(val)
74
- self[:queues] = Array(val).each_with_object([]) do |qstr, memo|
75
- name, weight = qstr.split(",")
76
- self[:strict] = false if weight.to_i > 0
77
- [weight.to_i, 1].max.times do
78
- memo << name
79
- end
80
- end
81
- end
82
-
83
- ### Private APIs
84
- def self.default_error_handler(ex, ctx)
85
- logger.warn(dump_json(ctx)) unless ctx.empty?
86
- logger.warn("#{ex.class.name}: #{ex.message}")
87
- logger.warn(ex.backtrace.join("\n")) unless ex.backtrace.nil?
88
- end
89
-
90
- # DEFAULT_ERROR_HANDLER is a constant that allows the default error handler to
91
- # be referenced. It must be defined here, after the default_error_handler
92
- # method is defined.
93
- DEFAULT_ERROR_HANDLER = method(:default_error_handler)
94
-
95
- @config = DEFAULTS.dup
96
- def self.options
97
- logger.warn "`config.options[:key] = value` is deprecated, use `config[:key] = value`: #{caller(1..2)}"
98
- @config
99
- end
100
-
101
- def self.options=(opts)
102
- logger.warn "config.options = hash` is deprecated, use `config.merge!(hash)`: #{caller(1..2)}"
103
- @config = opts
104
- end
105
-
106
- def self.[](key)
107
- @config[key]
108
- end
109
-
110
- def self.[]=(key, val)
111
- @config[key] = val
112
- end
113
-
114
- def self.merge!(hash)
115
- @config.merge!(hash)
116
- end
117
-
118
- def self.fetch(*args, &block)
119
- @config.fetch(*args, &block)
120
- end
121
-
122
- def self.handle_exception(ex, ctx = {})
123
- self[:error_handlers].each do |handler|
124
- handler.call(ex, ctx)
125
- rescue => ex
126
- logger.error "!!! ERROR HANDLER THREW AN ERROR !!!"
127
- logger.error ex
128
- logger.error ex.backtrace.join("\n") unless ex.backtrace.nil?
129
- end
130
- end
131
- ###
132
-
133
- ##
134
- # Configuration for Sidekiq server, use like:
135
- #
136
- # Sidekiq.configure_server do |config|
137
- # config.server_middleware do |chain|
138
- # chain.add MyServerHook
139
- # end
140
- # end
141
- def self.configure_server
142
- yield self if server?
143
- end
144
-
145
- ##
146
- # Configuration for Sidekiq client, use like:
147
- #
148
- # Sidekiq.configure_client do |config|
149
- # config.redis = { size: 1, url: 'redis://myhost:8877/0' }
150
- # end
151
- def self.configure_client
152
- yield self unless server?
45
+ puts "Take a deep breath and count to ten..."
153
46
  end
154
47
 
155
48
  def self.server?
156
49
  defined?(Sidekiq::CLI)
157
50
  end
158
51
 
159
- def self.redis
160
- raise ArgumentError, "requires a block" unless block_given?
161
- redis_pool.with do |conn|
162
- retryable = true
163
- begin
164
- yield conn
165
- rescue RedisConnection.adapter::BaseError => ex
166
- # 2550 Failover can cause the server to become a replica, need
167
- # to disconnect and reopen the socket to get back to the primary.
168
- # 4495 Use the same logic if we have a "Not enough replicas" error from the primary
169
- # 4985 Use the same logic when a blocking command is force-unblocked
170
- # The same retry logic is also used in client.rb
171
- if retryable && ex.message =~ /READONLY|NOREPLICAS|UNBLOCKED/
172
- conn.disconnect!
173
- retryable = false
174
- retry
175
- end
176
- raise
177
- end
178
- end
179
- end
180
-
181
- def self.redis_info
182
- redis do |conn|
183
- # admin commands can't go through redis-namespace starting
184
- # in redis-namespace 2.0
185
- if conn.respond_to?(:namespace)
186
- conn.redis.info
187
- else
188
- conn.info
189
- end
190
- rescue RedisConnection.adapter::CommandError => ex
191
- # 2850 return fake version when INFO command has (probably) been renamed
192
- raise unless /unknown command/.match?(ex.message)
193
- FAKE_INFO
194
- end
52
+ def self.load_json(string)
53
+ JSON.parse(string)
195
54
  end
196
55
 
197
- def self.redis_pool
198
- @redis ||= RedisConnection.create
56
+ def self.dump_json(object)
57
+ JSON.generate(object)
199
58
  end
200
59
 
201
- def self.redis=(hash)
202
- @redis = if hash.is_a?(ConnectionPool)
203
- hash
204
- else
205
- RedisConnection.create(hash)
206
- end
60
+ def self.pro?
61
+ defined?(Sidekiq::Pro)
207
62
  end
208
63
 
209
- def self.client_middleware
210
- @client_chain ||= Middleware::Chain.new(self)
211
- yield @client_chain if block_given?
212
- @client_chain
64
+ def self.ent?
65
+ defined?(Sidekiq::Enterprise)
213
66
  end
214
67
 
215
- def self.server_middleware
216
- @server_chain ||= default_server_middleware
217
- yield @server_chain if block_given?
218
- @server_chain
68
+ def self.redis_pool
69
+ (Thread.current[:sidekiq_capsule] || default_configuration).redis_pool
219
70
  end
220
71
 
221
- def self.default_server_middleware
222
- Middleware::Chain.new(self)
72
+ def self.redis(&block)
73
+ (Thread.current[:sidekiq_capsule] || default_configuration).redis(&block)
223
74
  end
224
75
 
225
- def self.default_worker_options=(hash) # deprecated
226
- @default_job_options = default_job_options.merge(hash.transform_keys(&:to_s))
76
+ def self.strict_args!(mode = :raise)
77
+ Sidekiq::Config::DEFAULTS[:on_complex_arguments] = mode
227
78
  end
228
79
 
229
80
  def self.default_job_options=(hash)
230
81
  @default_job_options = default_job_options.merge(hash.transform_keys(&:to_s))
231
82
  end
232
83
 
233
- def self.default_worker_options # deprecated
234
- @default_job_options ||= {"retry" => true, "queue" => "default"}
235
- end
236
-
237
84
  def self.default_job_options
238
85
  @default_job_options ||= {"retry" => true, "queue" => "default"}
239
86
  end
240
87
 
241
- ##
242
- # Death handlers are called when all retries for a job have been exhausted and
243
- # the job dies. It's the notification to your application
244
- # that this job will not succeed without manual intervention.
245
- #
246
- # Sidekiq.configure_server do |config|
247
- # config.death_handlers << ->(job, ex) do
248
- # end
249
- # end
250
- def self.death_handlers
251
- self[:death_handlers]
252
- end
253
-
254
- def self.load_json(string)
255
- JSON.parse(string)
256
- end
257
-
258
- def self.dump_json(object)
259
- JSON.generate(object)
260
- end
261
-
262
- def self.log_formatter
263
- @log_formatter ||= if ENV["DYNO"]
264
- Sidekiq::Logger::Formatters::WithoutTimestamp.new
265
- else
266
- Sidekiq::Logger::Formatters::Pretty.new
267
- end
268
- end
269
-
270
- def self.log_formatter=(log_formatter)
271
- @log_formatter = log_formatter
272
- logger.formatter = log_formatter
88
+ def self.default_configuration
89
+ @config ||= Sidekiq::Config.new
273
90
  end
274
91
 
275
92
  def self.logger
276
- @logger ||= Sidekiq::Logger.new($stdout, level: :info)
277
- end
278
-
279
- def self.logger=(logger)
280
- if logger.nil?
281
- self.logger.level = Logger::FATAL
282
- return self.logger
283
- end
284
-
285
- logger.extend(Sidekiq::LoggingUtils)
286
-
287
- @logger = logger
288
- end
289
-
290
- def self.pro?
291
- defined?(Sidekiq::Pro)
93
+ default_configuration.logger
292
94
  end
293
95
 
294
- def self.ent?
295
- defined?(Sidekiq::Enterprise)
96
+ def self.configure_server(&block)
97
+ (@config_blocks ||= []) << block
98
+ yield default_configuration if server?
296
99
  end
297
100
 
298
- # How frequently Redis should be checked by a random Sidekiq process for
299
- # scheduled and retriable jobs. Each individual process will take turns by
300
- # waiting some multiple of this value.
301
- #
302
- # See sidekiq/scheduled.rb for an in-depth explanation of this value
303
- def self.average_scheduled_poll_interval=(interval)
304
- self[:average_scheduled_poll_interval] = interval
101
+ def self.freeze!
102
+ @frozen = true
103
+ @config_blocks = nil
305
104
  end
306
105
 
307
- # Register a proc to handle any error which occurs within the Sidekiq process.
106
+ # Creates a Sidekiq::Config instance that is more tuned for embedding
107
+ # within an arbitrary Ruby process. Notably it reduces concurrency by
108
+ # default so there is less contention for CPU time with other threads.
308
109
  #
309
- # Sidekiq.configure_server do |config|
310
- # config.error_handlers << proc {|ex,ctx_hash| MyErrorService.notify(ex, ctx_hash) }
110
+ # inst = Sidekiq.configure_embed do |config|
111
+ # config.queues = %w[critical default low]
311
112
  # end
113
+ # inst.run
114
+ # sleep 10
115
+ # inst.terminate
312
116
  #
313
- # The default error handler logs errors to Sidekiq.logger.
314
- def self.error_handlers
315
- self[:error_handlers]
316
- end
317
-
318
- # Register a block to run at a point in the Sidekiq lifecycle.
319
- # :startup, :quiet or :shutdown are valid events.
117
+ # NB: it is really easy to overload a Ruby process with threads due to the GIL.
118
+ # I do not recommend setting concurrency higher than 2-3.
320
119
  #
321
- # Sidekiq.configure_server do |config|
322
- # config.on(:shutdown) do
323
- # puts "Goodbye cruel world!"
324
- # end
325
- # end
326
- def self.on(event, &block)
327
- raise ArgumentError, "Symbols only please: #{event}" unless event.is_a?(Symbol)
328
- raise ArgumentError, "Invalid event name: #{event}" unless self[:lifecycle_events].key?(event)
329
- self[:lifecycle_events][event] << block
120
+ # NB: Sidekiq only supports one instance in memory. You will get undefined behavior
121
+ # if you try to embed Sidekiq twice in the same process.
122
+ def self.configure_embed(&block)
123
+ raise "Sidekiq global configuration is frozen, you must create all embedded instances BEFORE calling `run`" if @frozen
124
+
125
+ require "sidekiq/embedded"
126
+ cfg = default_configuration
127
+ cfg.concurrency = 2
128
+ @config_blocks&.each { |block| block.call(cfg) }
129
+ yield cfg
130
+
131
+ Sidekiq::Embedded.new(cfg)
330
132
  end
331
133
 
332
- def self.strict_args!(mode = :raise)
333
- self[:on_complex_arguments] = mode
134
+ def self.configure_client
135
+ yield default_configuration unless server?
334
136
  end
335
137
 
336
138
  # We are shutting down Sidekiq but what about threads that
data/sidekiq.gemspec CHANGED
@@ -2,27 +2,29 @@ require_relative "lib/sidekiq/version"
2
2
 
3
3
  Gem::Specification.new do |gem|
4
4
  gem.authors = ["Mike Perham"]
5
- gem.email = ["mperham@gmail.com"]
5
+ gem.email = ["info@contribsys.com"]
6
6
  gem.summary = "Simple, efficient background processing for Ruby"
7
7
  gem.description = "Simple, efficient background processing for Ruby."
8
8
  gem.homepage = "https://sidekiq.org"
9
9
  gem.license = "LGPL-3.0"
10
10
 
11
11
  gem.executables = ["sidekiq", "sidekiqmon"]
12
- gem.files = ["sidekiq.gemspec", "README.md", "Changes.md", "LICENSE"] + `git ls-files | grep -E '^(bin|lib|web)'`.split("\n")
12
+ gem.files = %w[sidekiq.gemspec README.md Changes.md LICENSE.txt] + `git ls-files | grep -E '^(bin|lib|web)'`.split("\n")
13
13
  gem.name = "sidekiq"
14
14
  gem.version = Sidekiq::VERSION
15
- gem.required_ruby_version = ">= 2.5.0"
15
+ gem.required_ruby_version = ">= 2.7.0"
16
16
 
17
17
  gem.metadata = {
18
18
  "homepage_uri" => "https://sidekiq.org",
19
- "bug_tracker_uri" => "https://github.com/mperham/sidekiq/issues",
20
- "documentation_uri" => "https://github.com/mperham/sidekiq/wiki",
21
- "changelog_uri" => "https://github.com/mperham/sidekiq/blob/main/Changes.md",
22
- "source_code_uri" => "https://github.com/mperham/sidekiq"
19
+ "bug_tracker_uri" => "https://github.com/sidekiq/sidekiq/issues",
20
+ "documentation_uri" => "https://github.com/sidekiq/sidekiq/wiki",
21
+ "changelog_uri" => "https://github.com/sidekiq/sidekiq/blob/main/Changes.md",
22
+ "source_code_uri" => "https://github.com/sidekiq/sidekiq",
23
+ "rubygems_mfa_required" => "true"
23
24
  }
24
25
 
25
- gem.add_dependency "redis", ["<5", ">= 4.5.0"]
26
- gem.add_dependency "connection_pool", ["<3", ">= 2.2.5"]
27
- gem.add_dependency "rack", "~> 2.0"
26
+ gem.add_dependency "redis-client", ">= 0.19.0"
27
+ gem.add_dependency "connection_pool", ">= 2.3.0"
28
+ gem.add_dependency "rack", ">= 2.2.4"
29
+ gem.add_dependency "concurrent-ruby", "< 2"
28
30
  end
@@ -31,7 +31,9 @@ function addListeners() {
31
31
  node.addEventListener("click", addDataToggleListeners)
32
32
  })
33
33
 
34
+ addShiftClickListeners()
34
35
  updateFuzzyTimes();
36
+ updateNumbers();
35
37
  setLivePollFromUrl();
36
38
 
37
39
  var buttons = document.querySelectorAll(".live-poll");
@@ -45,6 +47,8 @@ function addListeners() {
45
47
  scheduleLivePoll();
46
48
  }
47
49
  }
50
+
51
+ document.getElementById("locale-select").addEventListener("change", updateLocale);
48
52
  }
49
53
 
50
54
  function addPollingListeners(_event) {
@@ -71,6 +75,23 @@ function addDataToggleListeners(event) {
71
75
  }
72
76
  }
73
77
 
78
+ function addShiftClickListeners() {
79
+ let checkboxes = Array.from(document.querySelectorAll(".shift_clickable"));
80
+ let lastChecked = null;
81
+ checkboxes.forEach(checkbox => {
82
+ checkbox.addEventListener("click", (e) => {
83
+ if (e.shiftKey && lastChecked) {
84
+ let myIndex = checkboxes.indexOf(checkbox);
85
+ let lastIndex = checkboxes.indexOf(lastChecked);
86
+ let [min, max] = [myIndex, lastIndex].sort();
87
+ let newState = checkbox.checked;
88
+ checkboxes.slice(min, max).forEach(c => c.checked = newState);
89
+ }
90
+ lastChecked = checkbox;
91
+ });
92
+ });
93
+ }
94
+
74
95
  function updateFuzzyTimes() {
75
96
  var locale = document.body.getAttribute("data-locale");
76
97
  var parts = locale.split('-');
@@ -84,6 +105,20 @@ function updateFuzzyTimes() {
84
105
  t.cancel();
85
106
  }
86
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
+
87
122
  function setLivePollFromUrl() {
88
123
  var url_params = new URL(window.location.href).searchParams
89
124
 
@@ -142,3 +177,7 @@ function replacePage(text) {
142
177
  function showError(error) {
143
178
  console.error(error)
144
179
  }
180
+
181
+ function updateLocale(event) {
182
+ event.target.form.submit();
183
+ };
@@ -0,0 +1,106 @@
1
+ if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
2
+ Chart.defaults.borderColor = "#333";
3
+ Chart.defaults.color = "#aaa";
4
+ }
5
+
6
+ class Colors {
7
+ constructor() {
8
+ this.assignments = {};
9
+ this.success = "#006f68";
10
+ this.failure = "#af0014";
11
+ this.fallback = "#999";
12
+ this.primary = "#537bc4";
13
+ this.available = [
14
+ // Colors taken from https://www.chartjs.org/docs/latest/samples/utils.html
15
+ "#537bc4",
16
+ "#4dc9f6",
17
+ "#f67019",
18
+ "#f53794",
19
+ "#acc236",
20
+ "#166a8f",
21
+ "#00a950",
22
+ "#58595b",
23
+ "#8549ba",
24
+ "#991b1b",
25
+ ];
26
+ }
27
+
28
+ checkOut(assignee) {
29
+ const color =
30
+ this.assignments[assignee] || this.available.shift() || this.fallback;
31
+ this.assignments[assignee] = color;
32
+ return color;
33
+ }
34
+
35
+ checkIn(assignee) {
36
+ const color = this.assignments[assignee];
37
+ delete this.assignments[assignee];
38
+
39
+ if (color && color != this.fallback) {
40
+ this.available.unshift(color);
41
+ }
42
+ }
43
+ }
44
+
45
+ class BaseChart {
46
+ constructor(el, options) {
47
+ this.el = el;
48
+ this.options = options;
49
+ this.colors = new Colors();
50
+ }
51
+
52
+ init() {
53
+ this.chart = new Chart(this.el, {
54
+ type: this.options.chartType,
55
+ data: { labels: this.options.labels, datasets: this.datasets },
56
+ options: this.chartOptions,
57
+ });
58
+ }
59
+
60
+ update() {
61
+ this.chart.options = this.chartOptions;
62
+ this.chart.update();
63
+ }
64
+
65
+ get chartOptions() {
66
+ let chartOptions = {
67
+ interaction: {
68
+ mode: "nearest",
69
+ axis: "x",
70
+ intersect: false,
71
+ },
72
+ scales: {
73
+ x: {
74
+ ticks: {
75
+ autoSkipPadding: 10,
76
+ },
77
+ },
78
+ },
79
+ plugins: {
80
+ legend: {
81
+ display: false,
82
+ },
83
+ annotation: {
84
+ annotations: {},
85
+ },
86
+ tooltip: {
87
+ animation: false,
88
+ },
89
+ },
90
+ };
91
+
92
+ if (this.options.marks) {
93
+ this.options.marks.forEach(([bucket, label], i) => {
94
+ chartOptions.plugins.annotation.annotations[`deploy-${i}`] = {
95
+ type: "line",
96
+ xMin: bucket,
97
+ xMax: bucket,
98
+ borderColor: "rgba(220, 38, 38, 0.4)",
99
+ borderWidth: 2,
100
+ };
101
+ });
102
+ }
103
+
104
+ return chartOptions;
105
+ }
106
+ }