katalyst-koi 5.7.0 → 5.8.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/katalyst/koi.esm.js +45 -6
  3. data/app/assets/builds/katalyst/koi.js +45 -6
  4. data/app/assets/builds/katalyst/koi.min.js +1 -1
  5. data/app/assets/builds/katalyst/koi.min.js.map +1 -1
  6. data/app/assets/stylesheets/koi/forms/checkboxes.css +2 -2
  7. data/app/assets/stylesheets/koi/forms/combobox.css +2 -2
  8. data/app/assets/stylesheets/koi/forms/index.css +2 -0
  9. data/app/assets/stylesheets/koi/forms/input.css +9 -11
  10. data/app/assets/stylesheets/koi/forms/lexxy.css +53 -0
  11. data/app/assets/stylesheets/koi/forms/radios.css +2 -2
  12. data/app/assets/stylesheets/koi/forms/textarea.css +1 -1
  13. data/app/assets/stylesheets/koi/forms/trix.css +30 -6
  14. data/app/assets/stylesheets/koi/index.css +1 -0
  15. data/app/controllers/admin/active_storage/direct_uploads_controller.rb +9 -0
  16. data/app/controllers/admin/background_jobs_controller.rb +68 -0
  17. data/app/controllers/admin/feature_flags_controller.rb +67 -0
  18. data/app/controllers/admin/recurring_tasks_controller.rb +67 -0
  19. data/app/helpers/admin/background_jobs_helper.rb +15 -0
  20. data/app/helpers/koi/tags/trix_toolbar.rb +64 -0
  21. data/app/javascript/koi/application.js +4 -1
  22. data/app/javascript/koi/utils/lexxy.js +23 -0
  23. data/app/javascript/koi/utils/trix.js +17 -0
  24. data/app/models/background_job.rb +98 -0
  25. data/app/models/feature_flag.rb +128 -0
  26. data/app/models/recurring_task.rb +31 -0
  27. data/app/views/admin/background_jobs/_navigation.html.erb +8 -0
  28. data/app/views/admin/background_jobs/blocked.html.erb +17 -0
  29. data/app/views/admin/background_jobs/completed.html.erb +17 -0
  30. data/app/views/admin/background_jobs/failed.html.erb +26 -0
  31. data/app/views/admin/background_jobs/in_progress.html.erb +17 -0
  32. data/app/views/admin/background_jobs/index.html.erb +22 -0
  33. data/app/views/admin/background_jobs/scheduled.html.erb +17 -0
  34. data/app/views/admin/background_jobs/show.html.erb +41 -0
  35. data/app/views/admin/feature_flags/index.html.erb +14 -0
  36. data/app/views/admin/feature_flags/new.html.erb +14 -0
  37. data/app/views/admin/feature_flags/show.html.erb +30 -0
  38. data/app/views/admin/recurring_tasks/index.html.erb +20 -0
  39. data/app/views/admin/recurring_tasks/show.html.erb +33 -0
  40. data/app/views/layouts/action_text/contents/_content.html.erb +3 -0
  41. data/config/ci.rb +10 -0
  42. data/config/importmap.rb +6 -4
  43. data/config/routes.rb +32 -0
  44. data/db/migrate/20230531063707_update_admin_users.rb +2 -0
  45. data/db/migrate/20260525111759_clean_up_admin_user_timestamps.rb +2 -0
  46. data/lib/generators/koi/admin_route/templates/initializer.rb.tt +4 -3
  47. data/lib/koi/action_text.rb +112 -0
  48. data/lib/koi/config.rb +3 -1
  49. data/lib/koi/engine.rb +23 -0
  50. data/lib/koi/form/builder.rb +27 -8
  51. data/lib/koi/menu.rb +4 -0
  52. data/lib/koi.rb +12 -6
  53. data/spec/factories/admin_sessions.rb +9 -0
  54. metadata +53 -5
  55. data/lib/koi/form/content.rb +0 -55
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class BackgroundJobsController < ApplicationController
5
+ before_action :set_background_job, only: %i[show retry discard]
6
+
7
+ attr_reader :collection, :background_job
8
+
9
+ def index = render_state(:pending)
10
+ def scheduled = render_state(:scheduled)
11
+ def in_progress = render_state(:in_progress)
12
+ def blocked = render_state(:blocked)
13
+ def failed = render_state(:failed)
14
+ def completed = render_state(:completed)
15
+
16
+ def show
17
+ render locals: { background_job: }
18
+ end
19
+
20
+ def retry
21
+ background_job.retry
22
+
23
+ redirect_to admin_background_jobs_path, status: :see_other
24
+ end
25
+
26
+ def discard
27
+ redirect_target = background_job.failed? ? failed_admin_background_jobs_path : admin_background_jobs_path
28
+
29
+ background_job.discard
30
+
31
+ redirect_to redirect_target, status: :see_other
32
+ end
33
+
34
+ def retry_all
35
+ SolidQueue::Job.failed.where(id: params[:id]).find_each(&:retry)
36
+
37
+ redirect_back_or_to failed_admin_background_jobs_path, status: :see_other
38
+ end
39
+
40
+ def discard_all
41
+ SolidQueue::Job.where(id: params[:id]).find_each(&:discard)
42
+
43
+ redirect_back_or_to admin_background_jobs_path, status: :see_other
44
+ end
45
+
46
+ private
47
+
48
+ def render_state(state)
49
+ states = BackgroundJob.states
50
+ @collection = Collection.with_params(params).apply(states.fetch(state).strict_loading)
51
+
52
+ render locals: { collection:, state_counts: states.transform_values(&:count) }
53
+ end
54
+
55
+ def set_background_job
56
+ job = SolidQueue::Job.find_by!(active_job_id: params.expect(:active_job_id))
57
+ @background_job = BackgroundJob.new(job)
58
+ end
59
+
60
+ class Collection < Admin::Collection
61
+ config.paginate = true
62
+
63
+ attribute :class_name, :string
64
+ attribute :queue_name, :string
65
+ attribute :scheduled_at, :date
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class FeatureFlagsController < ApplicationController
5
+ before_action :set_feature_flag, only: %i[show update destroy]
6
+
7
+ attr_reader :collection, :feature_flag
8
+
9
+ def index
10
+ @collection = Collection.with_params(params).apply(FeatureFlag.all)
11
+
12
+ render locals: { collection: }
13
+ end
14
+
15
+ def show
16
+ render locals: { feature_flag:, groups: Flipper.group_names }
17
+ end
18
+
19
+ def new
20
+ @feature_flag = FeatureFlag.build
21
+
22
+ render locals: { feature_flag: }
23
+ end
24
+
25
+ def create
26
+ @feature_flag = FeatureFlag.build(key: params.expect(feature_flag: [:key]).fetch(:key, nil))
27
+
28
+ if feature_flag.save
29
+ redirect_to(admin_feature_flag_path(feature_flag), status: :see_other)
30
+ else
31
+ render(:new, locals: { feature_flag: }, status: :unprocessable_content)
32
+ end
33
+ end
34
+
35
+ def update
36
+ feature_flag.update(feature_flag_params)
37
+
38
+ redirect_to(admin_feature_flags_path, status: :see_other)
39
+ end
40
+
41
+ def destroy
42
+ feature_flag.destroy
43
+
44
+ redirect_to(admin_feature_flags_path, status: :see_other)
45
+ end
46
+
47
+ private
48
+
49
+ def set_feature_flag
50
+ @feature_flag = FeatureFlag.find(params.expect(:key))
51
+ end
52
+
53
+ def feature_flag_params
54
+ params.expect(feature_flag: [:state, :percentage_of_actors, :percentage_of_time, :actors, { groups: [] }])
55
+ end
56
+
57
+ class Collection < Katalyst::Tables::Collection::Array
58
+ def model
59
+ FeatureFlag
60
+ end
61
+
62
+ def model_name
63
+ FeatureFlag.model_name
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class RecurringTasksController < ApplicationController
5
+ before_action :set_recurring_task, only: %i[show run]
6
+
7
+ attr_reader :recurring_task
8
+
9
+ def index
10
+ collection = Collection.with_params(params).apply(SolidQueue::RecurringTask.all)
11
+
12
+ render locals: { collection: }
13
+ end
14
+
15
+ def show
16
+ jobs = JobsCollection.with_params(params).apply(jobs_scope)
17
+
18
+ render locals: { recurring_task:, jobs: }
19
+ end
20
+
21
+ def run
22
+ recurring_task.run
23
+
24
+ redirect_to(admin_recurring_task_path(recurring_task), status: :see_other)
25
+ end
26
+
27
+ private
28
+
29
+ def set_recurring_task
30
+ task = SolidQueue::RecurringTask.find_by!(key: params.expect(:key))
31
+ @recurring_task = RecurringTask.new(task)
32
+ end
33
+
34
+ def jobs_scope
35
+ SolidQueue::Job.joins(:recurring_execution)
36
+ .where(solid_queue_recurring_executions: { task_key: recurring_task.key })
37
+ .order(scheduled_at: :desc)
38
+ end
39
+
40
+ class Collection < Admin::Collection
41
+ config.sorting = :key
42
+ config.paginate = true
43
+
44
+ attribute :key, :string
45
+ attribute :schedule, :string
46
+ attribute :last_enqueued_time, :date
47
+ end
48
+
49
+ class JobsCollection < Admin::Collection
50
+ config.paginate = true
51
+
52
+ def apply(items)
53
+ super.tap do
54
+ self.items = self.items.map { |job| BackgroundJob.new(job) }
55
+ end
56
+ end
57
+
58
+ def model
59
+ BackgroundJob
60
+ end
61
+
62
+ def model_name
63
+ BackgroundJob.model_name
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ module BackgroundJobsHelper
5
+ # The listing path for a job state. Pending is the index; the rest are
6
+ # collection routes named after the state.
7
+ def background_job_state_path(state)
8
+ if state == :pending
9
+ admin_background_jobs_path
10
+ else
11
+ public_send(:"#{state}_admin_background_jobs_path")
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ module Tags
5
+ class TrixToolbar
6
+ include ActionView::Helpers::TagHelper
7
+ include Katalyst::HtmlAttributes
8
+
9
+ TRIX_BUTTON_ROW_TEMPLATE = <<~HTML.html_safe.freeze
10
+ <div class="trix-button-row">
11
+ <span class="trix-button-group trix-button-group--text-tools" data-trix-button-group="text-tools">
12
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-bold" data-trix-attribute="bold" data-trix-key="b" title="Bold" tabindex="-1">Bold</button>
13
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-italic" data-trix-attribute="italic" data-trix-key="i" title="Italic" tabindex="-1">Italic</button>
14
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-strike" data-trix-attribute="strike" title="Strikethrough" tabindex="-1">Strikethrough</button>
15
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-link" data-trix-attribute="href" data-trix-action="link" data-trix-key="k" title="Link" tabindex="-1">Link</button>
16
+ </span>
17
+
18
+ <span class="trix-button-group trix-button-group--block-tools" data-trix-button-group="block-tools">
19
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-heading-1" data-trix-attribute="heading4" title="Heading" tabindex="-1">Heading</button>
20
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-quote" data-trix-attribute="quote" title="Quote" tabindex="-1">Quote</button>
21
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-code" data-trix-attribute="code" title="Code" tabindex="-1">Code</button>
22
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-bullet-list" data-trix-attribute="bullet" title="Bullets" tabindex="-1">Bullets</button>
23
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-number-list" data-trix-attribute="number" title="Numbers" tabindex="-1">Numbers</button>
24
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-decrease-nesting-level" data-trix-action="decreaseNestingLevel" title="Decrease level" tabindex="-1">Decrease level</button>
25
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-increase-nesting-level" data-trix-action="increaseNestingLevel" title="Increase level" tabindex="-1">Increase level</button>
26
+ </span>
27
+
28
+ <span class="trix-button-group trix-button-group--file-tools" data-trix-button-group="file-tools">
29
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-attach" data-trix-action="attachFiles" title="Attach files" tabindex="-1">Attach files</button>
30
+ </span>
31
+
32
+ <span class="trix-button-group-spacer"></span>
33
+
34
+ <span class="trix-button-group trix-button-group--history-tools" data-trix-button-group="history-tools">
35
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-undo" data-trix-action="undo" data-trix-key="z" title="Undo" tabindex="-1">Undo</button>
36
+ <button type="button" class="trix-button trix-button--icon trix-button--icon-redo" data-trix-action="redo" data-trix-key="shift+z" title="Redo" tabindex="-1">Redo</button>
37
+ </span>
38
+ </div>
39
+ HTML
40
+
41
+ TRIX_DIALOGS_TEMPLATE = <<~HTML.html_safe.freeze
42
+ <div class="trix-dialogs" data-trix-dialogs>
43
+ <div class="trix-dialog trix-dialog--link" data-trix-dialog="href" data-trix-dialog-attribute="href">
44
+ <div class="trix-dialog__link-fields">
45
+ <input type="text" name="href" pattern="(https?|mailto:|tel:|/|#).*?" class="trix-input trix-input--dialog" placeholder="Enter a URL…" aria-label="URL" data-trix-validate-href required data-trix-input>
46
+ <div class="trix-button-group">
47
+ <input type="button" class="trix-button trix-button--dialog" value="Link" data-trix-method="setAttribute">
48
+ <input type="button" class="trix-button trix-button--dialog" value="Unlink" data-trix-method="removeAttribute">
49
+ </div>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ HTML
54
+
55
+ def render
56
+ content_tag("trix-toolbar", TRIX_BUTTON_ROW_TEMPLATE + TRIX_DIALOGS_TEMPLATE, **html_attributes)
57
+ end
58
+
59
+ def default_html_attributes
60
+ { data: { turbo_permanent: "" } }
61
+ end
62
+ end
63
+ end
64
+ end
@@ -1,7 +1,8 @@
1
1
  import "@hotwired/turbo-rails";
