sidekiq-tasks 0.1.7 → 1.0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d6863627c4991d61e6f1347c061ec8a9c063553d9bee738e84bc3494d38a7b74
4
- data.tar.gz: 7a2566b801e14235edcf36df0963a9e0d6656eac5d1ef6f0d319937968544d4b
3
+ metadata.gz: 8fee03d24fdcc9755f8fadb9915b39127fac89ba1f4447a7d822998b9a64c102
4
+ data.tar.gz: 8a6c5940eae17706522c7f584c392779a0b2c513848fa49cab8206c2a5af5473
5
5
  SHA512:
6
- metadata.gz: 4da5a2d4ee995b4f5aed9dd822f4419ec6c0c2743f3737ed406f75ebb6b701d15f8620f31b936c97cd6cb02415178ca3847ede01be78b6d28fcf26c1691df346
7
- data.tar.gz: fbecf721dc29a700ecb3a1833f276319b2a62ff35ab4425d9be76629c7a242991017dfeb50137d56266e0022efe97a19722e209d8b3381f39e2120f4e4370cac
6
+ metadata.gz: b85eb95f150f99c0de282d0ac6fb1356a984f0cdc7d5a485baf1a9fdb7963346156a9e4c224d1cd22476002efd34bb8ed18bbf1252d762cbd25c8b41623557b4
7
+ data.tar.gz: 51ca1179d3232c84139c8ef380290aa5e9bd8f3b97b21e92a54165fcee0376a1a42b207ec9029510f086b35e2ec69aa091685f89d343a3eb38c31296d8600709
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  ## Changelog
2
2
 
3
+ ### [1.0.0] - 2026-03-13
4
+
5
+ - Add configurable `storage` option with support for custom backends (default: Redis).
6
+ - Add configurable `current_user` option to track who enqueued each task.
7
+ - Add configurable `history_limit` option (default: 10).
8
+ - Add sortable columns (`name`, `last_enqueued`) in the tasks list.
9
+
10
+ ### [0.1.8] - 2026-03-06
11
+
12
+ - Replace deprecated `webdrivers` gem with `selenium-webdriver`.
13
+ - Avoid duplicate CI runs on pull request branches.
14
+ - Fix `retry` option validation to accept integer values.
15
+ - Add `retry_for` option support (Sidekiq 7.1.3+).
16
+ - Fix `find_by` to use exact name matching instead of fuzzy matching.
17
+ - Disable live poll on tasks pages to prevent form state loss (Sidekiq >= 7.0.1).
18
+ - Escape filter parameter in tasks view to prevent HTML injection.
19
+
3
20
  ### [0.1.7] - 2025-07-27
4
21
 
5
22
  - Support multiline description in task view.
data/README.md CHANGED
@@ -10,6 +10,11 @@ Sidekiq-Tasks extends Sidekiq by providing an interface to enqueue tasks directl
10
10
 
11
11
  ![Task view](docs/task.png)
12
12
 
13
+ ## Requirements
14
+
15
+ - Ruby >= 3.0
16
+ - Sidekiq >= 6.5 (compatible with 6.5, 7.x, and 8.x)
17
+
13
18
  ## Installation
14
19
 
15
20
  ```bash
@@ -190,6 +195,8 @@ Sidekiq::Tasks.configure do |config|
190
195
  end
191
196
  ```
192
197
 
