sidekiq 6.5.10 → 8.1.7

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 (177) hide show
  1. checksums.yaml +4 -4
  2. data/Changes.md +568 -18
  3. data/README.md +66 -38
  4. data/bin/kiq +17 -0
  5. data/bin/lint-herb +13 -0
  6. data/bin/multi_queue_bench +271 -0
  7. data/bin/sidekiq +3 -8
  8. data/bin/sidekiqload +215 -118
  9. data/bin/sidekiqmon +3 -0
  10. data/bin/webload +69 -0
  11. data/lib/active_job/queue_adapters/sidekiq_adapter.rb +124 -0
  12. data/lib/generators/sidekiq/job_generator.rb +17 -3
  13. data/lib/generators/sidekiq/templates/job.rb.erb +1 -1
  14. data/lib/sidekiq/api.rb +526 -218
  15. data/lib/sidekiq/capsule.rb +135 -0
  16. data/lib/sidekiq/cli.rb +88 -96
  17. data/lib/sidekiq/client.rb +114 -47
  18. data/lib/sidekiq/component.rb +71 -4
  19. data/lib/sidekiq/config.rb +316 -0
  20. data/lib/sidekiq/deploy.rb +64 -0
  21. data/lib/sidekiq/embedded.rb +64 -0
  22. data/lib/sidekiq/fetch.rb +12 -14
  23. data/lib/sidekiq/iterable_job.rb +56 -0
  24. data/lib/sidekiq/job/interrupt_handler.rb +24 -0
  25. data/lib/sidekiq/job/iterable/active_record_enumerator.rb +53 -0
  26. data/lib/sidekiq/job/iterable/csv_enumerator.rb +47 -0
  27. data/lib/sidekiq/job/iterable/enumerators.rb +135 -0
  28. data/lib/sidekiq/job/iterable.rb +322 -0
  29. data/lib/sidekiq/job.rb +397 -10
  30. data/lib/sidekiq/job_logger.rb +14 -11
  31. data/lib/sidekiq/job_retry.rb +78 -28
  32. data/lib/sidekiq/job_util.rb +58 -16
  33. data/lib/sidekiq/launcher.rb +86 -70
  34. data/lib/sidekiq/loader.rb +57 -0
  35. data/lib/sidekiq/logger.rb +25 -94
  36. data/lib/sidekiq/manager.rb +11 -13
  37. data/lib/sidekiq/metrics/query.rb +84 -48
  38. data/lib/sidekiq/metrics/shared.rb +29 -14
  39. data/lib/sidekiq/metrics/tracking.rb +49 -30
  40. data/lib/sidekiq/middleware/chain.rb +19 -18
  41. data/lib/sidekiq/middleware/current_attributes.rb +78 -21
  42. data/lib/sidekiq/middleware/i18n.rb +2 -0
  43. data/lib/sidekiq/middleware/modules.rb +2 -0
  44. data/lib/sidekiq/monitor.rb +15 -5
  45. data/lib/sidekiq/paginator.rb +26 -4
  46. data/lib/sidekiq/processor.rb +78 -65
  47. data/lib/sidekiq/profiler.rb +73 -0
  48. data/lib/sidekiq/rails.rb +45 -60
  49. data/lib/sidekiq/redis_client_adapter.rb +35 -72
  50. data/lib/sidekiq/redis_connection.rb +54 -114
  51. data/lib/sidekiq/ring_buffer.rb +3 -0
  52. data/lib/sidekiq/scheduled.rb +28 -30
  53. data/lib/sidekiq/systemd.rb +2 -0
  54. data/lib/sidekiq/test_api.rb +331 -0
  55. data/lib/sidekiq/testing/inline.rb +2 -30
  56. data/lib/sidekiq/testing.rb +2 -342
  57. data/lib/sidekiq/transaction_aware_client.rb +23 -9
  58. data/lib/sidekiq/tui/controls.rb +53 -0
  59. data/lib/sidekiq/tui/filtering.rb +53 -0
  60. data/lib/sidekiq/tui/tabs/base_tab.rb +230 -0
  61. data/lib/sidekiq/tui/tabs/busy.rb +104 -0
  62. data/lib/sidekiq/tui/tabs/dead.rb +14 -0
  63. data/lib/sidekiq/tui/tabs/home.rb +115 -0
  64. data/lib/sidekiq/tui/tabs/metrics.rb +114 -0
  65. data/lib/sidekiq/tui/tabs/queues.rb +83 -0
  66. data/lib/sidekiq/tui/tabs/retries.rb +14 -0
  67. data/lib/sidekiq/tui/tabs/scheduled.rb +14 -0
  68. data/lib/sidekiq/tui/tabs/set_tab.rb +91 -0
  69. data/lib/sidekiq/tui/tabs.rb +15 -0
  70. data/lib/sidekiq/tui.rb +390 -0
  71. data/lib/sidekiq/version.rb +6 -1
  72. data/lib/sidekiq/web/action.rb +149 -64
  73. data/lib/sidekiq/web/application.rb +379 -281
  74. data/lib/sidekiq/web/config.rb +117 -0
  75. data/lib/sidekiq/web/helpers.rb +190 -71
  76. data/lib/sidekiq/web/router.rb +62 -75
  77. data/lib/sidekiq/web.rb +68 -101
  78. data/lib/sidekiq/worker_compatibility_alias.rb +13 -0
  79. data/lib/sidekiq.rb +85 -269
  80. data/sidekiq.gemspec +15 -11
  81. data/web/assets/images/logo.png +0 -0
  82. data/web/assets/images/status.png +0 -0
  83. data/web/assets/javascripts/application.js +84 -24
  84. data/web/assets/javascripts/base-charts.js +120 -0
  85. data/web/assets/javascripts/chartjs-adapter-date-fns.min.js +7 -0
  86. data/web/assets/javascripts/dashboard-charts.js +194 -0
  87. data/web/assets/javascripts/dashboard.js +18 -234
  88. data/web/assets/javascripts/metrics.js +138 -120
  89. data/web/assets/stylesheets/style.css +776 -0
  90. data/web/locales/ar.yml +72 -70
  91. data/web/locales/cs.yml +64 -62
  92. data/web/locales/da.yml +62 -53
  93. data/web/locales/de.yml +67 -65
  94. data/web/locales/el.yml +4 -7
  95. data/web/locales/en.yml +89 -71
  96. data/web/locales/es.yml +91 -68
  97. data/web/locales/fa.yml +67 -65
  98. data/web/locales/fr.yml +82 -67
  99. data/web/locales/gd.yml +110 -0
  100. data/web/locales/he.yml +67 -64
  101. data/web/locales/hi.yml +95 -59
  102. data/web/locales/it.yml +94 -54
  103. data/web/locales/ja.yml +69 -70
  104. data/web/locales/ko.yml +54 -52
  105. data/web/locales/lt.yml +68 -66
  106. data/web/locales/nb.yml +63 -61
  107. data/web/locales/nl.yml +54 -52
  108. data/web/locales/pl.yml +47 -45
  109. data/web/locales/{pt-br.yml → pt-BR.yml} +81 -70
  110. data/web/locales/pt.yml +53 -51
  111. data/web/locales/ru.yml +69 -66
  112. data/web/locales/sv.yml +55 -53
  113. data/web/locales/ta.yml +100 -64
  114. data/web/locales/tr.yml +102 -0
  115. data/web/locales/uk.yml +87 -61
  116. data/web/locales/ur.yml +66 -64
  117. data/web/locales/vi.yml +69 -67
  118. data/web/locales/zh-CN.yml +107 -0
  119. data/web/locales/{zh-tw.yml → zh-TW.yml} +13 -3
  120. data/web/views/_footer.html.erb +32 -0
  121. data/web/views/_job_info.html.erb +115 -0
  122. data/web/views/_metrics_period_select.html.erb +15 -0
  123. data/web/views/_nav.html.erb +45 -0
  124. data/web/views/_paging.html.erb +26 -0
  125. data/web/views/_poll_link.html.erb +4 -0
  126. data/web/views/_summary.html.erb +40 -0
  127. data/web/views/busy.html.erb +155 -0
  128. data/web/views/dashboard.html.erb +104 -0
  129. data/web/views/dead.html.erb +38 -0
  130. data/web/views/filtering.html.erb +6 -0
  131. data/web/views/layout.html.erb +26 -0
  132. data/web/views/metrics.html.erb +85 -0
  133. data/web/views/metrics_for_job.html.erb +58 -0
  134. data/web/views/morgue.html.erb +69 -0
  135. data/web/views/profiles.html.erb +43 -0
  136. data/web/views/queue.html.erb +57 -0
  137. data/web/views/queues.html.erb +46 -0
  138. data/web/views/retries.html.erb +77 -0
  139. data/web/views/retry.html.erb +39 -0
  140. data/web/views/scheduled.html.erb +64 -0
  141. data/web/views/{scheduled_job_info.erb → scheduled_job_info.html.erb} +3 -3
  142. metadata +113 -70
  143. data/lib/sidekiq/delay.rb +0 -43
  144. data/lib/sidekiq/extensions/action_mailer.rb +0 -48
  145. data/lib/sidekiq/extensions/active_record.rb +0 -43
  146. data/lib/sidekiq/extensions/class_methods.rb +0 -43
  147. data/lib/sidekiq/extensions/generic_proxy.rb +0 -33
  148. data/lib/sidekiq/metrics/deploy.rb +0 -47
  149. data/lib/sidekiq/web/csrf_protection.rb +0 -180
  150. data/lib/sidekiq/worker.rb +0 -370
  151. data/web/assets/javascripts/graph.js +0 -16
  152. data/web/assets/stylesheets/application-dark.css +0 -143
  153. data/web/assets/stylesheets/application-rtl.css +0 -242
  154. data/web/assets/stylesheets/application.css +0 -999
  155. data/web/assets/stylesheets/bootstrap-rtl.min.css +0 -9
  156. data/web/assets/stylesheets/bootstrap.css +0 -5
  157. data/web/locales/zh-cn.yml +0 -93
  158. data/web/views/_footer.erb +0 -20
  159. data/web/views/_job_info.erb +0 -89
  160. data/web/views/_nav.erb +0 -52
  161. data/web/views/_paging.erb +0 -23
  162. data/web/views/_poll_link.erb +0 -4
  163. data/web/views/_status.erb +0 -4
  164. data/web/views/_summary.erb +0 -40
  165. data/web/views/busy.erb +0 -137
  166. data/web/views/dashboard.erb +0 -84
  167. data/web/views/dead.erb +0 -34
  168. data/web/views/layout.erb +0 -42
  169. data/web/views/metrics.erb +0 -69
  170. data/web/views/metrics_for_job.erb +0 -87
  171. data/web/views/morgue.erb +0 -78
  172. data/web/views/queue.erb +0 -59
  173. data/web/views/queues.erb +0 -38
  174. data/web/views/retries.erb +0 -83
  175. data/web/views/retry.erb +0 -34
  176. data/web/views/scheduled.erb +0 -57
  177. /data/{LICENSE → LICENSE.txt} +0 -0
