sidekiq-tasks 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +59 -0
  4. data/.simplecov +16 -0
  5. data/CHANGELOG.md +5 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +178 -0
  9. data/Rakefile +12 -0
  10. data/docs/task.png +0 -0
  11. data/lib/sidekiq/sidekiq-tasks.rb +1 -0
  12. data/lib/sidekiq/tasks/config.rb +47 -0
  13. data/lib/sidekiq/tasks/errors.rb +12 -0
  14. data/lib/sidekiq/tasks/job.rb +19 -0
  15. data/lib/sidekiq/tasks/set.rb +57 -0
  16. data/lib/sidekiq/tasks/storage.rb +85 -0
  17. data/lib/sidekiq/tasks/strategies/base.rb +91 -0
  18. data/lib/sidekiq/tasks/strategies/rake_task.rb +26 -0
  19. data/lib/sidekiq/tasks/strategies/rules/base.rb +19 -0
  20. data/lib/sidekiq/tasks/strategies/rules/enable_with_comment.rb +29 -0
  21. data/lib/sidekiq/tasks/strategies/rules/task_from_lib.rb +13 -0
  22. data/lib/sidekiq/tasks/strategies/rules.rb +11 -0
  23. data/lib/sidekiq/tasks/strategies.rb +10 -0
  24. data/lib/sidekiq/tasks/task.rb +42 -0
  25. data/lib/sidekiq/tasks/task_metadata.rb +27 -0
  26. data/lib/sidekiq/tasks/validations.rb +37 -0
  27. data/lib/sidekiq/tasks/version.rb +7 -0
  28. data/lib/sidekiq/tasks/web/extension.rb +45 -0
  29. data/lib/sidekiq/tasks/web/helpers/application_helper.rb +17 -0
  30. data/lib/sidekiq/tasks/web/helpers/task_helper.rb +29 -0
  31. data/lib/sidekiq/tasks/web/locales/en.yml +19 -0
  32. data/lib/sidekiq/tasks/web/locales/fr.yml +19 -0
  33. data/lib/sidekiq/tasks/web/params.rb +44 -0
  34. data/lib/sidekiq/tasks/web/search.rb +53 -0
  35. data/lib/sidekiq/tasks/web/views/_pagination.html.erb +25 -0
  36. data/lib/sidekiq/tasks/web/views/_task.html.erb +84 -0
  37. data/lib/sidekiq/tasks/web/views/tasks.html.erb +53 -0
  38. data/lib/sidekiq/tasks/web.rb +18 -0
  39. data/lib/sidekiq/tasks.rb +37 -0
  40. data/sig/sidekiq/tasks.rbs +6 -0
  41. metadata +283 -0