198
+ All standard [Sidekiq job options](https://github.com/sidekiq/sidekiq/wiki/Advanced-Options#jobs) are supported.
199
+
193
200
  You can also override the `enqueue_task` method to implement your own enqueuing logic for your strategy:
194
201
 
195
202
  ```ruby
@@ -218,6 +225,40 @@ end
218
225
  >[!NOTE]
219
226
  > The `Tasks` button in the header will still be displayed regardless of the value of `authorization`.
220
227
 
228
+ ## Execution history
229
+
230
+ Each task keeps a history of its last executions (default: 10, stored in Redis). The task detail page displays for each execution:
231
+
232
+ - The enqueue, start, and finish timestamps
233
+ - The arguments passed
234
+ - The error message if the execution failed
235
+
236
+ You can configure the history limit:
237
+
238
+ ```ruby
239
+ Sidekiq::Tasks.configure do |config|
240
+ config.history_limit = 25
241
+ end
242
+ ```
243
+
244
+ You can also use a custom storage backend. See the [Custom Storage Guide](docs/custom_storage.md) for more details.
245
+
246
+ ## Current user
247
+
248
+ You can configure a `current_user` proc to track who enqueued each task.
249
+ The proc receives the Rack `env` and should return a Hash identifying the user:
250
+
251
+ ```ruby
252
+ Sidekiq::Tasks.configure do |config|
253
+ config.current_user = ->(env) do
254
+ user = env["warden"].user(:admin_user)
255
+ {id: user.id, email: user.email}
256
+ end
257
+ end
258
+ ```
259
+
260
+ When configured, an "Enqueued by" column appears in the task history table.
261
+
221
262
  ## Development
222
263
 
223
264
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -0,0 +1,95 @@
1
+ # Custom Storage Guide
2
+
3
+ This guide walks you through implementing a custom storage backend using ActiveRecord in a Rails application.
4
+
5
+ ## Step 1: Generate the model and run the migration
6
+
7
+ ```bash
8
+ rails generate model TaskExecution \
9
+ task_name:string:index \
10
+ jid:string:index \
11
+ args:jsonb \
12
+ enqueued_at:datetime \
13
+ executed_at:datetime \
14
+ finished_at:datetime \
15
+ error:string \
16
+ user:jsonb
17
+
18
+ rails db:migrate
19
+ ```
20
+
21
+ ## Step 2: Configure the initializer
22
+
23
+ In `config/initializers/sidekiq_tasks.rb`:
24
+
25
+ ```ruby
26
+ class ActiveRecordStorage < Sidekiq::Tasks::Storage::Base
27
+ def last_enqueue_at
28
+ TaskExecution.where(task_name: task_name).order(enqueued_at: :desc).pick(:enqueued_at)
29
+ end
30
+
31
+ def history
32
+ TaskExecution
33
+ .where(task_name: task_name)
34
+ .order(enqueued_at: :desc)
35
+ .limit(history_limit)
36
+ .select(:jid, :task_name, :args, :enqueued_at, :executed_at, :finished_at, :error, :user)
37
+ .map(&:attributes)
38
+ end
39
+
40
+ def store_enqueue(jid, args, user: nil)
41
+ TaskExecution.create!(
42
+ task_name: task_name,
43
+ jid: jid,
44
+ args: args,
45
+ enqueued_at: Time.now,
46
+ user: user
47
+ )
48
+ end
49
+
50
+ def store_execution(jid, time_key)
51
+ TaskExecution.find_by(jid: jid)&.update!(time_key => Time.now)
52
+ end
53
+
54
+ def store_execution_error(jid, error)
55
+ message = truncate_message("#{error.class}: #{error.message}", ERROR_MESSAGE_MAX_LENGTH)
56
+ TaskExecution.find_by(jid: jid)&.update!(error: message)
57
+ end
58
+ end
59
+
60
+ Sidekiq::Tasks.configure do |config|
61
+ config.storage = ActiveRecordStorage
62
+ end
63
+ ```
64
+
65
+ > [!NOTE]
66
+ > The `history_limit` config is passed to each storage instance. The default Redis storage uses it to trim old entries. Custom storage implementations receive it via the `history_limit` accessor and can use it as needed (e.g. as a SQL `LIMIT`) or ignore it entirely.
67
+
68
+ ## History entry format
69
+
70
+ The `history` method must return an array of hashes with the following keys:
71
+
72
+ | Key | Type | Description |
73
+ |-----------------|---------------|--------------------------------------|
74
+ | `"jid"` | String | The Sidekiq job ID |
75
+ | `"task_name"` | String | The task name |
76
+ | `"args"` | Hash | The arguments passed to the task |
77
+ | `"enqueued_at"` | Time | When the task was enqueued |
78
+ | `"executed_at"` | Time \| nil | When the task started executing |
79
+ | `"finished_at"` | Time \| nil | When the task finished executing |
80
+ | `"error"` | String \| nil | Error message if execution failed |
81
+ | `"user"` | Hash \| nil | User who enqueued the task |
82
+
83
+ ## Migrating from Redis
84
+
85
+ After switching to a custom storage, you can clean up the old Redis history:
86
+
87
+ ```bash
88
+ redis-cli --scan --pattern "task:*" | xargs redis-cli del
89
+ ```
90
+
91
+ Or from a Rails console:
92
+
93
+ ```ruby
94
+ Sidekiq.redis { |conn| conn.keys("task:*").each { |key| conn.del(key) } }
95
+ ```
@@ -15,21 +15,29 @@ module Sidekiq
15
15
  ),
16
16
  ].freeze
17
17
 
18
+ DEFAULT_STORAGE = Sidekiq::Tasks::Storage::Redis
19
+
20
+ DEFAULT_HISTORY_LIMIT = 10
21
+
18
22
  include Sidekiq::Tasks::Validations
19
23
 
20
- attr_reader :strategies, :sidekiq_options, :authorization
24
+ attr_reader :strategies, :sidekiq_options, :authorization, :history_limit, :current_user, :storage
21
25
 
22
26
  def initialize
23
27
  @sidekiq_options = DEFAULT_SIDEKIQ_OPTIONS
24
28
  @strategies = DEFAULT_STRATEGIES
29
+ @storage = DEFAULT_STORAGE
25
30
  @authorization = ->(_env) { true }
31
+ @history_limit = DEFAULT_HISTORY_LIMIT
32
+ @current_user = nil
26
33
  end
27
34
 
28
35
  # @see https://github.com/sidekiq/sidekiq/wiki/Advanced-Options#jobs
29
36
  def sidekiq_options=(options)
30
37
  validate_class!(options, [Hash], "sidekiq_options")
31
38
  validate_hash_option!(options, :queue, [String])
32
- validate_hash_option!(options, :retry, [TrueClass, FalseClass])
39
+ validate_hash_option!(options, :retry, [NilClass, TrueClass, FalseClass, Integer])
40
+ validate_hash_option!(options, :retry_for, [NilClass, Integer, Float])
33
41
  validate_hash_option!(options, :dead, [NilClass, TrueClass, FalseClass])
34
42
  validate_hash_option!(options, :backtrace, [NilClass, TrueClass, FalseClass, Integer])
35
43
  validate_hash_option!(options, :pool, [NilClass, String])
@@ -44,11 +52,31 @@ module Sidekiq
44
52
  @strategies = strategies
45
53
  end
46
54
 
55
+ def storage=(storage_class)
56
+ validate_subclass!(storage_class, Sidekiq::Tasks::Storage::Base, "storage")
57
+
58
+ @storage = storage_class
59
+ end
60
+
61
+ def history_limit=(limit)
62
+ validate_class!(limit, [Integer], "history_limit")
63
+
64
+ raise Sidekiq::Tasks::ArgumentError, "'history_limit' must be greater than 0" if limit <= 0
65
+
66
+ @history_limit = limit
67
+ end
68
+
47
69
  def authorization=(authorization_proc)
48
70
  validate_class!(authorization_proc, [Proc], "authorization")
49
71
 
50
72
  @authorization = authorization_proc
51
73
  end
74
+
75
+ def current_user=(current_user_proc)
76
+ validate_class!(current_user_proc, [Proc], "current_user")
77
+
78
+ @current_user = current_user_proc
79
+ end
52
80
  end
53
81
  end
54
82
  end
@@ -29,7 +29,7 @@ module Sidekiq
29
29
  end
30
30
 
31
31
  def find_by(name: nil)
32
- where(name: name).first
32
+ objects.find { |object| object.name == name }
33
33
  end
34
34
 
35
35
  def find_by!(name: nil)
@@ -0,0 +1,73 @@
1
+ module Sidekiq
2
+ module Tasks
3
+ module Storage
4
+ class Base
5
+ ERROR_MESSAGE_MAX_LENGTH = 255
6
+
7
+ attr_reader :task_name, :history_limit
8
+
9
+ def initialize(task_name, history_limit: nil)
10
+ @task_name = task_name
11
+ @history_limit = history_limit
12
+ end
13
+
14
+ # Returns the last enqueue time for the task.
15
+ #
16
+ # @abstract Subclasses must implement this method.
17
+ # @return [Time, NilClass] The last enqueue time or nil.
18
+ # @raise [NotImplementedError] If the method is not implemented in a subclass.
19
+ def last_enqueue_at
20
+ raise NotImplementedError, "Storage must implement #last_enqueue_at"
21
+ end
22
+
23
+ # Returns the execution history for the task.
24
+ #
25
+ # @abstract Subclasses must implement this method.
26
+ # @return [Array<Hash>] The execution history entries.
27
+ # @raise [NotImplementedError] If the method is not implemented in a subclass.
28
+ def history
29
+ raise NotImplementedError, "Storage must implement #history"
30
+ end
31
+
32
+ # Stores enqueue information for the task.
33
+ #
34
+ # @abstract Subclasses must implement this method.
35
+ # @param jid [String] The Sidekiq job ID.
36
+ # @param args [Hash] The arguments passed to the task.
37
+ # @param user [Hash, NilClass] The user who enqueued the task.
38
+ # @raise [NotImplementedError] If the method is not implemented in a subclass.
39
+ def store_enqueue(_jid, _args, user: nil)
40
+ raise NotImplementedError, "Storage must implement #store_enqueue"
41
+ end
42
+
43
+ # Stores execution time for a specific history entry.
44
+ #
45
+ # @abstract Subclasses must implement this method.
46
+ # @param jid [String] The Sidekiq job ID.
47
+ # @param time_key [String] The time key to store (e.g. "executed_at", "finished_at").
48
+ # @raise [NotImplementedError] If the method is not implemented in a subclass.
49
+ def store_execution(_jid, _time_key)
50
+ raise NotImplementedError, "Storage must implement #store_execution"
51
+ end
52
+
53
+ # Stores an execution error for a specific history entry.
54
+ #
55
+ # @abstract Subclasses must implement this method.
56
+ # @param jid [String] The Sidekiq job ID.
57
+ # @param error [Exception] The error that occurred during execution.
58
+ # @raise [NotImplementedError] If the method is not implemented in a subclass.
59
+ def store_execution_error(_jid, _error)
60
+ raise NotImplementedError, "Storage must implement #store_execution_error"
61
+ end
62
+
63
+ protected
64
+
65
+ def truncate_message(message, max_length)
66
+ return message if message.length <= max_length
67
+
68
+ "#{message[0...(max_length - 3)]}..."
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,89 @@
1
+ module Sidekiq
2
+ module Tasks
3
+ module Storage
4
+ class Redis < Base
5
+ JID_PREFIX = "task".freeze
6
+ def jid_key
7
+ "#{JID_PREFIX}:#{task_name}"
8
+ end
9
+
10
+ def history_key
11
+ "#{jid_key}:history"
12
+ end
13
+
14
+ def last_enqueue_at
15
+ stored_time("last_enqueue_at")
16
+ end
17
+
18
+ def history
19
+ raw_entries = Sidekiq.redis { |conn| conn.lrange(history_key, 0, -1) }
20
+
21
+ return [] unless raw_entries
22
+
23
+ raw_entries.map do |raw|
24
+ entry = Sidekiq.load_json(raw)
25
+ ["enqueued_at", "executed_at", "finished_at"].each do |key|
26
+ entry[key] = Time.at(entry[key]) if entry[key]
27
+ end
28
+ entry
29
+ end
30
+ end
31
+
32
+ def store_history(jid, task_args, time, user: nil)
33
+ Sidekiq.redis do |conn|
34
+ task_trace = {jid: jid, task_name: task_name, args: task_args, enqueued_at: time.to_f}
35
+ task_trace[:user] = user if user
36
+ conn.lpush(history_key, Sidekiq.dump_json(task_trace))
37
+ conn.ltrim(history_key, 0, history_limit - 1)
38
+ end
39
+ end
40
+
41
+ def store_enqueue(jid, args, user: nil)
42
+ time = Time.now.to_f
43
+ store_time(time, "last_enqueue_at")
44
+ store_history(jid, args, time, user: user)
45
+ end
46
+
47
+ def store_execution(jid, time_key)
48
+ update_history_entry(jid) do |entry|
49
+ entry.merge(time_key => Time.now.to_f)
50
+ end
51
+ end
52
+
53
+ def store_execution_error(jid, error)
54
+ update_history_entry(jid) do |entry|
55
+ error_message = truncate_message("#{error.class}: #{error.message}", ERROR_MESSAGE_MAX_LENGTH)
56
+ entry.merge("error" => error_message)
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def store_time(time, time_key)
63
+ Sidekiq.redis { |conn| conn.hset(jid_key, time_key, time.to_f) }
64
+ end
65
+
66
+ def stored_time(time_key)
67
+ timestamp = Sidekiq.redis { |conn| conn.hget(jid_key, time_key) }
68
+
69
+ [nil, ""].include?(timestamp) ? nil : Time.at(timestamp.to_f)
70
+ end
71
+
72
+ def update_history_entry(jid)
73
+ Sidekiq.redis do |conn|
74
+ entries = conn.lrange(history_key, 0, -1)
75
+
76
+ entries.each_with_index do |raw, index|
77
+ entry = Sidekiq.load_json(raw)
78
+ next unless entry["jid"] == jid
79
+
80
+ updated_entry = yield(entry)
81
+ conn.lset(history_key, index, Sidekiq.dump_json(updated_entry))
82
+ break
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -1,101 +1,8 @@
1
1
  module Sidekiq
2
2
  module Tasks
3
- class Storage
4
- JID_PREFIX = "task".freeze
5
- HISTORY_LIMIT = 10
6
- ERROR_MESSAGE_MAX_LENGTH = 255
7
-
8
- attr_reader :task_name
9
-
10
- def initialize(task_name)
11
- @task_name = task_name
12
- end
13
-
14
- def jid_key
15
- "#{JID_PREFIX}:#{task_name}"
16
- end
17
-
18
- def history_key
19
- "#{jid_key}:history"
20
- end
21
-
22
- def last_enqueue_at
23
- stored_time("last_enqueue_at")
24
- end
25
-
26
- def history
27
- raw_entries = Sidekiq.redis { |conn| conn.lrange(history_key, 0, -1) }
28
-
29
- return [] unless raw_entries
30
-
31
- raw_entries.map do |raw|
32
- entry = Sidekiq.load_json(raw)
33
- %w[enqueued_at executed_at finished_at].each do |key|
34
- entry[key] = Time.at(entry[key]) if entry[key]
35
- end
36
- entry
37
- end
38
- end
39
-
40
- def store_history(jid, task_args, time)
41
- Sidekiq.redis do |conn|
42
- task_trace = {jid: jid, name: task_name, args: task_args, enqueued_at: time.to_f}
43
- conn.lpush(history_key, Sidekiq.dump_json(task_trace))
44
- conn.ltrim(history_key, 0, HISTORY_LIMIT - 1)
45
- end
46
- end
47
-
48
- def store_enqueue(jid, args)
49
- time = Time.now.to_f
50
- store_time(time, "last_enqueue_at")
51
- store_history(jid, args, time)
52
- end
53
-
54
- def store_execution(jid, time_key)
55
- update_history_entry(jid) do |entry|
56
- entry.merge(time_key => Time.now.to_f)
57
- end
58
- end
59
-
60
- def store_execution_error(jid, error)
61
- update_history_entry(jid) do |entry|
62
- error_message = truncate_message("#{error.class}: #{error.message}", ERROR_MESSAGE_MAX_LENGTH)
63
- entry.merge("error" => error_message)
64
- end
65
- end
66
-
67
- private
68
-
69
- def truncate_message(message, max_length)
70
- return message if message.length <= max_length
71
-
72
- "#{message[0...(max_length - 3)]}..."
73
- end
74
-
75
- def store_time(time, time_key)
76
- Sidekiq.redis { |conn| conn.hset(jid_key, time_key, time.to_f) }
77
- end
78
-
79
- def stored_time(time_key)
80
- timestamp = Sidekiq.redis { |conn| conn.hget(jid_key, time_key) }
81
-
82
- [nil, ""].include?(timestamp) ? nil : Time.at(timestamp.to_f)
83
- end
84
-
85
- def update_history_entry(jid)
86
- Sidekiq.redis do |conn|
87
- entries = conn.lrange(history_key, 0, -1)
88
-
89
- entries.each_with_index do |raw, index|
90
- entry = Sidekiq.load_json(raw)
91
- next unless entry["jid"] == jid
92
-
93
- updated_entry = yield(entry)
94
- conn.lset(history_key, index, Sidekiq.dump_json(updated_entry))
95
- break
96
- end
97
- end
98
- end
3
+ module Storage
4
+ autoload :Base, "sidekiq/tasks/storage/base"
5
+ autoload :Redis, "sidekiq/tasks/storage/redis"
99
6
  end
100
7
  end
101
8
  end
@@ -22,10 +22,10 @@ module Sidekiq
22
22
  validate_class!(strategy, [Sidekiq::Tasks::Strategies::Base], "strategy")
23
23
  end
24
24
 
25
- def enqueue(params = {})
25
+ def enqueue(params = {}, user: nil)
26
26
  jid = strategy.enqueue_task(name, params)
27
27
 
28
- storage.store_enqueue(jid, params)
28
+ storage.store_enqueue(jid, params, user: user)
29
29
  end
30
30
 
31
31
  def execute(params = {}, jid: nil)
@@ -33,7 +33,7 @@ module Sidekiq
33
33
 
34
34
  begin
35
35
  strategy.execute_task(name, params)
36
- rescue => e
36
+ rescue StandardError, SystemExit => e
37
37
  storage.store_execution(jid, "finished_at")
38
38
  storage.store_execution_error(jid, e)
39
39
  raise
@@ -43,7 +43,7 @@ module Sidekiq
43
43
  end
44
44
 
45
45
  def storage
46
- @_storage ||= Sidekiq::Tasks::Storage.new(name)
46
+ @_storage ||= Sidekiq::Tasks.config.storage.new(name, history_limit: Sidekiq::Tasks.config.history_limit)
47
47
  end
48
48
  end
49
49
  end
@@ -32,6 +32,14 @@ module Sidekiq
32
32
  "'#{name}' must be one of #{expected_values.map(&:inspect).join(" or ")} but received #{value.inspect}"
33
33
  end
34
34
  module_function :validate_expected_values!
35
+
36
+ def validate_subclass!(klass, base_class, name = nil)
37
+ return if klass.is_a?(Class) && klass <= base_class
38
+
39
+ raise Sidekiq::Tasks::ArgumentError,
40
+ "'#{name}' must be a class inheriting from #{base_class.name} but received #{klass.inspect}"
41
+ end
42
+ module_function :validate_subclass!
35
43
  end
36
44
  end
37
45
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Sidekiq
4
4
  module Tasks
5
- VERSION = "0.1.7"
5
+ VERSION = "1.0.0"
6
6
  end
7
7
  end
@@ -2,6 +2,7 @@ require "sidekiq/tasks/web/helpers/application_helper"
2
2
  require "sidekiq/tasks/web/helpers/tag_helper"
3
3
  require "sidekiq/tasks/web/helpers/task_helper"
4
4
  require "sidekiq/tasks/web/helpers/pagination_helper"
5
+ require "sidekiq/tasks/web/helpers/sort_helper"
5
6
  require "sidekiq/tasks/web/search"
6
7
  require "sidekiq/tasks/web/pagination"
7
8
  require "sidekiq/tasks/web/params"
@@ -15,11 +16,12 @@ module Sidekiq
15
16
  app.helpers(Sidekiq::Tasks::Web::Helpers::TagHelper)
16
17
  app.helpers(Sidekiq::Tasks::Web::Helpers::TaskHelper)
17
18
  app.helpers(Sidekiq::Tasks::Web::Helpers::PaginationHelper)
19
+ app.helpers(Sidekiq::Tasks::Web::Helpers::SortHelper)
18
20
 
19
21
  app.get "/tasks" do
20
22
  authorize!
21
23
 
22
- @search = Sidekiq::Tasks::Web::Search.new(fetch_params(:count, :page, :filter))
24
+ @search = Sidekiq::Tasks::Web::Search.new(fetch_params(:count, :page, :filter, :sort, :direction))
23
25
 
24
26
  erb(read_view(:tasks), locals: {search: @search})
25
27
  end
@@ -29,7 +31,22 @@ module Sidekiq
29
31
 
30
32
  @task = find_task!(env["rack.route_params"][:name])
31
33
 
32
- erb(read_view(:task), locals: {task: @task})
34
+ history = @task.history
35
+ per_page = 10
36
+ page = [fetch_param("page").to_i, 1].max
37
+ total_pages = [(history.size.to_f / per_page).ceil, 1].max
38
+ history_entries = history.slice((page - 1) * per_page, per_page) || []
39
+
40
+ erb(
41
+ read_view(:task),
42
+ locals: {
43
+ task: @task,
44
+ history_entries: history_entries,
45
+ history_page: page,
46
+ history_total_pages: total_pages,
47
+ history_total_count: history.size,
48
+ }
49
+ )
33
50
  rescue Sidekiq::Tasks::NotFoundError
34
51
  throw :halt, [404, {Rack::CONTENT_TYPE => "text/plain"}, ["Task not found"]]
35
52
  end
@@ -44,7 +61,8 @@ module Sidekiq
44
61
  task = find_task!(env["rack.route_params"][:name])
45
62
  args = Sidekiq::Tasks::Web::Params.new(task, fetch_param("args")).permit!
46
63
 
47
- task.enqueue(args)
64
+ current_user = Sidekiq::Tasks.config.current_user&.call(env)
65
+ task.enqueue(args, user: current_user)
48
66
 
49
67
  redirect(task_url(root_path, task))
50
68
  rescue Sidekiq::Tasks::ArgumentError => e
@@ -25,6 +25,12 @@ module Sidekiq
25
25
  keys.to_h { |key| [key.to_sym, fetch_param(key)] }
26
26
  end
27
27
 
28
+ def pollable?
29
+ return false if current_path.start_with?("tasks")
30
+
31
+ super
32
+ end
33
+
28
34
  def authorize!
29
35
  return if Sidekiq::Tasks.config.authorization.call(env)
30
36
 
@@ -6,22 +6,29 @@ module Sidekiq
6
6
  extend self
7
7
  include Sidekiq::Tasks::Web::Helpers::TagHelper
8
8
 
9
- def pagination_link(root_path, link, search)
9
+ def pagination_base_url(search, root_path)
10
+ query_params = {
11
+ filter: search.filter.to_s,
12
+ count: search.count,
13
+ sort: search.sort,
14
+ direction: search.direction,
15
+ }
16
+
17
+ "#{root_path}tasks?#{URI.encode_www_form(query_params)}"
18
+ end
19
+
20
+ def build_pagination_link(link, base_url)
21
+ separator = base_url.include?("?") ? "&" : "?"
22
+
10
23
  build_tag(:li, class: "st-page-item") do
11
24
  build_tag(
12
25
  :a,
13
26
  link[:text],
14
27
  class: build_classes("st-page-link", disabled: link[:disabled], active: link[:active]),
15
- href: pagination_url(root_path, search, link[:page])
28
+ href: "#{base_url}#{separator}page=#{link[:page]}"
16
29
  )
17
30
  end
18
31
  end
19
-
20
- private
21
-
22
- def pagination_url(root_path, search, page)
23
- "#{root_path}tasks?filter=#{ERB::Util.url_encode(search.filter)}&count=#{search.count}&page=#{page}"
24
- end
25
32
  end
26
33
  end
27
34
  end
@@ -0,0 +1,26 @@
1
+ module Sidekiq
2
+ module Tasks
3
+ module Web
4
+ module Helpers
5
+ module SortHelper
6
+ extend self
7
+
8
+ def sort_header_url(search, root_path, column)
9
+ next_direction = search.toggle_direction(column)
10
+
11
+ query_params = {filter: search.filter.to_s, count: search.count}
12
+ query_params.merge!(sort: column, direction: next_direction) if next_direction
13
+
14
+ "#{root_path}tasks?#{URI.encode_www_form(query_params)}"
15
+ end
16
+
17
+ def sort_header_classes(search, column)
18
+ css = "st-sortable"
19
+ css += " st-sorted-#{search.direction}" if search.sorted_by?(column)
20
+ css
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -3,6 +3,8 @@ module Sidekiq
3
3
  module Web
4
4
  class Search
5
5
  DEFAULT_COUNT = 15
6
+ SORT_COLUMNS = ["name", "last_enqueued"].freeze
7
+ SORT_DIRECTIONS = ["asc", "desc"].freeze
6
8
 
7
9
  def self.count_options
8
10
  (0..3).map { |index| DEFAULT_COUNT * (2**index) }
@@ -15,7 +17,7 @@ module Sidekiq
15
17
  end
16
18
 
17
19
  def tasks
18
- @_tasks ||= filtered_collection.sort_by(&:file).slice(offset, count) || []
20
+ @_tasks ||= sorted_collection.slice(offset, count) || []
19
21
  end
20
22
 
21
23
  def filtered_collection
@@ -40,6 +42,25 @@ module Sidekiq
40
42
  requested_page.positive? ? requested_page : 1
41
43
  end
42
44
 
45
+ def sort
46
+ SORT_COLUMNS.include?(params[:sort]) ? params[:sort] : SORT_COLUMNS.first
47
+ end
48
+
49
+ def direction
50
+ SORT_DIRECTIONS.include?(params[:direction]) ? params[:direction] : SORT_DIRECTIONS.first
51
+ end
52
+
53
+ def toggle_direction(column)
54
+ return "asc" unless sort == column
55
+ return "desc" if direction == "asc"
56
+
57
+ nil
58
+ end
59
+
60
+ def sorted_by?(column)
61
+ sort == column
62
+ end
63
+
43
64
  def total_pages
44
65
  (filtered_collection.size.to_f / count).ceil
45
66
  end
@@ -47,6 +68,28 @@ module Sidekiq
47
68
  def offset
48
69
  (page - 1) * count
49
70
  end
71
+
72
+ private
73
+
74
+ def sorted_collection
75
+ sorted = filtered_collection.sort_by { |task| sort_value(task) }
76
+ direction == "desc" ? sorted.reverse : sorted
77
+ end
78
+
79
+ def sort_value(task)
80
+ case sort
81
+ when "name"
82
+ task.name.to_s.downcase
83
+ when "last_enqueued"
84
+ sort_value_for_time(task.last_enqueue_at)
85
+ end
86
+ end
87
+
88
+ def sort_value_for_time(time)
89
+ return time.to_f if time
90
+
91
+ direction == "asc" ? Float::INFINITY : -Float::INFINITY
92
+ end
50
93
  end
51
94
  end
52
95
  end
@@ -6,6 +6,7 @@ module Sidekiq
6
6
  autoload :Extension, "sidekiq/tasks/web/extension"
7
7
 
8
8
  ROOT = File.expand_path("../../../web", File.dirname(__FILE__))
9
+ SIDEKIQ_GTE_7_0_1 = Gem::Version.new(Sidekiq::VERSION) >= Gem::Version.new("7.0.1")
9
10
  SIDEKIQ_GTE_7_3_0 = Gem::Version.new(Sidekiq::VERSION) >= Gem::Version.new("7.3.0")
10
11
  SIDEKIQ_GTE_8_0_0 = Gem::Version.new(Sidekiq::VERSION) >= Gem::Version.new("8.0.0")
11
12
  end
data/lib/sidekiq/tasks.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "rake"
4
4
 
5
5
  require_relative "tasks/errors"
6
+ require_relative "tasks/storage"
6
7
  require_relative "tasks/strategies"
7
8
  require_relative "tasks/validations"
8
9
  require_relative "tasks/version"
@@ -12,7 +13,6 @@ module Sidekiq
12
13
  autoload :Config, "sidekiq/tasks/config"
13
14
  autoload :Job, "sidekiq/tasks/job"
14
15
  autoload :Set, "sidekiq/tasks/set"
15
- autoload :Storage, "sidekiq/tasks/storage"
16
16
  autoload :Task, "sidekiq/tasks/task"
17
17
  autoload :TaskMetadata, "sidekiq/tasks/task_metadata"
18
18
 
@@ -22,6 +22,7 @@
22
22
 
23
23
  .st-table-container {
24
24
  overflow: auto;
25
+ max-width: 100%;
25
26
  }
26
27
 
27
28
  .st-table {
@@ -64,9 +65,42 @@
64
65
  border-radius: 2px;
65
66
  padding: 0.2em 0.4em;
66
67
  border-radius: 0.2em;
68
+ display: block;
69
+ max-width: 200px;
70
+ overflow: auto;
67
71
  }
68
72
 
69
73
  .st-table .st-desc-cell {
70
74
  white-space: pre-wrap;
71
75
  word-wrap: break-word;
72
76
  }
77
+
78
+ .st-sortable {
79
+ color: inherit;
80
+ text-decoration: none;
81
+ cursor: pointer;
82
+ }
83
+
84
+ .st-sortable:hover {
85
+ text-decoration: underline;
86
+ }
87
+
88
+ .st-sorted-asc::after,
89
+ .st-sorted-desc::after {
90
+ content: "";
91
+ display: inline-block;
92
+ margin-left: 5px;
93
+ width: 0;
94
+ height: 0;
95
+ vertical-align: middle;
96
+ border-left: 4px solid transparent;
97
+ border-right: 4px solid transparent;
98
+ }
99
+
100
+ .st-sorted-asc::after {
101
+ border-bottom: 4px solid currentColor;
102
+ }
103
+
104
+ .st-sorted-desc::after {
105
+ border-top: 4px solid currentColor;
106
+ }
data/web/locales/en.yml CHANGED
@@ -25,4 +25,5 @@ en:
25
25
  running: "Running"
26
26
  success: "Success"
27
27
  failure: "Failure"
28
+ enqueued_by: "Enqueued by"
28
29
  task_time: "%m/%d/%y %H:%M:%S"
data/web/locales/fr.yml CHANGED
@@ -25,4 +25,5 @@ fr:
25
25
  running: "En cours"
26
26
  success: "Succès"
27
27
  failure: "Echec"
28
+ enqueued_by: "Lancée par"
28
29
  task_time: "%d/%m/%y %H:%M:%S"
@@ -1,14 +1,14 @@
1
- <% if search.total_pages > 1 %>
1
+ <% if total_pages > 1 %>
2
2
  <div class="st-pagination-wrapper">
3
3
  <div>
4
4
  <small class="st-text-primary">
5
- <%= search.tasks.size %> / <%= Sidekiq::Tasks.tasks.size %>
5
+ <%= displayed_count %> / <%= total_count %>
6
6
  </small>
7
7
  </div>
8
8
 
9
9
  <ul class="st-pagination">
10
- <% Sidekiq::Tasks::Web::Pagination.new(search.page, search.total_pages).links.each do |link| %>
11
- <%= pagination_link(root_path, link, search) %>
10
+ <% Sidekiq::Tasks::Web::Pagination.new(page, total_pages).links.each do |link| %>
11
+ <%= build_pagination_link(link, base_url) %>
12
12
  <% end %>
13
13
  </ul>
14
14
  </div>
data/web/views/task.erb CHANGED
@@ -6,7 +6,7 @@
6
6
  <% end %>
7
7
  <% else %>
8
8
  <% add_to_head do %>
9
- <link href="<%= root_path %>tasks/css/ext.css" media="screen" rel="stylesheet" type="text/css"/>
9
+ <link href="<%= root_path %>tasks/css/ext.css" media="screen" rel="stylesheet" type="text/css">
10
10
  <% end %>
11
11
  <% end %>
12
12
 
@@ -42,7 +42,7 @@
42
42
  </header>
43
43
 
44
44
  <div class="st-table-container">
45
- <% if task.history.empty? %>
45
+ <% if history_entries.empty? %>
46
46
  <p><%= t("no_history") %></p>
47
47
  <% else %>
48
48
  <table class="st-table">
@@ -50,6 +50,9 @@
50
50
  <tr>
51
51
  <th><%= t("jid") %></th>
52
52
  <th><%= t("args") %></th>
53
+ <% if Sidekiq::Tasks.config.current_user %>
54
+ <th><%= t("enqueued_by") %></th>
55
+ <% end %>
53
56
  <th><%= t("enqueued") %></th>
54
57
  <th><%= t("executed") %></th>
55
58
  <th><%= t("duration") %></th>
@@ -57,12 +60,17 @@
57
60
  </tr>
58
61
  </thead>
59
62
  <tbody>
60
- <% task.history.each do |jid_history| %>
63
+ <% history_entries.each do |jid_history| %>
61
64
  <tr>
62
65
  <td><%= jid_history["jid"] %></td>
63
66
  <td>
64
67
  <code><%= jid_history["args"] %></code>
65
68
  </td>
69
+ <% if Sidekiq::Tasks.config.current_user %>
70
+ <td>
71
+ <code><%= jid_history["user"] || "-" %></code>
72
+ </td>
73
+ <% end %>
66
74
  <td>
67
75
  <%= jid_history["enqueued_at"] ? jid_history["enqueued_at"].strftime(t("task_time")) : "-" %>
68
76
  </td>
@@ -77,13 +85,24 @@
77
85
  :span,
78
86
  t(task_status(jid_history).to_s).capitalize,
79
87
  class: "st-status-badge #{task_status(jid_history)}",
80
- "data-tooltip": jid_history["error"],
88
+ "data-tooltip": jid_history["error"]
81
89
  ) %>
82
90
  </td>
83
91
  </tr>
84
92
  <% end %>
85
93
  </tbody>
86
94
  </table>
95
+
96
+ <%= erb(
97
+ read_view(:_pagination),
98
+ locals: {
99
+ displayed_count: history_entries.size,
100
+ total_count: history_total_count,
101
+ page: history_page,
102
+ total_pages: history_total_pages,
103
+ base_url: task_url(root_path, task),
104
+ }
105
+ ) %>
87
106
  <% end %>
88
107
  </div>
89
108
 
@@ -100,7 +119,7 @@
100
119
  <% task.args.each do |arg| %>
101
120
  <div class="st-form-group">
102
121
  <label for="<%= arg %>" class="st-label"><%= arg %></label>
103
- <input type="text" id="<%= arg %>" class="st-input" name="args[<%= arg %>]"/>
122
+ <input type="text" id="<%= arg %>" class="st-input" name="args[<%= arg %>]" autocomplete="off">
104
123
  </div>
105
124
  <% end %>
106
125
  </div>
@@ -109,7 +128,15 @@
109
128
  <label for="envConfirmationInput" class="st-label">
110
129
  <%= t("env_confirmation", current_env: current_env) %>
111
130
  </label>
112
- <input type="text" id="envConfirmationInput" class="st-input" name="env_confirmation" data-current-env="<%= current_env %>" required/>
131
+ <input
132
+ type="text"
133
+ id="envConfirmationInput"
134
+ class="st-input"
135
+ name="env_confirmation"
136
+ data-current-env="<%= current_env %>"
137
+ autocomplete="off"
138
+ required
139
+ >
113
140
  </div>
114
141
 
115
142
  <button type="submit" class="st-button" id="submitBtn" disabled>
data/web/views/tasks.erb CHANGED
@@ -6,13 +6,23 @@
6
6
  <% end %>
7
7
  <% else %>
8
8
  <% add_to_head do %>
9
- <link href="<%= root_path %>tasks/css/ext.css" media="screen" rel="stylesheet" type="text/css"/>
9
+ <link href="<%= root_path %>tasks/css/ext.css" media="screen" rel="stylesheet" type="text/css">
10
10
  <% end %>
11
11
  <% end %>
12
12
 
13
13
  <header class="st-header">
14
14
  <form action="<%= root_path %>tasks" method="get" class="st-search-form">
15
- <input name="filter" class="st-input" type="text" value="<%= @search.filter %>" placeholder="<%= t("search") %>">
15
+ <input type="hidden" name="sort" value="<%= @search.sort %>">
16
+ <input type="hidden" name="direction" value="<%= @search.direction %>">
17
+
18
+ <input
19
+ name="filter"
20
+ class="st-input"
21
+ type="text"
22
+ value="<%= Rack::Utils.escape_html(@search.filter.to_s) %>"
23
+ placeholder="<%= t("search") %>"
24
+ autocomplete="off"
25
+ >
16
26
 
17
27
  <select name="count" class="st-select">
18
28
  <% Sidekiq::Tasks::Web::Search.count_options.each do |count| %>
@@ -35,9 +45,23 @@
35
45
  <table class="st-table">
36
46
  <thead>
37
47
  <tr>
38
- <th><%= t("name") %></th>
48
+ <th>
49
+ <a
50
+ href="<%= sort_header_url(@search, root_path, "name") %>"
51
+ class="<%= sort_header_classes(@search, "name") %>"
52
+ >
53
+ <%= t("name") %>
54
+ </a>
55
+ </th>
39
56
  <th><%= t("description") %></th>
40
- <th><%= t("last_enqueued") %></th>
57
+ <th>
58
+ <a
59
+ href="<%= sort_header_url(@search, root_path, "last_enqueued") %>"
60
+ class="<%= sort_header_classes(@search, "last_enqueued") %>"
61
+ >
62
+ <%= t("last_enqueued") %>
63
+ </a>
64
+ </th>
41
65
  </tr>
42
66
  </thead>
43
67
  <tbody>
@@ -56,6 +80,15 @@
56
80
  </tbody>
57
81
  </table>
58
82
 
59
- <%= erb(read_view(:_pagination), locals: {search: @search}) %>
83
+ <%= erb(
84
+ read_view(:_pagination),
85
+ locals: {
86
+ displayed_count: @search.tasks.size,
87
+ total_count: Sidekiq::Tasks.tasks.size,
88
+ page: @search.page,
89
+ total_pages: @search.total_pages,
90
+ base_url: pagination_base_url(@search, root_path),
91
+ }
92
+ ) %>
60
93
  <% end %>
61
94
  </div>
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sidekiq-tasks
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.7
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Victor
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2025-07-27 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rake
@@ -151,7 +150,7 @@ dependencies:
151
150
  - !ruby/object:Gem::Version
152
151
  version: '0'
153
152
  - !ruby/object:Gem::Dependency
154
- name: sidekiq
153
+ name: selenium-webdriver
155
154
  requirement: !ruby/object:Gem::Requirement
156
155
  requirements:
157
156
  - - ">="
@@ -165,7 +164,7 @@ dependencies:
165
164
  - !ruby/object:Gem::Version
166
165
  version: '0'
167
166
  - !ruby/object:Gem::Dependency
168
- name: simplecov
167
+ name: sidekiq
169
168
  requirement: !ruby/object:Gem::Requirement
170
169
  requirements:
171
170
  - - ">="
@@ -179,7 +178,7 @@ dependencies:
179
178
  - !ruby/object:Gem::Version
180
179
  version: '0'
181
180
  - !ruby/object:Gem::Dependency
182
- name: simplecov-json
181
+ name: simplecov
183
182
  requirement: !ruby/object:Gem::Requirement
184
183
  requirements:
185
184
  - - ">="
@@ -193,7 +192,7 @@ dependencies:
193
192
  - !ruby/object:Gem::Version
194
193
  version: '0'
195
194
  - !ruby/object:Gem::Dependency
196
- name: webdrivers
195
+ name: simplecov-json
197
196
  requirement: !ruby/object:Gem::Requirement
198
197
  requirements:
199
198
  - - ">="
@@ -237,6 +236,7 @@ files:
237
236
  - LICENSE.txt
238
237
  - README.md
239
238
  - Rakefile
239
+ - docs/custom_storage.md
240
240
  - docs/task.png
241
241
  - lib/sidekiq-tasks.rb
242
242
  - lib/sidekiq/tasks.rb
@@ -245,6 +245,8 @@ files:
245
245
  - lib/sidekiq/tasks/job.rb
246
246
  - lib/sidekiq/tasks/set.rb
247
247
  - lib/sidekiq/tasks/storage.rb
248
+ - lib/sidekiq/tasks/storage/base.rb
249
+ - lib/sidekiq/tasks/storage/redis.rb
248
250
  - lib/sidekiq/tasks/strategies.rb
249
251
  - lib/sidekiq/tasks/strategies/base.rb
250
252
  - lib/sidekiq/tasks/strategies/rake_task.rb
@@ -261,6 +263,7 @@ files:
261
263
  - lib/sidekiq/tasks/web/extension.rb
262
264
  - lib/sidekiq/tasks/web/helpers/application_helper.rb
263
265
  - lib/sidekiq/tasks/web/helpers/pagination_helper.rb
266
+ - lib/sidekiq/tasks/web/helpers/sort_helper.rb
264
267
  - lib/sidekiq/tasks/web/helpers/tag_helper.rb
265
268
  - lib/sidekiq/tasks/web/helpers/task_helper.rb
266
269
  - lib/sidekiq/tasks/web/pagination.rb
@@ -290,7 +293,6 @@ metadata:
290
293
  homepage_uri: https://github.com/victorauthiat/sidekiq-tasks
291
294
  source_code_uri: https://github.com/victorauthiat/sidekiq-tasks/blob/master
292
295
  changelog_uri: https://github.com/victorauthiat/sidekiq-tasks/blob/master/CHANGELOG.md
293
- post_install_message:
294
296
  rdoc_options: []
295
297
  require_paths:
296
298
  - lib
@@ -305,8 +307,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
305
307
  - !ruby/object:Gem::Version
306
308
  version: '0'
307
309
  requirements: []
308
- rubygems_version: 3.5.22
309
- signing_key:
310
+ rubygems_version: 3.6.9
310
311
  specification_version: 4
311
312
  summary: Sidekiq extension for launching tasks.
312
313
  test_files: []