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.
- checksums.yaml +4 -4
- data/app/assets/builds/katalyst/koi.esm.js +45 -6
- data/app/assets/builds/katalyst/koi.js +45 -6
- data/app/assets/builds/katalyst/koi.min.js +1 -1
- data/app/assets/builds/katalyst/koi.min.js.map +1 -1
- data/app/assets/stylesheets/koi/forms/checkboxes.css +2 -2
- data/app/assets/stylesheets/koi/forms/combobox.css +2 -2
- data/app/assets/stylesheets/koi/forms/index.css +2 -0
- data/app/assets/stylesheets/koi/forms/input.css +9 -11
- data/app/assets/stylesheets/koi/forms/lexxy.css +53 -0
- data/app/assets/stylesheets/koi/forms/radios.css +2 -2
- data/app/assets/stylesheets/koi/forms/textarea.css +1 -1
- data/app/assets/stylesheets/koi/forms/trix.css +30 -6
- data/app/assets/stylesheets/koi/index.css +1 -0
- data/app/controllers/admin/active_storage/direct_uploads_controller.rb +9 -0
- data/app/controllers/admin/background_jobs_controller.rb +68 -0
- data/app/controllers/admin/feature_flags_controller.rb +67 -0
- data/app/controllers/admin/recurring_tasks_controller.rb +67 -0
- data/app/helpers/admin/background_jobs_helper.rb +15 -0
- data/app/helpers/koi/tags/trix_toolbar.rb +64 -0
- data/app/javascript/koi/application.js +4 -1
- data/app/javascript/koi/utils/lexxy.js +23 -0
- data/app/javascript/koi/utils/trix.js +17 -0
- data/app/models/background_job.rb +98 -0
- data/app/models/feature_flag.rb +128 -0
- data/app/models/recurring_task.rb +31 -0
- data/app/views/admin/background_jobs/_navigation.html.erb +8 -0
- data/app/views/admin/background_jobs/blocked.html.erb +17 -0
- data/app/views/admin/background_jobs/completed.html.erb +17 -0
- data/app/views/admin/background_jobs/failed.html.erb +26 -0
- data/app/views/admin/background_jobs/in_progress.html.erb +17 -0
- data/app/views/admin/background_jobs/index.html.erb +22 -0
- data/app/views/admin/background_jobs/scheduled.html.erb +17 -0
- data/app/views/admin/background_jobs/show.html.erb +41 -0
- data/app/views/admin/feature_flags/index.html.erb +14 -0
- data/app/views/admin/feature_flags/new.html.erb +14 -0
- data/app/views/admin/feature_flags/show.html.erb +30 -0
- data/app/views/admin/recurring_tasks/index.html.erb +20 -0
- data/app/views/admin/recurring_tasks/show.html.erb +33 -0
- data/app/views/layouts/action_text/contents/_content.html.erb +3 -0
- data/config/ci.rb +10 -0
- data/config/importmap.rb +6 -4
- data/config/routes.rb +32 -0
- data/db/migrate/20230531063707_update_admin_users.rb +2 -0
- data/db/migrate/20260525111759_clean_up_admin_user_timestamps.rb +2 -0
- data/lib/generators/koi/admin_route/templates/initializer.rb.tt +4 -3
- data/lib/koi/action_text.rb +112 -0
- data/lib/koi/config.rb +3 -1
- data/lib/koi/engine.rb +23 -0
- data/lib/koi/form/builder.rb +27 -8
- data/lib/koi/menu.rb +4 -0
- data/lib/koi.rb +12 -6
- data/spec/factories/admin_sessions.rb +9 -0
- metadata +53 -5
- 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 "
|
|
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:) %>
|