@@ -0,0 +1,91 @@
1
+ module Sidekiq
2
+ module Tasks
3
+ module Strategies
4
+ class Base
5
+ include Sidekiq::Tasks::Validations
6
+
7
+ # A set of rules to fetch tasks.
8
+ #
9
+ # @return [Array<Sidekiq::Tasks::Strategies::Rules::Base>]
10
+ # @see Sidekiq::Tasks::Strategies::Base#filtered_tasks
11
+ attr_reader :rules
12
+
13
+ # Initializes a strategy with the given rules.
14
+ #
15
+ # @param rules [Array<Sidekiq::Tasks::Strategies::Rules::Base>] List of rule instances to be applied.
16
+ # @raise [Sidekiq::Tasks::ArgumentError] If the rules are not valid instances.
17
+ def initialize(rules: [])
18
+ @rules = rules
19
+
20
+ validate_array_classes!(rules, [Sidekiq::Tasks::Strategies::Rules::Base], "rules")
21
+ end
22
+
23
+ # Returns the name of the strategy.
24
+ #
25
+ # @return [String] The name of the class without module namespaces.
26
+ def name
27
+ self.class.name.split("::").last
28
+ end
29
+
30
+ # Returns all the raw tasks that should be filtered.
31
+ #
32
+ # @abstract Subclasses must implement this method.
33
+ # @return [Array] A list of tasks to be filtered.
34
+ # @raise [NotImplementedError] If the method is not implemented in a subclass.
35
+ def load_tasks
36
+ raise NotImplementedError, "Strategy must implement #load_tasks"
37
+ end
38
+
39
+ # Executes a task with the given parameters.
40
+ #
41
+ # @note Consider accepting a `Sidekiq::Tasks::Task` instead of a task name.
42
+ #
43
+ # @param name [String] The name of the task to execute.
44
+ # @param args [Hash, NilClass] Arguments to pass to the task.
45
+ # @raise [NotImplementedError] If the method is not implemented in a subclass.
46
+ def execute_task(_name, _args = nil)
47
+ raise NotImplementedError, "Strategy must implement #execute_task"
48
+ end
49
+
50
+ # Enqueues a task with the given parameters and returns the JID.
51
+ #
52
+ # @param name [String] The name of the task to enqueue.
53
+ # @param params [Hash] Parameters to pass to the task.
54
+ # @return [String] The JID of the sidekiq job that will execute the task.
55
+ def enqueue_task(name, params = {})
56
+ Sidekiq::Tasks::Job.perform_async(name, params.to_json)
57
+ end
58
+
59
+ # Returns all the tasks that should be executed.
60
+ #
61
+ # @return [Array<Sidekiq::Tasks::Task>]
62
+ def tasks
63
+ filtered_tasks = load_tasks.select { |task| respects_rules?(task) }
64
+
65
+ filtered_tasks.map { |task| Sidekiq::Tasks::Task.new(metadata: build_task_metadata(task), strategy: self) }
66
+ end
67
+
68
+ # Factory method to build the metadata for a task.
69
+ #
70
+ # @abstract Subclasses must implement this method.
71
+ # @param task [Object] The task to build the metadata for.
72
+ # @return [Sidekiq::Tasks::TaskMetadata] The metadata for the task.
73
+ def build_task_metadata(_task)
74
+ raise NotImplementedError, "Strategy must implement #build_task_metadata"
75
+ end
76
+
77
+ private
78
+
79
+ # Checks if a task respects all the defined rules.
80
+ #
81
+ # @param task [Sidekiq::Tasks::Task] The task to validate against the rules.
82
+ # @return [Boolean] `true` if the task respects all rules, `false` otherwise.
83
+ def respects_rules?(task)
84
+ rules.all? do |rule|
85
+ rule.respected?(task)
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,26 @@
1
+ module Sidekiq
2
+ module Tasks
3
+ module Strategies
4
+ class RakeTask < Base
5
+ def load_tasks
6
+ Rake::TaskManager.record_task_metadata = true
7
+ Rake.application.load_rakefile
8
+ Rake::Task.tasks
9
+ end
10
+
11
+ def build_task_metadata(task)
12
+ Sidekiq::Tasks::TaskMetadata.new(
13
+ name: task.name,
14
+ desc: task.full_comment,
15
+ file: task.locations.first.split(":").first,
16
+ args: task.arg_names
17
+ )
18
+ end
19
+
20
+ def execute_task(name, args = nil)
21
+ Rake::Task[name].execute(args)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,19 @@
1
+ module Sidekiq
2
+ module Tasks
3
+ module Strategies
4
+ module Rules
5
+ class Base
6
+ # Checks if the given task respects the rule
7
+ #
8
+ # @abstract Subclasses must implement this method.
9
+ # @param task [Sidekiq::Tasks::Task] The task to validate.
10
+ # @return [Boolean] `true` if the task respects the rule, `false` otherwise.
11
+ # @raise [NotImplementedError] If the method is not implemented in a subclass.
12
+ def respected?(_task)
13
+ raise NotImplementedError, "Rule must implement #respected?"
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,29 @@
1
+ module Sidekiq
2
+ module Tasks
3
+ module Strategies
4
+ module Rules
5
+ class EnableWithComment < Base
6
+ MAGIC_COMMENT_REGEX = /sidekiq-tasks:enable/
7
+
8
+ def respected?(task)
9
+ file, start_line = task.locations.first.split(":")
10
+ start_line_counting_desc = start_line.to_i > 2 ? start_line.to_i - 3 : 0
11
+ lines = File.read(file).split("\n")[start_line_counting_desc..start_line_counting_desc + 1].reverse
12
+
13
+ valid_magic_comment_line?(lines)
14
+ rescue Errno::ENOENT
15
+ raise ArgumentError, "File '#{file}' not found"
16
+ end
17
+
18
+ private
19
+
20
+ def valid_magic_comment_line?(lines)
21
+ return false if lines.first.match?(/namespace/)
22
+
23
+ lines.any? { |line| line.strip.match?(MAGIC_COMMENT_REGEX) }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,13 @@
1
+ module Sidekiq
2
+ module Tasks
3
+ module Strategies
4
+ module Rules
5
+ class TaskFromLib < Base
6
+ def respected?(task)
7
+ task.locations.first.start_with?("#{Rake.application.original_dir}/lib")
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,11 @@
1
+ module Sidekiq
2
+ module Tasks
3
+ module Strategies
4
+ module Rules
5
+ autoload :Base, "sidekiq/tasks/strategies/rules/base"
6
+ autoload :TaskFromLib, "sidekiq/tasks/strategies/rules/task_from_lib"
7
+ autoload :EnableWithComment, "sidekiq/tasks/strategies/rules/enable_with_comment"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,10 @@
1
+ require_relative "strategies/rules"
2
+
3
+ module Sidekiq
4
+ module Tasks
5
+ module Strategies
6
+ autoload :Base, "sidekiq/tasks/strategies/base"
7
+ autoload :RakeTask, "sidekiq/tasks/strategies/rake_task"
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,42 @@
1
+ require "forwardable"
2
+
3
+ module Sidekiq
4
+ module Tasks
5
+ class Task
6
+ extend Forwardable
7
+ include Sidekiq::Tasks::Validations
8
+
9
+ def_delegators :metadata, :name, :desc, :file, :args
10
+ def_delegators :storage, :last_enqueue_at, :last_execution_at, :history
11
+
12
+ attr_reader :metadata, :strategy
13
+
14
+ # @param metadata [Sidekiq::Tasks::TaskMetadata] The metadata for the task.
15
+ # @param strategy [Sidekiq::Tasks::Strategies::Base] The strategy to use to execute the task.
16
+ # @raise [Sidekiq::Tasks::ArgumentError] If the metadata or strategy are not valid instances.
17
+ def initialize(metadata:, strategy:)
18
+ @metadata = metadata
19
+ @strategy = strategy
20
+
21
+ validate_class!(metadata, [Sidekiq::Tasks::TaskMetadata], "metadata")
22
+ validate_class!(strategy, [Sidekiq::Tasks::Strategies::Base], "strategy")
23
+ end
24
+
25
+ def enqueue(params = {})
26
+ jid = strategy.enqueue_task(name, params)
27
+
28
+ storage.store_enqueue(jid, params)
29
+ end
30
+
31
+ def execute(params = {}, jid: nil)
32
+ strategy.execute_task(name, params)
33
+
34
+ storage.store_execution(jid)
35
+ end
36
+
37
+ def storage
38
+ @_storage ||= Sidekiq::Tasks::Storage.new(name)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,27 @@
1
+ module Sidekiq
2
+ module Tasks
3
+ class TaskMetadata
4
+ include Sidekiq::Tasks::Validations
5
+
6
+ attr_reader :name, :desc, :file, :args
7
+
8
+ def initialize(name:, file:, desc: "", args: [])
9
+ @name = name
10
+ @file = file
11
+ @desc = desc
12
+ @args = args
13
+
14
+ validate_params!
15
+ end
16
+
17
+ private
18
+
19
+ def validate_params!
20
+ validate_class!(name, [String, Symbol], "name")
21
+ validate_class!(file, [String, NilClass], "file")
22
+ validate_class!(desc, [String, NilClass], "desc")
23
+ validate_array_classes!(args, [String, Symbol], "args")
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,37 @@
1
+ module Sidekiq
2
+ module Tasks
3
+ module Validations
4
+ def validate_class!(object, classes, name = nil)
5
+ return if classes.any? { |klass| object.is_a?(klass) }
6
+
7
+ expected_classes = classes.map(&:name).join(" or ")
8
+ name ||= object
9
+
10
+ raise Sidekiq::Tasks::ArgumentError,
11
+ "'#{name}' must be an instance of #{expected_classes} but received #{object.class}"
12
+ end
13
+ module_function :validate_class!
14
+
15
+ def validate_array_classes!(objects, classes, name = nil)
16
+ validate_class!(objects, [Array], name)
17
+
18
+ objects.each { |object| validate_class!(object, classes) }
19
+ end
20
+ module_function :validate_array_classes!
21
+
22
+ def validate_hash_option!(options, key, classes = [])
23
+ validate_class!(options, [Hash])
24
+ validate_class!(options[key], classes, key)
25
+ end
26
+ module_function :validate_hash_option!
27
+
28
+ def validate_expected_values!(value, expected_values, name = nil)
29
+ return if expected_values.any? { |expected_value| value == expected_value }
30
+
31
+ raise Sidekiq::Tasks::ArgumentError,
32
+ "'#{name}' must be one of #{expected_values.map(&:inspect).join(" or ")} but received #{value.inspect}"
33
+ end
34
+ module_function :validate_expected_values!
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ module Tasks
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,45 @@
1
+ module Sidekiq
2
+ module Tasks
3
+ module Web
4
+ class Extension
5
+ LOCALES_PATH = File.expand_path("../web/locales", __dir__).freeze
6
+
7
+ def self.registered(app)
8
+ app.settings.locales << File.join(LOCALES_PATH)
9
+
10
+ app.helpers do
11
+ include Sidekiq::Tasks::Web::Helpers::ApplicationHelper
12
+ include Sidekiq::Tasks::Web::Helpers::TaskHelper
13
+ end
14
+
15
+ app.get "/tasks" do
16
+ @search = Sidekiq::Tasks::Web::Search.new(params)
17
+
18
+ erb(read_view(:tasks), locals: {search: @search})
19
+ end
20
+
21
+ app.get "/tasks/:name" do
22
+ @task = find_task!(params["name"])
23
+
24
+ erb(read_view(:_task), locals: {task: @task})
25
+ rescue Sidekiq::Tasks::NotFoundError
26
+ throw :halt, [404, {Rack::CONTENT_TYPE => "text/plain"}, ["Task not found"]]
27
+ end
28
+
29
+ app.post "/tasks/:name/enqueue" do
30
+ task = find_task!(params["name"])
31
+ args = Sidekiq::Tasks::Web::Params.new(task, params["args"]).permit!
32
+
33
+ task.enqueue(args)
34
+
35
+ redirect(task_url(root_path, task))
36
+ rescue Sidekiq::Tasks::ArgumentError => e
37
+ throw :halt, [400, {Rack::CONTENT_TYPE => "text/plain"}, [e.message]]
38
+ rescue Sidekiq::Tasks::NotFoundError
39
+ throw :halt, [404, {Rack::CONTENT_TYPE => "text/plain"}, ["Task not found"]]
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,17 @@
1
+ module Sidekiq
2
+ module Tasks
3
+ module Web
4
+ module Helpers
5
+ module ApplicationHelper
6
+ extend self
7
+
8
+ VIEW_PATH = File.expand_path("../../web/views", __dir__).freeze
9
+
10
+ def read_view(name)
11
+ File.read(File.join(VIEW_PATH, "#{name}.html.erb"))
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,29 @@
1
+ module Sidekiq
2
+ module Tasks
3
+ module Web
4
+ module Helpers
5
+ module TaskHelper
6
+ extend self
7
+
8
+ def parameterize_task_name(task_name)
9
+ task_name.gsub(":", "-")
10
+ end
11
+
12
+ def unparameterize_task_name(task_name)
13
+ task_name.gsub("-", ":")
14
+ end
15
+
16
+ def find_task!(parameterized_name)
17
+ name = unparameterize_task_name(parameterized_name)
18
+
19
+ Sidekiq::Tasks.tasks.find_by!(name: name)
20
+ end
21
+
22
+ def task_url(root_path, task)
23
+ "#{root_path}tasks/#{parameterize_task_name(task.name)}"
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,19 @@
1
+ en:
2
+ prev: "Prev"
3
+ next: "Next"
4
+ tasks: "Tasks"
5
+ filter: "Filter"
6
+ no_tasks: "No tasks found"
7
+ name: "Name"
8
+ last_enqueued: "Last enqueued"
9
+ history: "History"
10
+ no_history: "No history"
11
+ jid: "JID"
12
+ args: "Arguments"
13
+ enqueued_at: "Enqueued at"
14
+ executed_at: "Executed at"
15
+ task: "Task"
16
+ desc: "Description"
17
+ strategy: "Strategy"
18
+ run_task: "Run task"
19
+ enqueue: "Enqueue"
@@ -0,0 +1,19 @@
1
+ fr:
2
+ prev: "Précédent"
3
+ next: "Suivant"
4
+ tasks: "Tâches"
5
+ filter: "Filtrer"
6
+ no_tasks: "Aucune tâche trouvée"
7
+ name: "Nom"
8
+ last_enqueued: "Dernière mise en file d'attente"
9
+ history: "Historique"
10
+ no_history: "Aucun historique"
11
+ jid: "JID"
12
+ args: "Arguments"
13
+ enqueued_at: "Mise en file d'attente le"
14
+ executed_at: "Exécuté le"
15
+ task: "Tâche"
16
+ desc: "Description"
17
+ strategy: "Stratégie"
18
+ run_task: "Exécuter la tâche"
19
+ enqueue: "Mettre en file d'attente"
@@ -0,0 +1,44 @@
1
+ module Sidekiq
2
+ module Tasks
3
+ module Web
4
+ class Params
5
+ attr_reader :task, :params
6
+
7
+ # @param task [Sidekiq::Tasks::Task] The task to validate the params against.
8
+ # @param params [Hash] The params to validate.
9
+ def initialize(task, params)
10
+ @task = task
11
+ @params = params
12
+ end
13
+
14
+ # Returns the permitted params.
15
+ #
16
+ # @return [Hash] The permitted params.
17
+ # @raise [Sidekiq::Tasks::ArgumentError] If the params are not NilClass or Hash.
18
+ def permit!
19
+ case params
20
+ when NilClass then {}
21
+ when Hash then permit_hash!
22
+ else
23
+ raise Sidekiq::Tasks::ArgumentError, "Invalid parameters: #{params.inspect}"
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ # Validates and returns the permitted params as a hash.
30
+ #
31
+ # @return [Hash] The permitted params as a hash.
32
+ # @raise [Sidekiq::Tasks::ArgumentError] If given params does not match the task args.
33
+ def permit_hash!
34
+ permitted_keys = task.args.map(&:to_s)
35
+ invalid_keys = params.keys - permitted_keys
36
+
37
+ raise Sidekiq::Tasks::ArgumentError, "Invalid parameters: #{invalid_keys.join(", ")}" if invalid_keys.any?
38
+
39
+ params.slice(*permitted_keys)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,53 @@
1
+ module Sidekiq
2
+ module Tasks
3
+ module Web
4
+ class Search
5
+ DEFAULT_COUNT = 25
6
+
7
+ def self.count_options
8
+ (1..4).map { |index| index * DEFAULT_COUNT }
9
+ end
10
+
11
+ attr_reader :params
12
+
13
+ def initialize(params)
14
+ @params = params
15
+ end
16
+
17
+ def tasks
18
+ @_tasks ||= filtered_collection.sort_by(&:file).slice(offset, count) || []
19
+ end
20
+
21
+ def filtered_collection
22
+ @_filtered_collection ||= Sidekiq::Tasks.tasks.where(name: filter)
23
+ end
24
+
25
+ def filter
26
+ request_filter = params[:filter]
27
+
28
+ ["", nil].include?(request_filter) ? nil : request_filter
29
+ end
30
+
31
+ def count
32
+ requested_count = params[:count].to_i
33
+
34
+ requested_count.positive? ? requested_count : DEFAULT_COUNT
35
+ end
36
+
37
+ def page
38
+ requested_page = params[:page].to_i
39
+
40
+ requested_page.positive? ? requested_page : 1
41
+ end
42
+
43
+ def total_pages
44
+ (filtered_collection.size.to_f / count).ceil
45
+ end
46
+
47
+ def offset
48
+ (page - 1) * count
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,25 @@
1
+ <% if search.total_pages > 1 %>
2
+ <ul class="pagination pull-right">
3
+ <% if search.page > 1 %>
4
+ <li>
5
+ <a href="<%= "#{root_path}tasks?filter=#{ERB::Util.url_encode(search.filter)}&count=#{search.count}&page=#{search.page - 1}" %>">
6
+ <%= t("prev") %>
7
+ </a>
8
+ </li>
9
+ <% end %>
10
+
11
+ <% (0..(search.total_pages - 1)).each.with_index(1) do |_page, index| %>
12
+ <li class="<%= 'active' if index == search.page %>">
13
+ <a href="<%= "#{root_path}tasks?filter=#{ERB::Util.url_encode(search.filter)}&count=#{search.count}&page=#{index}" %>"><%= index %></a>
14
+ </li>
15
+ <% end %>
16
+
17
+ <% if search.tasks.any? && search.tasks.size == search.count %>
18
+ <li>
19
+ <a href="<%= "#{root_path}tasks?filter=#{ERB::Util.url_encode(search.filter)}&count=#{search.count}&page=#{search.page + 1}" %>">
20
+ <%= t("next") %>
21
+ </a>
22
+ </li>
23
+ <% end %>
24
+ </ul>
25
+ <% end %>
@@ -0,0 +1,84 @@
1
+ <header class="row">
2
+ <div class="span col-sm-5 pull-left">
3
+ <h1><%= t("task") %></h1>
4
+ </div>
5
+ </header>
6
+
7
+ <table class="table table-bordered table-striped">
8
+ <tbody>
9
+ <tr>
10
+ <th><%= t("name") %></th>
11
+ <td><%= task.name %></td>
12
+ </tr>
13
+ <tr>
14
+ <th><%= t("desc") %></th>
15
+ <td><%= task.desc %></td>
16
+ </tr>
17
+ <tr>
18
+ <th><%= t("strategy") %></th>
19
+ <td><%= task.strategy.name %></td>
20
+ </tr>
21
+ <tr>
22
+ <th><%= t("last_enqueued") %></th>
23
+ <td><%= task.last_enqueue_at ? relative_time(task.last_enqueue_at) : "-" %></td>
24
+ </tr>
25
+ </tbody>
26
+ </table>
27
+
28
+ <header class="row">
29
+ <div class="col-sm-12">
30
+ <h2><%= t("history") %></h2>
31
+ </div>
32
+ </header>
33
+
34
+ <% if task.history.empty? %>
35
+ <p><%= t("no_history") %></p>
36
+ <% else %>
37
+ <table class="table table-hover table-bordered table-striped">
38
+ <thead>
39
+ <tr>
40
+ <th><%= t("jid") %></th>
41
+ <th><%= t("args") %></th>
42
+ <th><%= t("enqueued_at") %></th>
43
+ <th><%= t("executed_at") %></th>
44
+ </tr>
45
+ </thead>
46
+ <tbody>
47
+ <% task.history.each do |jid_history| %>
48
+ <tr>
49
+ <td><%= jid_history["jid"] %></td>
50
+ <td><%= jid_history["args"] %></td>
51
+ <td><%= jid_history["enqueued_at"] ? relative_time(jid_history["enqueued_at"]) : "-" %></td>
52
+ <td><%= jid_history["executed_at"] ? relative_time(jid_history["executed_at"]) : "-" %></td>
53
+ </tr>
54
+ <% end %>
55
+ </tbody>
56
+ </table>
57
+ <% end %>
58
+
59
+ <header class="row">
60
+ <div class="col-sm-12">
61
+ <h2><%= t("run_task") %></h2>
62
+ </div>
63
+ </header>
64
+
65
+ <form action="<%= task_url(root_path, task) %>/enqueue" method="post">
66
+ <%= csrf_tag %>
67
+
68
+ <div class="container">
69
+ <div class="row">
70
+ <% task.args.each do |arg| %>
71
+ <div class="col-md-6 mb-3">
72
+ <div class="form-group">
73
+ <label for="<%= arg %>" class="form-label"><%= arg %></label>
74
+ <input type="text" class="form-control" name="args[<%= arg %>]" id="<%= arg %>" />
75
+ </div>
76
+ </div>
77
+ <% end %>
78
+ </div>
79
+ </div>
80
+
81
+ <button type="submit" class="btn btn-primary">
82
+ <%= t("enqueue") %>
83
+ </button>
84
+ </form>