2
2
  import { initAll } from "@katalyst/govuk-formbuilder";
3
3
  import "@rails/actiontext";
4
- import "trix";
4
+ import Lexxy from "./utils/lexxy";
5
+ import Trix from "./utils/trix";
5
6
 
6
7
  import "./controllers";
7
8
  import "./elements";
@@ -18,3 +19,5 @@ function initGOVUK() {
18
19
 
19
20
  window.addEventListener("turbo:load", initGOVUK);
20
21
  if (window.Turbo) initGOVUK();
22
+
23
+ export default { Lexxy, Trix };
@@ -0,0 +1,23 @@
1
+ import * as Lexxy from "lexxy";
2
+
3
+ Lexxy.configure({
4
+ global: {
5
+ authenticatedUploads: true,
6
+ },
7
+ });
8
+
9
+ // The link toolbar's URL input is type=url, whose checkValidity() rejects
10
+ // schemeless values like #anchor and /relative that lexxy's link node happily
11
+ // preserves. Switch it to type=text with the same pattern Koi applies to the
12
+ // trix link dialog (see Koi::Tags::TrixToolbar::TRIX_DIALOGS_TEMPLATE) so both
13
+ // editors validate link input identically.
14
+ document.addEventListener("lexxy:initialize", ({ target }) => {
15
+ target
16
+ .querySelectorAll("lexxy-link-dropdown input[type=url]")
17
+ .forEach((input) => {
18
+ input.type = "text";
19
+ input.pattern = "(https?|mailto:|tel:|/|#).*?";
20
+ });
21
+ });
22
+
23
+ export default Lexxy;
@@ -0,0 +1,17 @@
1
+ import "trix";
2
+
3
+ // Trix does not provide an export
4
+ const Trix = window.Trix;
5
+
6
+ // Koi uses H4 for headings instead of H1
7
+ Trix.config.blockAttributes["heading4"] = {
8
+ tagName: "h4",
9
+ terminal: true,
10
+ breakOnReturn: true,
11
+ group: false,
12
+ };
13
+
14
+ // Remove H1 from trix list of acceptable tags
15
+ delete Trix.config.blockAttributes.heading1;
16
+
17
+ export default Trix;
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BackgroundJob
4
+ include ActiveModel::Model
5
+
6
+ # @return [SolidQueue::Job]
7
+ attr_reader :job
8
+
9
+ delegate_missing_to :job
10
+
11
+ # The SolidQueue relation backing each job state, in display order. A job that
12
+ # isn't finished holds exactly one execution; finished jobs carry a finished_at.
13
+ def self.states
14
+ {
15
+ pending: SolidQueue::Job.where.associated(:ready_execution),
16
+ scheduled: SolidQueue::Job.where.associated(:scheduled_execution),
17
+ in_progress: SolidQueue::Job.where.associated(:claimed_execution),
18
+ blocked: SolidQueue::Job.where.associated(:blocked_execution),
19
+ failed: SolidQueue::Job.failed,
20
+ completed: SolidQueue::Job.finished,
21
+ }
22
+ end
23
+
24
+ # @param [SolidQueue::Job] job
25
+ def initialize(job)
26
+ @job = job
27
+ end
28
+
29
+ # ActiveJob's job id, stored in the serialized payload rather than as a column.
30
+ def job_id
31
+ job.arguments["job_id"]
32
+ end
33
+
34
+ # Serialized as an ISO8601 string in the payload; parse so views can format it.
35
+ def enqueued_at
36
+ job.arguments["enqueued_at"]&.then { |value| Time.iso8601(value) }
37
+ end
38
+
39
+ # The arguments the job was enqueued with, from the serialized payload.
40
+ def serialized_arguments
41
+ job.arguments["arguments"]
42
+ end
43
+
44
+ # Human-readable run time once finished, e.g. "1 minute and 35 seconds".
45
+ def duration
46
+ return unless finished? && enqueued_at
47
+
48
+ seconds = finished_at - enqueued_at
49
+ precision = case seconds
50
+ when 0...1 then 2
51
+ when 1...10 then 1
52
+ else 0
53
+ end
54
+ ActiveSupport::Duration.build(seconds.round(precision)).inspect
55
+ end
56
+
57
+ # SolidQueue::Job#discard only acts on a ready, claimed, or failed execution;
58
+ # it's a no-op for scheduled, blocked, or finished jobs.
59
+ def discardable?
60
+ ready_execution.present? || claimed_execution.present? || failed_execution.present?
61
+ end
62
+
63
+ # Exception class and message from the current failure, e.g. "RuntimeError: boom".
64
+ def failure_reason
65
+ return unless (failure = failed_execution)
66
+
67
+ "#{failure.exception_class}: #{failure.message}"
68
+ end
69
+
70
+ def state
71
+ if finished?
72
+ :finished
73
+ elsif failed?
74
+ :failed
75
+ elsif claimed_execution.present?
76
+ :in_progress
77
+ elsif blocked_execution.present?
78
+ :blocked
79
+ elsif scheduled_execution.present?
80
+ :scheduled
81
+ else
82
+ :pending
83
+ end
84
+ end
85
+
86
+ # Backtrace lines from the current failure, joined into a single string.
87
+ def backtrace
88
+ failed_execution&.backtrace&.join("\n")
89
+ end
90
+
91
+ def to_param
92
+ active_job_id
93
+ end
94
+
95
+ def to_s
96
+ class_name
97
+ end
98
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Presents a Flipper feature for the admin UI. Wrapping Flipper::Feature in an
4
+ # ActiveModel-compatible object lets it flow through Koi's table, form, and
5
+ # routing helpers.
6
+ class FeatureFlag
7
+ include ActiveModel::Model
8
+ include ActiveModel::Attributes
9
+
10
+ def self.all
11
+ Flipper.features.sort_by(&:key).map { |feature| new(feature:) }
12
+ end
13
+
14
+ def self.find(key)
15
+ raise ActiveRecord::RecordNotFound, "Couldn't find feature #{key.inspect}" unless Flipper.exist?(key)
16
+
17
+ new(key:)
18
+ end
19
+
20
+ def self.build(key: nil)
21
+ new(key:)
22
+ end
23
+
24
+ attribute :key, :string
25
+
26
+ validates :key, presence: true
27
+ validates :key, format: { with: /\A[a-z0-9_]+\z/i }, allow_blank: true
28
+
29
+ # @return [Flipper::Feature]
30
+ attr_reader :feature
31
+
32
+ delegate_missing_to :feature
33
+
34
+ # @param [Flipper::Feature] feature
35
+ def initialize(feature: nil, **)
36
+ super(**)
37
+
38
+ if feature
39
+ self.key = feature.key
40
+ @feature = feature
41
+ else
42
+ @feature = Flipper.feature(key.to_s)
43
+ end
44
+ end
45
+
46
+ # Validate and register the feature. Returns false (leaving errors populated)
47
+ # when invalid. Adding is idempotent, so re-saving an existing key is a no-op.
48
+ def save # rubocop:disable Naming/PredicateMethod
49
+ return false unless valid?
50
+
51
+ Flipper.add(key)
52
+ true
53
+ end
54
+
55
+ # Apply the desired availability from the form: fully on, fully off, or a set
56
+ # of conditional rules. Clear the gates before applying rules so a previously
57
+ # set boolean gate can't mask them — Flipper reads a feature as fully on
58
+ # whenever its boolean gate is set, and there's no way to disable only that
59
+ # gate.
60
+ def update(attributes) # rubocop:disable Naming/PredicateMethod
61
+ case attributes[:state]
62
+ when "on" then enable
63
+ when "off" then disable
64
+ else configure(attributes)
65
+ end
66
+
67
+ true
68
+ end
69
+
70
+ # Remove the feature from Flipper entirely.
71
+ def destroy
72
+ Flipper.remove(key)
73
+ end
74
+
75
+ # Enabled groups as names, for display and form pre-fill.
76
+ def groups
77
+ groups_value.to_a
78
+ end
79
+
80
+ def percentage_of_actors
81
+ percentage_of_actors_value
82
+ end
83
+
84
+ def percentage_of_time
85
+ percentage_of_time_value
86
+ end
87
+
88
+ # Enabled actors as a newline-separated list of flipper ids, for editing.
89
+ def actors
90
+ actors_value.to_a.join("\n")
91
+ end
92
+
93
+ # A wrapped feature is backed by a registered Flipper feature, so it counts as
94
+ # persisted. Lets Koi helpers (e.g. link_to_delete) treat it like a record.
95
+ def persisted?
96
+ key.present? && Flipper.exist?(key)
97
+ end
98
+
99
+ def to_param
100
+ key
101
+ end
102
+
103
+ def to_s
104
+ key
105
+ end
106
+
107
+ private
108
+
109
+ def configure(attributes)
110
+ clear
111
+
112
+ Array(attributes[:groups]).compact_blank.each { |name| enable_group(name) }
113
+ parse_actors(attributes[:actors]).each { |id| enable_actor(Flipper::Actor.new(id)) }
114
+
115
+ apply_percentage(:enable_percentage_of_actors, attributes[:percentage_of_actors])
116
+ apply_percentage(:enable_percentage_of_time, attributes[:percentage_of_time])
117
+ end
118
+
119
+ def apply_percentage(method, value)
120
+ percentage = value.to_i
121
+
122
+ public_send(method, percentage) if percentage.positive?
123
+ end
124
+
125
+ def parse_actors(value)
126
+ value.to_s.split(/[\s,]+/)
127
+ end
128
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RecurringTask
4
+ include ActiveModel::Model
5
+
6
+ # @return [SolidQueue::RecurringTask]
7
+ attr_reader :task
8
+
9
+ delegate_missing_to :task
10
+
11
+ # @param [SolidQueue::RecurringTask] task
12
+ def initialize(task)
13
+ @task = task
14
+ end
15
+
16
+ def job_class
17
+ class_name.presence || command
18
+ end
19
+
20
+ def run
21
+ task.enqueue(at: Time.current)
22
+ end
23
+
24
+ def to_param
25
+ key
26
+ end
27
+
28
+ def to_s
29
+ key
30
+ end
31
+ end
@@ -0,0 +1,8 @@
1
+ <%# locals: (state_counts:) %>
2
+
3
+ <%= actions_list do %>
4
+ <% state_counts.each do |state, count| %>
5
+ <li><%= link_to("#{state.to_s.humanize} (#{count})", background_job_state_path(state)) %></li>
6
+ <% end %>
7
+ <li><%= link_to("Recurring tasks", admin_recurring_tasks_path) %></li>
8
+ <% end %>
@@ -0,0 +1,17 @@
1
+ <%# locals: (collection:, state_counts:) %>
2
+
3
+ <% content_for(:header) do %>
4
+ <h1>Blocked jobs</h1>
5
+
6
+ <%= render "navigation", state_counts: %>
7
+ <% end %>
8
+
9
+ <%= table_query_with(collection:) %>
10
+
11
+ <%= table_with(collection:) do |row, job| %>
12
+ <% row.link(:class_name, url: ->(record) { admin_background_job_path(record.active_job_id) }, label: "Job") %>
13
+ <% row.text(:queue_name, label: "Queue") %>
14
+ <% row.datetime(:scheduled_at, label: "Scheduled") %>
15
+ <% end %>
16
+
17
+ <%= table_pagination_with(collection:) %>
@@ -0,0 +1,17 @@
1
+ <%# locals: (collection:, state_counts:) %>
2
+
3
+ <% content_for(:header) do %>
4
+ <h1>Completed jobs</h1>
5
+
6
+ <%= render "navigation", state_counts: %>
7
+ <% end %>
8
+
9
+ <%= table_query_with(collection:) %>
10
+
11
+ <%= table_with(collection:) do |row, job| %>
12
+ <% row.link(:class_name, url: ->(record) { admin_background_job_path(record.active_job_id) }, label: "Job") %>
13
+ <% row.text(:queue_name, label: "Queue") %>
14
+ <% row.datetime(:finished_at, label: "Finished") %>
15
+ <% end %>
16
+
17
+ <%= table_pagination_with(collection:) %>
@@ -0,0 +1,26 @@
1
+ <%# locals: (collection:, state_counts:) %>
2
+
3
+ <% content_for(:header) do %>
4
+ <h1>Failed jobs</h1>
5
+
6
+ <%= render "navigation", state_counts: %>
7
+ <% end %>
8
+
9
+ <%= table_query_with(collection:) %>
10
+
11
+ <%= table_selection_with(collection:) do %>
12
+ <%= tag.button("Retry", formaction: retry_all_admin_background_jobs_path, formmethod: :post, class: "button") %>
13
+ <%= tag.button("Discard", formaction: discard_all_admin_background_jobs_path, formmethod: :delete, class: "button button--secondary") %>
14
+ <% end %>
15
+
16
+ <%= table_with(collection:) do |row, job| %>
17
+ <% row.select %>
18
+ <% row.link(:class_name, url: ->(record) { admin_background_job_path(record.active_job_id) }, label: "Job") %>
19
+ <% row.text(:queue_name, label: "Queue") %>
20
+ <% row.datetime(:updated_at, label: "Last failed") %>
21
+ <% row.text(:error, label: "Error") do %>
22
+ <%= BackgroundJob.new(job).failure_reason %>
23
+ <% end %>
24
+ <% end %>
25
+
26
+ <%= table_pagination_with(collection:) %>