@@ -1,89 +1,58 @@
1
- if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
2
- Chart.defaults.borderColor = "#333"
3
- Chart.defaults.color = "#aaa"
4
- }
5
-
6
- class BaseChart {
7
- constructor(id, options) {
8
- this.ctx = document.getElementById(id);
9
- this.options = options
10
- this.fallbackColor = "#999";
11
- this.colors = [
12
- // Colors taken from https://www.chartjs.org/docs/latest/samples/utils.html
13
- "#537bc4",
14
- "#4dc9f6",
15
- "#f67019",
16
- "#f53794",
17
- "#acc236",
18
- "#166a8f",
19
- "#00a950",
20
- "#58595b",
21
- "#8549ba",
22
- "#991b1b",
23
- ];
1
+ class JobMetricsOverviewChart extends BaseChart {
2
+ constructor(el, options) {
3
+ super(el, { ...options, chartType: "line" });
4
+ this.swatches = [];
5
+ this.visibleKls = options.visibleKls;
24
6
 
25
- this.chart = new Chart(this.ctx, {
26
- type: this.options.chartType,
27
- data: { labels: this.options.labels, datasets: this.datasets },
28
- options: this.chartOptions,
29
- });
7
+ this.init();
30
8
  }
31
9
 
32
- addMarksToChart() {
33
- this.options.marks.forEach(([bucket, label], i) => {
34
- this.chart.options.plugins.annotation.annotations[`deploy-${i}`] = {
35
- type: "line",
36
- xMin: bucket,
37
- xMax: bucket,
38
- borderColor: "rgba(220, 38, 38, 0.4)",
39
- borderWidth: 2,
40
- };
41
- });
10
+ get datasets() {
11
+ return Object.entries(this.options.series)
12
+ .filter(([kls, _]) => this.visibleKls.includes(kls))
13
+ .map(([kls, _]) => this.buildDataset(kls));
42
14
  }
43
- }
44
15
 
45
- class JobMetricsOverviewChart extends BaseChart {
46
- constructor(id, options) {
47
- super(id, { ...options, chartType: "line" });
48
- this.swatches = [];
16
+ get metric() {
17
+ return this._metric || this.options.initialMetric;
18
+ }
49
19
 
50
- this.addMarksToChart();
51
- this.chart.update();
20
+ set metric(m) {
21
+ this._metric = m;
52
22
  }
53
23
 
54
24
  registerSwatch(id) {
55
25
  const el = document.getElementById(id);
56
- el.onchange = () => this.toggle(el.value, el.checked);
26
+ el.addEventListener("change", () => this.toggleKls(el.value, el.checked));
57
27
  this.swatches[el.value] = el;
58
- this.updateSwatch(el.value);
28
+ this.updateSwatch(el.value, el.checked);
59
29
  }
60
30
 
61
- updateSwatch(kls) {
31
+ updateSwatch(kls, checked) {
62
32
  const el = this.swatches[kls];
63
- const ds = this.chart.data.datasets.find((ds) => ds.label == kls);
64
- el.checked = !!ds;
65
- el.style.color = ds ? ds.borderColor : null;
33
+ el.checked = checked;
34
+ el.style.accentColor = this.colors.assignments[kls] || "";
66
35
  }
67
36
 
68
- toggle(kls, visible) {
37
+ toggleKls(kls, visible) {
69
38
  if (visible) {
70
- this.chart.data.datasets.push(this.dataset(kls));
39
+ this.chart.data.datasets.push(this.buildDataset(kls));
71
40
  } else {
72
41
  const i = this.chart.data.datasets.findIndex((ds) => ds.label == kls);
73
- this.colors.unshift(this.chart.data.datasets[i].borderColor);
42
+ this.colors.checkIn(kls);
74
43
  this.chart.data.datasets.splice(i, 1);
75
44
  }
76
45
 
77
- this.updateSwatch(kls);
78
- this.chart.update();
46
+ this.updateSwatch(kls, visible);
47
+ this.update();
79
48
  }
80
49
 
81
- dataset(kls) {
82
- const color = this.colors.shift() || this.fallbackColor;
50
+ buildDataset(kls) {
51
+ const color = this.colors.checkOut(kls);
83
52
 
84
53
  return {
85
54
  label: kls,
86
- data: this.options.series[kls],
55
+ data: this.dataFromSeries(this.options.series[kls]),
87
56
  borderColor: color,
88
57
  backgroundColor: color,
89
58
  borderWidth: 2,
@@ -91,40 +60,46 @@ class JobMetricsOverviewChart extends BaseChart {
91
60
  };
92
61
  }
93
62
 
94
- get datasets() {
95
- return Object.entries(this.options.series)
96
- .filter(([kls, _]) => this.options.visible.includes(kls))
97
- .map(([kls, _]) => this.dataset(kls));
63
+ dataFromSeries(series) {
64
+ // Chart.js expects `data` to be an array of objects with `x` and `y` values.
65
+ return Object.entries(series).map(([isoTime, val]) => ({ x: isoTime, y: val }));
98
66
  }
99
67
 
100
68
  get chartOptions() {
101
69
  return {
70
+ ...super.chartOptions,
102
71
  aspectRatio: 4,
103
72
  scales: {
73
+ ...super.chartOptions.scales,
74
+ x: {
75
+ ...super.chartOptions.scales.x,
76
+ type: "time",
77
+ min: this.options.starts_at,
78
+ max: this.options.ends_at,
79
+ },
104
80
  y: {
81
+ ...super.chartOptions.scales.y,
105
82
  beginAtZero: true,
106
83
  title: {
107
- text: "Total execution time (sec)",
84
+ text: this.options.yLabel,
108
85
  display: true,
109
86
  },
110
87
  },
111
88
  },
112
- interaction: {
113
- mode: "x",
114
- },
115
89
  plugins: {
116
- legend: {
117
- display: false,
118
- },
90
+ ...super.chartOptions.plugins,
119
91
  tooltip: {
92
+ ...super.chartOptions.plugins.tooltip,
120
93
  callbacks: {
121
- title: (items) => `${items[0].label} UTC`,
122
94
  label: (item) =>
123
- `${item.dataset.label}: ${item.parsed.y.toFixed(1)} seconds`,
95
+ `${item.dataset.label}: ${item.parsed.y.toFixed(1)} ` +
96
+ `${this.options.units}`,
124
97
  footer: (items) => {
125
- const bucket = items[0].label;
98
+ const bucket = items[0].raw.x;
126
99
  const marks = this.options.marks.filter(([b, _]) => b == bucket);
127
- return marks.map(([b, msg]) => `Deploy: ${msg}`);
100
+ return marks.map(
101
+ ([b, msg]) => `${this.options.markLabel}: ${msg}`
102
+ );
128
103
  },
129
104
  },
130
105
  },
@@ -134,40 +109,49 @@ class JobMetricsOverviewChart extends BaseChart {
134
109
  }
135
110
 
136
111
  class HistTotalsChart extends BaseChart {
137
- constructor(id, options) {
138
- super(id, { ...options, chartType: "bar" });
112
+ constructor(el, options) {
113
+ super(el, { ...options, chartType: "bar" });
114
+ this.init();
139
115
  }
140
116
 
141
117
  get datasets() {
142
- return [{
143
- data: this.options.series,
144
- backgroundColor: this.colors[0],
145
- borderWidth: 0,
146
- }];
118
+ return [
119
+ {
120
+ data: this.options.series,
121
+ backgroundColor: this.colors.primary,
122
+ borderWidth: 0,
123
+ },
124
+ ];
147
125
  }
148
126
 
149
127
  get chartOptions() {
150
128
  return {
129
+ ...super.chartOptions,
151
130
  aspectRatio: 6,
152
131
  scales: {
132
+ ...super.chartOptions.scales,
153
133
  y: {
134
+ ...super.chartOptions.scales.y,
154
135
  beginAtZero: true,
155
136
  title: {
156
- text: "Total jobs",
137
+ text: this.options.yLabel,
138
+ display: true,
139
+ },
140
+ },
141
+ x: {
142
+ ...super.chartOptions.scales.x,
143
+ title: {
144
+ text: this.options.xLabel,
157
145
  display: true,
158
146
  },
159
147
  },
160
- },
161
- interaction: {
162
- mode: "x",
163
148
  },
164
149
  plugins: {
165
- legend: {
166
- display: false,
167
- },
150
+ ...super.chartOptions.plugins,
168
151
  tooltip: {
152
+ ...super.chartOptions.plugins.tooltip,
169
153
  callbacks: {
170
- label: (item) => `${item.parsed.y} jobs`,
154
+ label: (item) => `${item.parsed.y} ${this.options.units}`,
171
155
  },
172
156
  },
173
157
  },
@@ -176,11 +160,9 @@ class HistTotalsChart extends BaseChart {
176
160
  }
177
161
 
178
162
  class HistBubbleChart extends BaseChart {
179
- constructor(id, options) {
180
- super(id, { ...options, chartType: "bubble" });
181
-
182
- this.addMarksToChart();
183
- this.chart.update();
163
+ constructor(el, options) {
164
+ super(el, { ...options, chartType: "bubble" });
165
+ this.init();
184
166
  }
185
167
 
186
168
  get datasets() {
@@ -190,13 +172,13 @@ class HistBubbleChart extends BaseChart {
190
172
  Object.entries(this.options.hist).forEach(([bucket, hist]) => {
191
173
  hist.forEach((count, histBucket) => {
192
174
  if (count > 0) {
175
+ // histogram data is ordered fastest to slowest, but this.histIntervals is
176
+ // slowest to fastest (so it displays correctly on the chart).
177
+ const index = this.options.histIntervals.length - 1 - histBucket;
178
+
193
179
  data.push({
194
180
  x: bucket,
195
- // histogram data is ordered fastest to slowest, but this.histIntervals is
196
- // slowest to fastest (so it displays correctly on the chart).
197
- y:
198
- this.options.histIntervals[this.options.histIntervals.length - 1 - histBucket] /
199
- 1000,
181
+ y: this.options.histIntervals[index] / 1000,
200
182
  count: count,
201
183
  });
202
184
 
@@ -206,53 +188,55 @@ class HistBubbleChart extends BaseChart {
206
188
  });
207
189
 
208
190
  // Chart.js will not calculate the bubble size. We have to do that.
209
- const maxRadius = this.ctx.offsetWidth / this.options.labels.length;
210
- const minRadius = 1
191
+ const maxRadius = this.el.offsetWidth / 100;
192
+ const minRadius = 1;
211
193
  const multiplier = (maxRadius / maxCount) * 1.5;
212
194
  data.forEach((entry) => {
213
195
  entry.r = entry.count * multiplier + minRadius;
214
196
  });
215
197
 
216
- return [{
217
- data: data,
218
- backgroundColor: "#537bc4",
219
- borderColor: "#537bc4",
220
- }];
198
+ return [
199
+ {
200
+ data: data,
201
+ backgroundColor: this.colors.primary,
202
+ borderColor: this.colors.primary,
203
+ },
204
+ ];
221
205
  }
222
206
 
223
207
  get chartOptions() {
224
208
  return {
209
+ ...super.chartOptions,
225
210
  aspectRatio: 3,
226
211
  scales: {
212
+ ...super.chartOptions.scales,
227
213
  x: {
228
- type: "category",
229
- labels: this.options.labels,
214
+ ...super.chartOptions.scales.x,
215
+ type: "time",
216
+ min: this.options.starts_at,
217
+ max: this.options.ends_at,
230
218
  },
231
219
  y: {
220
+ ...super.chartOptions.scales.y,
232
221
  title: {
233
- text: "Execution time (sec)",
222
+ text: this.options.yLabel,
234
223
  display: true,
235
224
  },
236
225
  },
237
226
  },
238
- interaction: {
239
- mode: "x",
240
- },
241
227
  plugins: {
242
- legend: {
243
- display: false,
244
- },
228
+ ...super.chartOptions.plugins,
245
229
  tooltip: {
230
+ ...super.chartOptions.plugins.tooltip,
246
231
  callbacks: {
247
- title: (items) => `${items[0].raw.x} UTC`,
248
232
  label: (item) =>
249
- `${item.parsed.y} seconds: ${item.raw.count} job${
250
- item.raw.count == 1 ? "" : "s"
251
- }`,
233
+ `${item.parsed.y} ${this.options.yUnits}: ${item.raw.count} ${this.options.zUnits}`,
252
234
  footer: (items) => {
253
235
  const bucket = items[0].raw.x;
254
236
  const marks = this.options.marks.filter(([b, _]) => b == bucket);
255
- return marks.map(([b, msg]) => `Deploy: ${msg}`);
237
+ return marks.map(
238
+ ([b, msg]) => `${this.options.markLabel}: ${msg}`
239
+ );
256
240
  },
257
241
  },
258
242
  },
@@ -260,3 +244,37 @@ class HistBubbleChart extends BaseChart {
260
244
  };
261
245
  }
262
246
  }
247
+
248
+ var ch = document.getElementById("job-metrics-overview-chart");
249
+ if (ch != null) {
250
+ var jm = new JobMetricsOverviewChart(ch, JSON.parse(ch.textContent));
251
+ document.querySelectorAll(".metrics-swatch-wrapper > input[type=checkbox]").forEach((imp) => {
252
+ jm.registerSwatch(imp.id)
253
+ });
254
+ window.jobMetricsChart = jm;
255
+ }
256
+
257
+ var htc = document.getElementById("hist-totals-chart");
258
+ if (htc != null) {
259
+ var tc = new HistTotalsChart(htc, JSON.parse(htc.textContent));
260
+ window.histTotalsChart = tc
261
+ }
262
+
263
+ var hbc = document.getElementById("hist-bubble-chart");
264
+ if (hbc != null) {
265
+ var bc = new HistBubbleChart(hbc, JSON.parse(hbc.textContent));
266
+ window.histBubbleChart = bc
267
+ }
268
+
269
+ var form = document.getElementById("metrics-form")
270
+ document.querySelectorAll("#period-selector").forEach(node => {
271
+ node.addEventListener("input", debounce(() => form.submit()))
272
+ })
273
+
274
+ function debounce(func, timeout = 300) {
275
+ let timer;
276
+ return (...args) => {
277
+ clearTimeout(timer);
278
+ timer = setTimeout(() => { func.apply(this, args); }, timeout);
279
+ };
280
+ }