plutonium 0.67.0 → 0.69.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/.claude/skills/plutonium-async-interactions/SKILL.md +41 -2
- data/.claude/skills/plutonium-dashboard/SKILL.md +17 -2
- data/.claude/skills/plutonium-resource/SKILL.md +3 -2
- data/.claude/skills/plutonium-ui/SKILL.md +2 -0
- data/CHANGELOG.md +33 -0
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +3 -1
- data/config/locales/en/async.yml +11 -1
- data/config/locales/en/dashboard.yml +1 -0
- data/config/locales/en/ui.yml +3 -0
- data/docs/public/images/reference/async-progress-page.png +0 -0
- data/docs/public/images/reference/async-running-banner.png +0 -0
- data/docs/reference/behavior/async-interactions.md +62 -5
- data/docs/reference/dashboard/dsl.md +54 -3
- data/docs/reference/generators/lite.md +30 -6
- data/docs/reference/i18n.md +1 -1
- data/docs/reference/resource/definition.md +2 -0
- data/docs/reference/ui/forms.md +23 -0
- data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md +1056 -0
- data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md.tasks.json +49 -0
- data/docs/superpowers/specs/2026-10-15-async-run-pre-check-design.md +208 -0
- data/gemfiles/rails_7.gemfile.lock +5 -5
- data/gemfiles/rails_8.0.gemfile.lock +3 -3
- data/gemfiles/rails_8.1.gemfile.lock +3 -3
- data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +20 -1
- data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +18 -1
- data/lib/plutonium/dashboard/base.rb +80 -1
- data/lib/plutonium/dashboard/card.rb +12 -13
- data/lib/plutonium/dashboard/controller.rb +28 -10
- data/lib/plutonium/dashboard/dsl.rb +59 -2
- data/lib/plutonium/dashboard/evaluation.rb +30 -0
- data/lib/plutonium/dashboard/filter.rb +114 -0
- data/lib/plutonium/dashboard/link.rb +35 -0
- data/lib/plutonium/dashboard/route_resolution.rb +6 -2
- data/lib/plutonium/definition/presentable.rb +4 -1
- data/lib/plutonium/interaction/async/configuration.rb +15 -0
- data/lib/plutonium/interaction/async/context.rb +12 -2
- data/lib/plutonium/interaction/async/executor.rb +54 -0
- data/lib/plutonium/interaction/async/run.rb +128 -14
- data/lib/plutonium/interaction/async/run_definition.rb +5 -4
- data/lib/plutonium/interaction/async/run_policy.rb +5 -5
- data/lib/plutonium/interaction/response/redirect.rb +19 -7
- data/lib/plutonium/resource/query_object.rb +6 -0
- data/lib/plutonium/routing/dashboard_registration.rb +5 -0
- data/lib/plutonium/ui/avatar.rb +2 -2
- data/lib/plutonium/ui/breadcrumbs.rb +26 -8
- data/lib/plutonium/ui/dashboard/board.rb +4 -1
- data/lib/plutonium/ui/dashboard/frame.rb +1 -1
- data/lib/plutonium/ui/dashboard/metric.rb +18 -5
- data/lib/plutonium/ui/dashboard/toolbar.rb +76 -0
- data/lib/plutonium/ui/form/base.rb +20 -0
- data/lib/plutonium/ui/form/components/secure_association.rb +31 -8
- data/lib/plutonium/ui/form/components/secure_association_checkboxes.rb +30 -0
- data/lib/plutonium/ui/form/components/secure_association_choices.rb +123 -0
- data/lib/plutonium/ui/form/components/secure_association_radio_buttons.rb +45 -0
- data/lib/plutonium/ui/form/components/secure_association_toggles.rb +18 -0
- data/lib/plutonium/ui/form/theme.rb +15 -1
- data/lib/plutonium/ui/grid/card.rb +12 -4
- data/lib/plutonium/ui/interaction/async/run_progress.rb +25 -11
- data/lib/plutonium/ui/interaction/async/running_banner.rb +21 -5
- data/lib/plutonium/ui/layout/base.rb +0 -9
- data/lib/plutonium/ui/page/dashboard.rb +1 -0
- data/lib/plutonium/ui/page/index.rb +23 -4
- data/lib/plutonium/ui/table/components/scopes_bar.rb +1 -1
- data/lib/plutonium/ui/table/components/scopes_pills.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/plutonium.gemspec +1 -1
- data/src/css/components.css +9 -0
- data/src/css/font_awesome.css +2328 -0
- data/src/css/plutonium.css +1 -0
- metadata +16 -4
|
@@ -31,6 +31,7 @@ module Plutonium
|
|
|
31
31
|
# GET the mount — the full page.
|
|
32
32
|
def show
|
|
33
33
|
authorize_dashboard!
|
|
34
|
+
ensure_filters_clear_of_path_params!
|
|
34
35
|
set_page_title(current_dashboard_class.label)
|
|
35
36
|
render Plutonium::UI::Page::Dashboard.new(dashboard: current_dashboard)
|
|
36
37
|
end
|
|
@@ -38,6 +39,7 @@ module Plutonium
|
|
|
38
39
|
# GET <mount>/cards/:card — one card, wrapped in its turbo frame.
|
|
39
40
|
def card
|
|
40
41
|
authorize_dashboard!
|
|
42
|
+
ensure_filters_clear_of_path_params!
|
|
41
43
|
card = current_dashboard.visible_card!(params[:card])
|
|
42
44
|
render Plutonium::UI::Dashboard::Frame.new(dashboard: current_dashboard, card:)
|
|
43
45
|
end
|
|
@@ -77,24 +79,40 @@ module Plutonium
|
|
|
77
79
|
)
|
|
78
80
|
end
|
|
79
81
|
|
|
82
|
+
# A path segment of the mount (the tenant on a path-scoped portal, a
|
|
83
|
+
# `(:locale)` scope) belongs to the route, so it cannot be refused when
|
|
84
|
+
# the filter is declared. A filter under that key would read the
|
|
85
|
+
# segment's value and overwrite it on every card's frame URL. The card
|
|
86
|
+
# route's own segments count even when optional and absent from this
|
|
87
|
+
# request, since its URL helper would fill them.
|
|
88
|
+
def ensure_filters_clear_of_path_params!
|
|
89
|
+
segments = (request.path_parameters.keys.map(&:to_sym) | dashboard_card_route.parts) - %i[controller action]
|
|
90
|
+
collision = current_dashboard_class.filters.map(&:key) & segments
|
|
91
|
+
return if collision.empty?
|
|
92
|
+
|
|
93
|
+
raise ArgumentError,
|
|
94
|
+
"#{current_dashboard_class.name} filter #{collision.first.inspect} collides with a path segment of the dashboard's route, pick another name"
|
|
95
|
+
end
|
|
96
|
+
|
|
80
97
|
# The card endpoint's path, threading the entity scope segment through so
|
|
81
|
-
# the frame stays inside the tenant
|
|
82
|
-
#
|
|
98
|
+
# the frame stays inside the tenant, and the dashboard's filter values
|
|
99
|
+
# through as query parameters so the card reads what the page read.
|
|
100
|
+
# Resolved by the route's `dashboard_class` default, so `at:` / `as:` and
|
|
101
|
+
# a scope prefix are honoured.
|
|
83
102
|
def dashboard_card_path(card)
|
|
84
|
-
options =
|
|
103
|
+
options = current_dashboard.filter_values.merge(card: card.key)
|
|
85
104
|
options[scoped_entity_param_key] = params[scoped_entity_param_key] if scoped_to_entity?
|
|
86
105
|
current_engine.routes.url_helpers.public_send(dashboard_card_route_helper, **options)
|
|
87
106
|
end
|
|
88
107
|
|
|
89
108
|
def dashboard_card_route_helper
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
current_engine.routes, current_dashboard_class, action: "card"
|
|
93
|
-
)
|
|
94
|
-
raise "no register_dashboard route found for #{current_dashboard_class.name}" unless name
|
|
109
|
+
:"#{dashboard_card_route.name}_path"
|
|
110
|
+
end
|
|
95
111
|
|
|
96
|
-
|
|
97
|
-
|
|
112
|
+
def dashboard_card_route
|
|
113
|
+
@dashboard_card_route ||=
|
|
114
|
+
Plutonium::Dashboard::RouteResolution.route(current_engine.routes, current_dashboard_class, action: "card") ||
|
|
115
|
+
raise("no register_dashboard route found for #{current_dashboard_class.name}")
|
|
98
116
|
end
|
|
99
117
|
end
|
|
100
118
|
end
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Plutonium
|
|
4
4
|
module Dashboard
|
|
5
|
-
# The author-facing class macros: `metric`, `chart`, `card`, `
|
|
6
|
-
# and `width`. Mixed into {Base}.
|
|
5
|
+
# The author-facing class macros: `metric`, `chart`, `card`, `filter`,
|
|
6
|
+
# `link`, `refresh` and `width`. Mixed into {Base}.
|
|
7
7
|
module DSL
|
|
8
8
|
extend ActiveSupport::Concern
|
|
9
9
|
|
|
@@ -47,6 +47,61 @@ module Plutonium
|
|
|
47
47
|
add_card(key, kind: :custom, options:, block:)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
# The declared filters, in declaration order.
|
|
51
|
+
def filters
|
|
52
|
+
@filters ||= []
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The declared toolbar links, in declaration order.
|
|
56
|
+
def links
|
|
57
|
+
@links ||= []
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A page-level control every card reads, such as a period or a region.
|
|
61
|
+
# The chosen value comes from the query string, is carried onto every
|
|
62
|
+
# lazy card's frame URL so each card sees what the page saw, and is
|
|
63
|
+
# read in a card block with `filter_value(key)`. The page renders the
|
|
64
|
+
# filters as a segmented control above the cards.
|
|
65
|
+
#
|
|
66
|
+
# filter :period, choices: {"7" => "Last 7 days", "30" => "Last 30 days"}, default: "30"
|
|
67
|
+
# metric(:orders) { orders.where(created_at: filter_value(:period).to_i.days.ago..).count }
|
|
68
|
+
#
|
|
69
|
+
# `scopes:` takes a resource instead of choices: "All" plus the scopes
|
|
70
|
+
# its definition declares. `filtered(Post)` then counts what the index
|
|
71
|
+
# would list, and `filtered_url(Post)` opens that list.
|
|
72
|
+
#
|
|
73
|
+
# filter :status, scopes: Post
|
|
74
|
+
# metric(:posts, href: -> { filtered_url(Post) }) { filtered(Post).count }
|
|
75
|
+
def filter(key, choices: nil, scopes: nil, default: nil, label: nil)
|
|
76
|
+
key = key.to_sym
|
|
77
|
+
raise ArgumentError, "#{name || "dashboard"} already declares a filter #{key.inspect}" if find_filter(key)
|
|
78
|
+
if scopes && filters.any? { |filter| filter.resource_class == scopes }
|
|
79
|
+
raise ArgumentError, "#{name || "dashboard"} already declares a scope filter for #{scopes}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
filters << Filter.new(key, choices:, scopes:, default:, label:)
|
|
83
|
+
filters.last
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A link in the toolbar above the cards, such as the dashboard's
|
|
87
|
+
# settings or a full report. `href:` and `condition:` are evaluated on
|
|
88
|
+
# the dashboard instance, like a card's.
|
|
89
|
+
#
|
|
90
|
+
# link :settings, href: -> { resource_url_for(Setting) }, icon: Phlex::TablerIcons::Settings
|
|
91
|
+
def link(key, href:, label: nil, icon: nil, condition: nil)
|
|
92
|
+
key = key.to_sym
|
|
93
|
+
raise ArgumentError, "#{name || "dashboard"} already declares a link #{key.inspect}" if links.any? { |link| link.key == key }
|
|
94
|
+
|
|
95
|
+
links << Link.new(key, href:, label:, icon:, condition:)
|
|
96
|
+
links.last
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @return [Filter, nil]
|
|
100
|
+
def find_filter(key)
|
|
101
|
+
key = key.to_sym
|
|
102
|
+
filters.find { |filter| filter.key == key }
|
|
103
|
+
end
|
|
104
|
+
|
|
50
105
|
# Default refresh interval (seconds) for every lazy card. A card's own
|
|
51
106
|
# `refresh:` overrides it; `refresh: false` on a card opts out.
|
|
52
107
|
def refresh(seconds = UNSET)
|
|
@@ -95,6 +150,8 @@ module Plutonium
|
|
|
95
150
|
def inherited(subclass)
|
|
96
151
|
super
|
|
97
152
|
subclass.instance_variable_set(:@cards, cards.dup)
|
|
153
|
+
subclass.instance_variable_set(:@filters, filters.dup)
|
|
154
|
+
subclass.instance_variable_set(:@links, links.dup)
|
|
98
155
|
subclass.instance_variable_set(:@refresh, @refresh)
|
|
99
156
|
subclass.instance_variable_set(:@width, @width)
|
|
100
157
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Dashboard
|
|
5
|
+
# How a card or toolbar link runs its author-supplied procs and
|
|
6
|
+
# `condition:` against the dashboard instance, so both accept the same
|
|
7
|
+
# forms.
|
|
8
|
+
module Evaluation
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# A zero-arity proc runs on the dashboard instance; a proc taking an
|
|
12
|
+
# argument also receives the dashboard.
|
|
13
|
+
def evaluate_on(dashboard, callable)
|
|
14
|
+
callable.arity.zero? ? dashboard.instance_exec(&callable) : dashboard.instance_exec(dashboard, &callable)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# `condition:` is nil (always), a proc (see {#evaluate_on}), a symbol
|
|
18
|
+
# naming a dashboard method, or a plain value.
|
|
19
|
+
def condition_passes?(dashboard, condition)
|
|
20
|
+
result = case condition
|
|
21
|
+
when nil then true
|
|
22
|
+
when Symbol then dashboard.send(condition)
|
|
23
|
+
when Proc then evaluate_on(dashboard, condition)
|
|
24
|
+
else condition
|
|
25
|
+
end
|
|
26
|
+
result ? true : false
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Dashboard
|
|
5
|
+
# A page-level control every card reads, declared with `filter`. Its value
|
|
6
|
+
# comes from the request's query string under the filter's key, and a value
|
|
7
|
+
# that is not one of the choices reads as the default, so a hand-edited URL
|
|
8
|
+
# can never reach a card block.
|
|
9
|
+
#
|
|
10
|
+
# The choices are either given (`choices:`) or are a resource's scopes
|
|
11
|
+
# (`scopes: Post`): "All" plus every scope the resource's definition
|
|
12
|
+
# declares, labelled and gated the way the index's scope pills are.
|
|
13
|
+
class Filter
|
|
14
|
+
# Keys a filter cannot take: the card route's own params and the options
|
|
15
|
+
# `url_for` reads, all of which the filter values are merged into when
|
|
16
|
+
# building each card's frame URL. A key that is a path segment of the
|
|
17
|
+
# mount (an entity scope, a `(:locale)` scope) is refused per request,
|
|
18
|
+
# since only the route knows it.
|
|
19
|
+
RESERVED_KEYS = %i[
|
|
20
|
+
card dashboard_class controller action format anchor params path_params
|
|
21
|
+
host protocol port subdomain domain tld_length user password
|
|
22
|
+
script_name original_script_name only_path trailing_slash
|
|
23
|
+
use_route _recall
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
# The value of a scope filter's "All" choice.
|
|
27
|
+
ALL = "all"
|
|
28
|
+
|
|
29
|
+
attr_reader :key, :resource_class
|
|
30
|
+
|
|
31
|
+
# @param choices [Hash{#to_s => String, Proc}, nil] value => label, in display order
|
|
32
|
+
# @param scopes [Class, nil] a resource whose definition's scopes are the choices
|
|
33
|
+
# @param default [#to_s, nil] one of the choices; the first (or, for
|
|
34
|
+
# scopes, the definition's `default_scope`, else "All") when nil
|
|
35
|
+
def initialize(key, choices: nil, scopes: nil, default: nil, label: nil)
|
|
36
|
+
@key = key.to_sym
|
|
37
|
+
if RESERVED_KEYS.include?(@key)
|
|
38
|
+
raise ArgumentError, "filter #{@key.inspect}: the name is reserved by the card route, pick another"
|
|
39
|
+
end
|
|
40
|
+
unless choices.nil? ^ scopes.nil?
|
|
41
|
+
raise ArgumentError, "filter #{@key.inspect}: pass exactly one of choices: or scopes:"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
@label = label
|
|
45
|
+
@default = default&.to_s
|
|
46
|
+
|
|
47
|
+
if scopes
|
|
48
|
+
raise ArgumentError, "filter #{@key.inspect}: scopes: takes a resource class, got #{scopes.inspect}" unless scopes.is_a?(Class)
|
|
49
|
+
|
|
50
|
+
@resource_class = scopes
|
|
51
|
+
else
|
|
52
|
+
@choices = choices.to_h.transform_keys(&:to_s).freeze
|
|
53
|
+
raise ArgumentError, "filter #{@key.inspect}: choices must not be empty" if @choices.empty?
|
|
54
|
+
|
|
55
|
+
@default ||= @choices.keys.first
|
|
56
|
+
validate_default!
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def scopes? = !@resource_class.nil?
|
|
61
|
+
|
|
62
|
+
# The control's accessible name.
|
|
63
|
+
def label
|
|
64
|
+
Plutonium::Translation.resolve(@label) || key.to_s.humanize
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# value => label for this request. Labels may be lazy `t` procs. Read it
|
|
68
|
+
# through {Base#filter_choices}, which builds it once per request.
|
|
69
|
+
def choices_for(dashboard)
|
|
70
|
+
scopes? ? scope_choices(dashboard) : @choices
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# An explicit `default:` must be one of the definition's scopes. A
|
|
74
|
+
# default (explicit or the definition's `default_scope`) whose
|
|
75
|
+
# `condition:` hides it from this user reads as "All", as does a
|
|
76
|
+
# `default_scope` the definition never declares as a scope.
|
|
77
|
+
def default_for(dashboard)
|
|
78
|
+
return @default unless scopes?
|
|
79
|
+
|
|
80
|
+
if @default && !dashboard.scope_query_for(resource_class).scope_definitions.key?(@default)
|
|
81
|
+
raise ArgumentError, "filter #{key.inspect}: default #{@default.inspect} is not a scope of #{resource_class}'s definition"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
default = @default || dashboard.resource_definition_for(resource_class).default_scope&.to_s
|
|
85
|
+
dashboard.filter_choices(self).key?(default) ? default : ALL
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The chosen value for this request.
|
|
89
|
+
def value_from(dashboard)
|
|
90
|
+
value = dashboard.params[key].to_s
|
|
91
|
+
dashboard.filter_choices(self).key?(value) ? value : default_for(dashboard)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def scope_choices(dashboard)
|
|
97
|
+
query = dashboard.scope_query_for(resource_class)
|
|
98
|
+
if query.scope_definitions.key?(ALL)
|
|
99
|
+
raise ArgumentError, "filter #{key.inspect}: #{resource_class}'s definition declares a scope named :#{ALL}, which is the filter's \"All\" choice"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
visible = query.scope_definitions.keys.select { |name| query.scope_visible?(name, dashboard.view_context) }
|
|
103
|
+
{ALL => -> { Plutonium::Translation.t("plutonium.ui.table.all") }}
|
|
104
|
+
.merge(visible.to_h { |name| [name, query.scope_label(name)] })
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def validate_default!
|
|
108
|
+
return if @choices.key?(@default)
|
|
109
|
+
|
|
110
|
+
raise ArgumentError, "filter #{key.inspect}: default #{@default.inspect} is not one of #{@choices.keys.inspect}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Dashboard
|
|
5
|
+
# A link in the dashboard's toolbar, declared with `link`: the way to a
|
|
6
|
+
# related page such as the dashboard's settings or a full report.
|
|
7
|
+
class Link
|
|
8
|
+
include Evaluation
|
|
9
|
+
|
|
10
|
+
attr_reader :key, :icon
|
|
11
|
+
|
|
12
|
+
def initialize(key, href:, label: nil, icon: nil, condition: nil)
|
|
13
|
+
@key = key.to_sym
|
|
14
|
+
@href = href
|
|
15
|
+
@label = label
|
|
16
|
+
@icon = icon
|
|
17
|
+
@condition = condition
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def label
|
|
21
|
+
Plutonium::Translation.resolve(@label) || key.to_s.humanize
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Evaluated like a card's `href:`.
|
|
25
|
+
def href_for(dashboard)
|
|
26
|
+
@href.is_a?(Proc) ? evaluate_on(dashboard, @href) : @href
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Evaluated like a card's `condition:`.
|
|
30
|
+
def visible?(dashboard)
|
|
31
|
+
condition_passes?(dashboard, @condition)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -17,13 +17,17 @@ module Plutonium
|
|
|
17
17
|
# @param action [String, Symbol] "show" or "card"
|
|
18
18
|
# @return [Symbol, nil] the route name (e.g. :sales_dashboard_card)
|
|
19
19
|
def route_name(route_set, dashboard_class, action:)
|
|
20
|
-
route
|
|
20
|
+
route(route_set, dashboard_class, action:)&.name&.to_sym
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [ActionDispatch::Journey::Route, nil] the named route itself
|
|
24
|
+
def route(route_set, dashboard_class, action:)
|
|
25
|
+
route_set.routes.find do |r|
|
|
21
26
|
d = r.defaults
|
|
22
27
|
r.name.present? &&
|
|
23
28
|
d[:action].to_s == action.to_s &&
|
|
24
29
|
d[:dashboard_class].to_s == dashboard_class.name
|
|
25
30
|
end
|
|
26
|
-
route&.name&.to_sym
|
|
27
31
|
end
|
|
28
32
|
end
|
|
29
33
|
end
|
|
@@ -36,8 +36,11 @@ module Plutonium
|
|
|
36
36
|
# Returns the label for the interaction.
|
|
37
37
|
#
|
|
38
38
|
# @return [String] The label defined in the presentation metadata or a default generated from the class name.
|
|
39
|
+
# The conventional "Interaction" suffix is dropped: ImportPostsInteraction reads "Import Posts".
|
|
40
|
+
# Only a suffix: a class named just Interaction keeps its name rather than going blank.
|
|
39
41
|
def label
|
|
40
|
-
Plutonium::Translation.resolve(presentation_metadata[:label]) ||
|
|
42
|
+
Plutonium::Translation.resolve(presentation_metadata[:label]) ||
|
|
43
|
+
name.demodulize.sub(/(?<=.)Interaction\z/, "").titleize
|
|
41
44
|
end
|
|
42
45
|
|
|
43
46
|
# Returns the icon for the interaction.
|
|
@@ -27,10 +27,25 @@ module Plutonium
|
|
|
27
27
|
# This is the knob for which backend does that, for runs alone.
|
|
28
28
|
attr_accessor :attachment_backend
|
|
29
29
|
|
|
30
|
+
# @return [Integer] how many pre_check rejections a run stores. The rest
|
|
31
|
+
# are counted and reported as one "and N more" entry, so a file that is
|
|
32
|
+
# bad on every row cannot write tens of thousands of entries into the
|
|
33
|
+
# errors_log column (and onto the progress page). Must be a positive Integer.
|
|
34
|
+
attr_accessor :max_rejections
|
|
35
|
+
|
|
36
|
+
# @return [ActiveSupport::Duration] how long a run stays in the running
|
|
37
|
+
# banner after it finishes, for the user who started it. A dispatch
|
|
38
|
+
# sends that user back to the index, and a short run (or one its
|
|
39
|
+
# pre_check rejected) settles before the page loads; without this it
|
|
40
|
+
# would never show there at all.
|
|
41
|
+
attr_accessor :recent_runs_window
|
|
42
|
+
|
|
30
43
|
def initialize
|
|
31
44
|
@enabled = false
|
|
32
45
|
@queue = :default
|
|
33
46
|
@stall_after = 1.hour
|
|
47
|
+
@max_rejections = 100
|
|
48
|
+
@recent_runs_window = 10.minutes
|
|
34
49
|
end
|
|
35
50
|
end
|
|
36
51
|
end
|
|
@@ -225,9 +225,14 @@ module Plutonium
|
|
|
225
225
|
#
|
|
226
226
|
# @return [Targets]
|
|
227
227
|
def targets
|
|
228
|
+
unless run.targeted?
|
|
229
|
+
raise UnresolvableError,
|
|
230
|
+
"run #{run.id} (#{run.class}) is not a targeted run; it has no targets to resolve"
|
|
231
|
+
end
|
|
232
|
+
|
|
228
233
|
unless run.target_type
|
|
229
234
|
raise UnresolvableError,
|
|
230
|
-
"run #{run.id} (#{run.class}) has no target_type; it
|
|
235
|
+
"run #{run.id} (#{run.class}) has no target_type; it cannot resolve its targets"
|
|
231
236
|
end
|
|
232
237
|
|
|
233
238
|
key = target_class.primary_key
|
|
@@ -380,8 +385,13 @@ module Plutonium
|
|
|
380
385
|
#
|
|
381
386
|
# Only meaningful for a targeted run — untargeted work has no target
|
|
382
387
|
# resource and therefore no policy to pin.
|
|
388
|
+
#
|
|
389
|
+
# Keyed on Run#targeted?, not on target_type: an opaque run dispatched
|
|
390
|
+
# from a resource still records target_type (it is what puts the run in
|
|
391
|
+
# that resource's banner), but it resolves no targets, so it pins no
|
|
392
|
+
# policy and there is nothing here to verify.
|
|
383
393
|
def verify_policy!
|
|
384
|
-
return
|
|
394
|
+
return unless run.targeted? && run.target_type
|
|
385
395
|
|
|
386
396
|
# A targeted run with no recorded policy is not "unasserted", it is
|
|
387
397
|
# unverifiable: there is nothing to compare today's lookup against, and
|
|
@@ -190,6 +190,8 @@ module Plutonium
|
|
|
190
190
|
# the framework is meant to call, and it must not change what the
|
|
191
191
|
# executor does with it.
|
|
192
192
|
def perform_opaque
|
|
193
|
+
return unless pre_check_passed?
|
|
194
|
+
|
|
193
195
|
run.send(:perform)
|
|
194
196
|
run.finish!
|
|
195
197
|
end
|
|
@@ -200,6 +202,17 @@ module Plutonium
|
|
|
200
202
|
|
|
201
203
|
return refuse_partial_batch(resolved) if unresolved?(resolved) && !continue?
|
|
202
204
|
|
|
205
|
+
# A resumed run whose earlier pass already applied a target was
|
|
206
|
+
# approved by that pass's check: targets are only performed after it
|
|
207
|
+
# passes. Re-checking the remainder could reject a run that is
|
|
208
|
+
# half-applied and report "nothing was processed". Opaque runs restart
|
|
209
|
+
# from scratch, so they re-check.
|
|
210
|
+
#
|
|
211
|
+
# After record_unresolved, so a rejected :continue run still reports
|
|
212
|
+
# the targets that had gone; those count toward progress_done, though
|
|
213
|
+
# nothing was performed.
|
|
214
|
+
return unless run.any_target_applied? || pre_check_passed?(resolved.records)
|
|
215
|
+
|
|
203
216
|
if transactional?
|
|
204
217
|
perform_all_or_nothing(resolved.records)
|
|
205
218
|
run.finish!
|
|
@@ -217,6 +230,47 @@ module Plutonium
|
|
|
217
230
|
end
|
|
218
231
|
end
|
|
219
232
|
|
|
233
|
+
# Runs the run's pre_check with #reject live, and fails the run with
|
|
234
|
+
# whatever it rejected. Before any work: a rejected run touches no
|
|
235
|
+
# target. Outside the :transactional batch, which never opens for a
|
|
236
|
+
# rejected run.
|
|
237
|
+
#
|
|
238
|
+
# An exception from the check is NOT a rejection. It propagates to #call
|
|
239
|
+
# and fails the run the ordinary way, so "your input is wrong" and "the
|
|
240
|
+
# check itself broke" stay distinguishable (Run#outcome).
|
|
241
|
+
#
|
|
242
|
+
# send, for the reason #perform_opaque gives.
|
|
243
|
+
#
|
|
244
|
+
# @return [Boolean] whether the work may go ahead
|
|
245
|
+
def pre_check_passed?(*args)
|
|
246
|
+
verify_pre_check_arity!(args.size)
|
|
247
|
+
|
|
248
|
+
messages, overflow = run.collect_rejections { run.send(:pre_check, *args) }
|
|
249
|
+
return true if messages.empty? && overflow.zero?
|
|
250
|
+
|
|
251
|
+
run.reject_run!(messages, overflow: overflow)
|
|
252
|
+
false
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# A targeted check declared opaque-style (or the reverse) would raise a
|
|
256
|
+
# bare "wrong number of arguments" from inside the executor, naming
|
|
257
|
+
# neither the class nor the fix.
|
|
258
|
+
#
|
|
259
|
+
# Raised as NotImplementedError ("the run class is written wrong"), which
|
|
260
|
+
# #call records like any other failure.
|
|
261
|
+
def verify_pre_check_arity!(given)
|
|
262
|
+
params = run.method(:pre_check).parameters.group_by(&:first)
|
|
263
|
+
required = params.fetch(:req, []).size
|
|
264
|
+
positional = required + params.fetch(:opt, []).size
|
|
265
|
+
fits = params[:keyreq].nil? && required <= given && (params.key?(:rest) || positional >= given)
|
|
266
|
+
return if fits
|
|
267
|
+
|
|
268
|
+
raise NotImplementedError, I18n.t(
|
|
269
|
+
"plutonium.async.executor.pre_check_arity.#{run.targeted? ? "targeted" : "opaque"}",
|
|
270
|
+
run_class: run.class
|
|
271
|
+
)
|
|
272
|
+
end
|
|
273
|
+
|
|
220
274
|
# @return [Integer, nil] how many records were never attempted, if the
|
|
221
275
|
# failure policy stopped the loop early — nil if it ran to the end
|
|
222
276
|
def perform_each(records)
|